You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/10/23 07:51:53 UTC

(maka) branch rust-preview updated: feat(nsh): sync paced lists and embedding host capabilities

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/rust-preview by this push:
     new 32fc601cbb feat(nsh): sync paced lists and embedding host capabilities
32fc601cbb is described below

commit 32fc601cbb80880ed0469dbdc605c6db65891846
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Fri Oct 23 16:51:38 2026 +0900

    feat(nsh): sync paced lists and embedding host capabilities
    
    Integrate longbridge/nsh 7ed244990097dbbec6b177e26dbc357cb0dba654 by vendor-directory three-way merge. Add visible-first paced list binding, mobile theme overrides and hardware shortcuts, and the opt-in nsh/host API.
    
    Preserve terminal keyboard behavior, scroll-intent event identity, sticky/source anchoring, and the list source-publication fence. Keep Maka on its existing typed maka/host registry with generic NSH host capabilities disabled. Include red/green source-change regressions and retain settled test coverage.
    
    Refresh source provenance and only affected NSH notice inputs. Generated source hashes remain upstream bytes rather than local adaptations.
    
    Generated-by: OpenAI Codex
---
 products/maka/cli/notices.json                     | 269 +++----
 third-party/nsh/AGENTS.md                          |   6 +-
 third-party/nsh/Cargo.lock                         |   1 +
 third-party/nsh/SOURCE.md                          |   6 +-
 third-party/nsh/crates/nsh-base/src/lib.rs         |   2 +-
 third-party/nsh/crates/nsh-base/src/theme.rs       |  53 +-
 third-party/nsh/crates/nsh-bundler/src/bundle.rs   |  40 +
 third-party/nsh/crates/nsh-cli/src/dev.rs          |   3 +-
 third-party/nsh/crates/nsh-cli/src/main.rs         |   7 +
 third-party/nsh/crates/nsh-cli/src/package.rs      |  76 ++
 third-party/nsh/crates/nsh-cli/src/test.rs         |  26 +
 third-party/nsh/crates/nsh-cli/src/test_web.rs     |   4 +-
 third-party/nsh/crates/nsh-cli/src/web.rs          | 141 +++-
 third-party/nsh/crates/nsh-cli/src/web/dev.html    |  14 +
 third-party/nsh/crates/nsh-cli/src/web/index.html  |  13 +-
 third-party/nsh/crates/nsh-core/src/access.rs      | 137 +++-
 .../nsh/crates/nsh-core/src/core/components.rs     |  84 +++
 third-party/nsh/crates/nsh-core/src/core/list.rs   | 295 +++++++-
 third-party/nsh/crates/nsh-core/src/core/mod.rs    |  95 ++-
 third-party/nsh/crates/nsh-core/src/core/tests.rs  |   6 +-
 third-party/nsh/crates/nsh-core/src/js/host.rs     |   3 +-
 third-party/nsh/crates/nsh-core/src/js/mod.rs      |   1 +
 third-party/nsh/crates/nsh-core/src/keymap.rs      | 182 +++++
 third-party/nsh/crates/nsh-core/src/ops.rs         |  17 +
 third-party/nsh/crates/nsh-core/src/platform.rs    |   4 +
 .../nsh/crates/nsh-core/src/runtime/host.rs        |  89 +++
 third-party/nsh/crates/nsh-core/src/runtime/mod.rs |   7 +-
 third-party/nsh/crates/nsh-core/src/ui/ambient.rs  |  16 +
 third-party/nsh/crates/nsh-core/src/ui/env.rs      |   4 +
 third-party/nsh/crates/nsh-core/src/ui/services.rs |  16 +
 third-party/nsh/crates/nsh-core/src/ui/task.rs     |  10 +
 third-party/nsh/crates/nsh-engine/src/lib.rs       | 151 +++-
 .../nsh-engine/tests/engine/component_bench.rs     |   1 +
 .../crates/nsh-engine/tests/engine/components.rs   |   9 +-
 .../nsh/crates/nsh-engine/tests/engine/host.rs     | 476 ++++++++++++
 .../nsh/crates/nsh-engine/tests/engine/list.rs     | 357 ++++++++-
 .../crates/nsh-engine/tests/engine/list_bench.rs   |   5 +
 .../nsh/crates/nsh-engine/tests/engine/logs.rs     |  11 +
 .../nsh/crates/nsh-engine/tests/engine/main.rs     |   1 +
 .../nsh/crates/nsh-engine/tests/engine/theme.rs    | 108 ++-
 third-party/nsh/crates/nsh-js/src/host.rs          |   1 +
 third-party/nsh/crates/nsh-js/src/keymap.rs        |   4 +-
 third-party/nsh/crates/nsh-js/src/modules.rs       |   7 +
 .../nsh/crates/nsh-js/src/platform_modules.rs      | 213 +++++-
 .../java/com/longbridge/nsh/Native.java            |  17 +
 .../java/com/longbridge/nsh/NshView.java           |  61 ++
 .../java/com/longbridge/nsh/TestAgent.java         |  19 +
 .../nsh/crates/nsh-platform-android/src/agent.rs   |  34 +
 .../nsh/crates/nsh-platform-android/src/encode.rs  |  11 +-
 .../nsh/crates/nsh-platform-android/src/host.rs    |  79 +-
 .../nsh/crates/nsh-platform-android/src/keymap.rs  | 161 ++++
 .../nsh/crates/nsh-platform-android/src/lib.rs     |   1 +
 .../nsh/crates/nsh-platform-ios/include/nsh.h      |   8 +
 .../nsh/crates/nsh-platform-ios/src/agent.rs       |  39 +
 .../nsh/crates/nsh-platform-ios/src/embed.rs       |  32 +
 .../nsh/crates/nsh-platform-ios/src/input.rs       |  52 ++
 .../nsh/crates/nsh-platform-ios/src/keymap.rs      | 288 ++++++++
 third-party/nsh/crates/nsh-platform-ios/src/lib.rs |   1 +
 .../nsh/crates/nsh-platform-ios/src/mount.rs       |   7 +-
 .../nsh/crates/nsh-platform-ios/src/surface.rs     |  75 +-
 .../nsh/crates/nsh-platform-ios/src/synth.rs       |  35 +
 .../nsh/crates/nsh-platform-ios/src/target.rs      |  10 +-
 .../nsh/crates/nsh-platform-ios/src/view.rs        |  22 +
 .../nsh-platform-ios/swift/NativeShell.swift       |  14 +
 .../nsh/crates/nsh-platform-linux/src/app.rs       |  15 +
 .../nsh/crates/nsh-platform-linux/src/surface.rs   |  14 +-
 .../nsh/crates/nsh-platform-macos/src/app.rs       |  15 +
 .../nsh/crates/nsh-platform-macos/src/surface.rs   |  95 ++-
 .../nsh/crates/nsh-platform-tui/src/surface.rs     |   1 +
 .../nsh/crates/nsh-platform-web/src/encode.rs      |  15 +
 third-party/nsh/crates/nsh-platform-web/src/lib.rs |   9 +-
 third-party/nsh/crates/nsh-platform-web/web/nsh.js |  67 +-
 .../nsh/crates/nsh-platform-web/web/nsh.test.mjs   | 167 +++++
 .../nsh/crates/nsh-platform-windows/src/app.rs     |  15 +
 .../nsh/crates/nsh-platform-windows/src/surface.rs |  14 +-
 third-party/nsh/crates/nsh-testing/Cargo.toml      |   7 +-
 third-party/nsh/crates/nsh-testing/src/agent.rs    |  10 +-
 .../crates/nsh-testing/src/headless/keyboard.rs    |  18 +-
 .../nsh/crates/nsh-testing/src/headless/mod.rs     |  47 +-
 third-party/nsh/crates/nsh-testing/src/lib.rs      |   2 +
 .../nsh/crates/nsh-testing/src/page_host.rs        | 332 +++++++++
 third-party/nsh/crates/nsh-testing/src/run.rs      |  20 +
 third-party/nsh/crates/nsh-testing/src/runner.rs   |   5 +-
 .../crates/nsh-testing/tests/testing/host/App.tsx  |  40 +
 .../nsh-testing/tests/testing/host/Broken.test.tsx |   5 +
 .../nsh-testing/tests/testing/host/Host.test.tsx   |  51 ++
 .../nsh-testing/tests/testing/host/NoHost.test.tsx |   8 +
 .../crates/nsh-testing/tests/testing/host/host.js  |  17 +
 .../crates/nsh-testing/tests/testing/host/none.js  |   1 +
 .../crates/nsh-testing/tests/testing/host/nsh.json |   5 +
 .../nsh/crates/nsh-testing/tests/testing/main.rs   |  19 +
 third-party/nsh/docs/dev/elements.md               |   7 +-
 third-party/nsh/docs/dev/embedding.md              |  16 +
 third-party/nsh/docs/dev/js-modules.md             |   1 +
 third-party/nsh/docs/dev/macos.md                  |   6 +
 third-party/nsh/docs/dev/performance.md            |  23 +
 third-party/nsh/docs/dev/pitfalls.md               |  14 +
 third-party/nsh/docs/dev/sandbox.md                |  14 +-
 third-party/nsh/docs/dev/scrolling-and-lists.md    |   8 +
 third-party/nsh/docs/dev/tooling.md                |  17 +-
 third-party/nsh/docs/dev/web.md                    |  34 +-
 third-party/nsh/examples/embed-android/README.md   |   8 +
 .../com/longbridge/nsh/embeddemo/MainActivity.java |  15 +-
 third-party/nsh/examples/embed-ios/README.md       |   8 +
 third-party/nsh/examples/embed-ios/main.swift      |  29 +-
 third-party/nsh/examples/embedded/App.tsx          |   7 +-
 .../examples/gallery/tests/fixtures/mention.tsx    |  39 +
 .../nsh/examples/gallery/tests/mention.test.tsx    |  15 +
 third-party/nsh/examples/host-echo/App.test.tsx    |  17 +
 third-party/nsh/examples/host-echo/App.tsx         |  34 +
 third-party/nsh/examples/host-echo/host.js         |  13 +
 third-party/nsh/examples/host-echo/nsh.json        |  10 +
 third-party/nsh/examples/host-echo/tsconfig.json   |   4 +
 .../nsh/tests/compat/cases/core/core-list.tsx      |  11 +-
 third-party/nsh/tests/compat/cases/core/script.txt |   5 +
 third-party/nsh/tests/compat/cases/modules/App.tsx |   2 +
 .../nsh/tests/compat/cases/modules/mod-host.tsx    |  35 +
 .../nsh/tests/compat/cases/modules/script.txt      |  11 +
 third-party/nsh/upstream.json                      | 813 +++++++++++++++++----
 third-party/nsh/website/docs/android.md            |  19 +
 third-party/nsh/website/docs/cli.md                |   3 +-
 third-party/nsh/website/docs/ios.md                |  22 +
 third-party/nsh/website/docs/linux.md              |   5 +
 third-party/nsh/website/docs/macos.md              |   5 +
 third-party/nsh/website/docs/project-structure.md  |   1 +
 .../nsh/website/docs/sandbox-and-capabilities.md   |  30 +-
 third-party/nsh/website/docs/tailwind.md           |   2 +-
 third-party/nsh/website/docs/testing-and-trace.md  |  30 +-
 third-party/nsh/website/docs/web.md                |  60 ++
 third-party/nsh/website/docs/windows.md            |   5 +
 third-party/nsh/website/modules/index.md           |   1 +
 third-party/nsh/website/modules/nsh-host.md        |  91 +++
 third-party/nsh/website/modules/nsh-keymap.md      |   8 +-
 third-party/nsh/website/zh-CN/docs/android.md      |  15 +
 third-party/nsh/website/zh-CN/docs/cli.md          |   3 +-
 third-party/nsh/website/zh-CN/docs/ios.md          |  18 +
 third-party/nsh/website/zh-CN/docs/linux.md        |   2 +
 third-party/nsh/website/zh-CN/docs/macos.md        |   2 +
 .../nsh/website/zh-CN/docs/project-structure.md    |   1 +
 .../website/zh-CN/docs/sandbox-and-capabilities.md |  29 +-
 third-party/nsh/website/zh-CN/docs/tailwind.md     |   2 +-
 .../nsh/website/zh-CN/docs/testing-and-trace.md    |  26 +-
 third-party/nsh/website/zh-CN/docs/web.md          |  61 +-
 third-party/nsh/website/zh-CN/docs/windows.md      |   2 +
 third-party/nsh/website/zh-CN/modules/index.md     |   1 +
 third-party/nsh/website/zh-CN/modules/nsh-host.md  |  91 +++
 .../nsh/website/zh-CN/modules/nsh-keymap.md        |   8 +-
 147 files changed, 6290 insertions(+), 420 deletions(-)

diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index ddce7829e3..e6b5a53f88 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -1022,14 +1022,14 @@
     "third-party/llrt-crypto/src/subtle/verify.rs": "50b94c3f7476085fa15dfee704c1ae86b45746a715ce31fee0a8a3568562e3a5",
     "third-party/llrt-crypto/src/subtle/wrapping.rs": "438a0b83ff2309e3c040bb3d4d875b8bdd847f338455323427eeb1d1b07b2db6",
     "third-party/llrt-crypto/upstream.json": "eaf8f0a0b7b6027ea1656c21bf021885168681479b03ef925a622dfa788aa7bf",
-    "third-party/nsh/AGENTS.md": "6670fd3b737cba0e6a8afe4fa5cab194a88192dcb41a00a67fd1753893bcbd88",
+    "third-party/nsh/AGENTS.md": "b5c12bc94c1466fd6f39a0c34e895fb306724303ca2ba095e3309f5d58f95899",
     "third-party/nsh/CLAUDE.md": "336cc4fbf19beaada7ccf9986414fa91851a8d7a07dfb3ccbe800a69eed0ab49",
     "third-party/nsh/CONTRIBUTING.md": "fb27100fec01ba22dde6da1546f9cd761b8e23bec7d47eca87146c7991e85de2",
-    "third-party/nsh/Cargo.lock": "f1765287f4b60baad0fe758919f878f9da61b6ac0f3e8a17ed9488b3608955c6",
+    "third-party/nsh/Cargo.lock": "6c2b50eeed7acb3bee579b0a5ea55db19f468b6d542c7ed6682ef80c18d6a2e8",
     "third-party/nsh/Cargo.toml": "8b8808f82642ac958bb922917b2d986b485c12fcc7667997ca2123c83ec06a92",
     "third-party/nsh/LICENSE-APACHE": "ec75427ad955e3c740f22e7dc3f6db6fc5b2cc1d9135c2af91cece8dd47a2d38",
     "third-party/nsh/README.md": "ff632ba97e2af5f8f0d69ed03250dd2a7d90f53da48b7a07976394d37f85aacb",
-    "third-party/nsh/SOURCE.md": "031818282de156628cee8236b5ebb455a5afa6e29aa294f7ab653d5622f2ad33",
+    "third-party/nsh/SOURCE.md": "408a39295eab27b5ec27183b4b83bf65fd8ddba622a343d9d591f284432908d4",
     "third-party/nsh/crates/gpui/Cargo.lock": "2b8a4fd17ecabf2cb11520392322bf5aaa2736a8f79989d9e55ab8795360487b",
     "third-party/nsh/crates/gpui/Cargo.toml": "1d53eec1242cd480700a9e2ffd5c013f21c8b0c3207cb9544733fd5ea375f0f1",
     "third-party/nsh/crates/gpui/nsh-gpui-host/Cargo.toml": "f36b982a17b38bd1d91760a2d8d9db06b42db949c227878cef294a5ff2100844",
@@ -1114,7 +1114,7 @@
     "third-party/nsh/crates/nsh-base/src/dock/value.rs": "a8f75f77b4b770755247eac7c07d4340e49541bc3dfd164fbc189fa27d09771e",
     "third-party/nsh/crates/nsh-base/src/dock/value/tests.rs": "3c730d09df8502d04211f7f3f00ede95be06ff1efac11deebc3d802ef8993449",
     "third-party/nsh/crates/nsh-base/src/keyboard.rs": "797de960194d5a7c29c0f88c1e14429895067d68ccd00987777615f73673e856",
-    "third-party/nsh/crates/nsh-base/src/lib.rs": "de0c6516d558654521318e20b97ad0d1609de40d0801a13347903f6aef629f8d",
+    "third-party/nsh/crates/nsh-base/src/lib.rs": "290ef42b9145ceffe02cf046d72ed4d007068ff7690f9891e7816460a2106dde",
     "third-party/nsh/crates/nsh-base/src/menu.rs": "ba6339940ab1f77de454cd32a3d01fcaa370f2234bf44352a8ade0d043f1cd8a",
     "third-party/nsh/crates/nsh-base/src/navigation.rs": "0fa4be89464b3feffacb521991192ca5d71db53a0021f90461c4e37df77c1278",
     "third-party/nsh/crates/nsh-base/src/popover.rs": "d42902bb2396513bc9eeb98b7e584cee2b99647523779e2e888bf4b0fbbf1063",
@@ -1144,14 +1144,14 @@
     "third-party/nsh/crates/nsh-base/src/text_view/mod.rs": "f6d200e37c21aece2cf3355781577539a9830118d3e55d34356a55776d3b5a7b",
     "third-party/nsh/crates/nsh-base/src/text_view/parts.rs": "1cc12fdfdb815ea5eb44ceaa37467849b9005d99353809150379708f4b7c1add",
     "third-party/nsh/crates/nsh-base/src/text_view/view.rs": "e1162a8a0e79876951ffc30fd3f930346f2cfb68969d03c284ca350c9e5e4105",
-    "third-party/nsh/crates/nsh-base/src/theme.rs": "cf2c7a0d55987301434e8c61a96d4190c0945fed9c52632076c50812aef8b432",
+    "third-party/nsh/crates/nsh-base/src/theme.rs": "2eedb3df1738c45b6c295f18cd5a8a2f4959195c87d9c304fa2d8e5ecd8bb9c5",
     "third-party/nsh/crates/nsh-base/src/tooltip.rs": "5ee4deb7358e62d9e88b0287d06624e538c21619229b4cace4ecda09c214c5a0",
     "third-party/nsh/crates/nsh-base/tests/base/dock.rs": "d6657e7a078fec7ee3f3640540b8f2935255d68a176af79444713574d8177c27",
     "third-party/nsh/crates/nsh-base/tests/base/main.rs": "d4bd54e22c71fee7a6ea0f4c242b77aaafd24c7af9773b86db6d26b6e1f9b699",
     "third-party/nsh/crates/nsh-base/tests/base/resizable.rs": "77d40746a511c641b8332fa3187ecc890f278cac1582a4c5a613476fbcf200b7",
     "third-party/nsh/crates/nsh-base/tests/base/sheet.rs": "2a7cfda4b7087d85df7f4e65e23c2386fe3b91a868339aa7dba159e42e27f56a",
     "third-party/nsh/crates/nsh-bundler/Cargo.toml": "8284a211f8f7fbf7fc14de5ac6739a5b837ad2db30729bdbffd70001df95000f",
-    "third-party/nsh/crates/nsh-bundler/src/bundle.rs": "812e38f4d8522267f832f4381d388c2ee8226729beb9605ceeea610c35600721",
+    "third-party/nsh/crates/nsh-bundler/src/bundle.rs": "19e06f32851a6f70a1ae37b709bf53c26d2165d08d9692654bac63c15e321668",
     "third-party/nsh/crates/nsh-bundler/src/classes.rs": "b6fc4affe5ca9ee0dc38b0b00579375dd2c6d6684424e4b6aa08371ab965fb40",
     "third-party/nsh/crates/nsh-bundler/src/compile.rs": "a8c3881697eedf15074495c7bee24c5bf79af9667ecf5a99fb6089dabdeb6445",
     "third-party/nsh/crates/nsh-bundler/src/hot.rs": "c3655c9dc2c1c561831bf33d0a7915bfa596063ac130cca4b79e37a021d76c10",
@@ -1171,12 +1171,12 @@
     "third-party/nsh/crates/nsh-cli/src/build.rs": "930e9e61189b299f9b4121db7810c48a463f50d2e45d3dae2a88d820ab08d309",
     "third-party/nsh/crates/nsh-cli/src/cdp.rs": "a7df2735afb1013b4946b9d21393c9d72e1afa2e28e23667eaa242eb5878681d",
     "third-party/nsh/crates/nsh-cli/src/check.rs": "4caecd505d9a8464a99eb6ebc3a34e047aafa205c86e794524f865b4761677ff",
-    "third-party/nsh/crates/nsh-cli/src/dev.rs": "e25c40cf05389b1c2eb8d0de22a5d4fd33492eecd1ef0c0a5ec96d36421dc4f3",
+    "third-party/nsh/crates/nsh-cli/src/dev.rs": "ec8d33fe7cf391b26e6425877ad568f45fdeaf213b55268961e9fc5aff0171bb",
     "third-party/nsh/crates/nsh-cli/src/entry.rs": "8880023842ca765687e3ce6f356a9f8498881fa8d90f459c025702964098ea41",
     "third-party/nsh/crates/nsh-cli/src/init.rs": "953623491dc2fc64dc1f72c113b8d18edb564271b54a3e62ca7ff8648bb41f08",
     "third-party/nsh/crates/nsh-cli/src/logger.rs": "09574fa1381d8dc650d820f6953541601180698657cf3eda2f1429165122840d",
-    "third-party/nsh/crates/nsh-cli/src/main.rs": "feb8ecbb8f3e4b33cd29e3df840198f28cead2c3341bce22407d234b734ed46e",
-    "third-party/nsh/crates/nsh-cli/src/package.rs": "40c91f4ec6e23cacd7fc256c3bed159a35a52e833de67f21797fb9917fbd542d",
+    "third-party/nsh/crates/nsh-cli/src/main.rs": "95ad9f71238edcb12aedad8c615201368147605c18731ea064e72dbdc036c985",
+    "third-party/nsh/crates/nsh-cli/src/package.rs": "cf70454089f44c682f7e30ea6ac93a8fa2219956f37be0ebf57a8453dd0585ee",
     "third-party/nsh/crates/nsh-cli/src/run.rs": "077ebada325b5ac5470a21a4be510a2507c28f47711be42844e53914cf8f9f51",
     "third-party/nsh/crates/nsh-cli/src/runtime.rs": "5c0c87e5f3793083bc66dcd67c0faff2362de0ee788145fa00198cd29c5b9628",
     "third-party/nsh/crates/nsh-cli/src/rust_app.rs": "ada2a98a27628f508eea1fe818a2dc4252a7c60b1d73179dd3eeceba076ddd8e",
@@ -1194,19 +1194,19 @@
     "third-party/nsh/crates/nsh-cli/src/templates/tsconfig/3.json": "832b63d1c87c6381f7d49d7f55394ce4ed2503d3cf0d0124b81a88ec9621a330",
     "third-party/nsh/crates/nsh-cli/src/templates/tsconfig/4.json": "d7972c656ce45ce90df97fd5370c26c660c5fe8fca5355cf24822498039685ee",
     "third-party/nsh/crates/nsh-cli/src/templates/tsconfig/5.json": "5d6ee953f0366848a5cbe9fe9d0a6b31637d14e8dfb20761e7730c9228753b1a",
-    "third-party/nsh/crates/nsh-cli/src/test.rs": "ebf44d8fa58c05c0375a0cd7586a3f0ee38968f9c2897682c7411bae8599bce4",
+    "third-party/nsh/crates/nsh-cli/src/test.rs": "a938625c17784be9648ab3525bb76b3e549c73c8f3173a4028db531f0fba2867",
     "third-party/nsh/crates/nsh-cli/src/test_android.rs": "6f3ca5c6caf8e46d48fa7d3046e82a9d1d5f50efd1a6a68a08f3c0472295c48c",
     "third-party/nsh/crates/nsh-cli/src/test_host.rs": "1d180f234bed65f2e53fbae83e3b9547644e17d3d5bfeb165a12e15b874cf892",
     "third-party/nsh/crates/nsh-cli/src/test_ios.rs": "aa5e20afee9e655471d7971c31f94ed9d3973784dc13edc20019f3ef45de3ef5",
-    "third-party/nsh/crates/nsh-cli/src/test_web.rs": "0f4d0ce39f86ffd4a4de2e6d2d6b9d6f69427aaf10e7c1a3dfa397fc440bdba9",
+    "third-party/nsh/crates/nsh-cli/src/test_web.rs": "cedb081afca9ece1830142ce344f80cfcc08f217ae6d92dd05ebb7a260fc1748",
     "third-party/nsh/crates/nsh-cli/src/tui.rs": "edadbdd4996dce76cf87b4e8eb4e4101fa6ed338f2ebb7f0725a6c61d2bde6c0",
     "third-party/nsh/crates/nsh-cli/src/ui.rs": "b76573d4d63e976026013526e661b0f32d74f9da995fc1facc7f3f48c040735e",
-    "third-party/nsh/crates/nsh-cli/src/web.rs": "736b5930852cca6f86b5c7ceb376308607a1804dc42ca092869c8c67cfcffcfb",
-    "third-party/nsh/crates/nsh-cli/src/web/dev.html": "6ee194e1e1eeae680741635ff7c4f1f2e8a24613bf190d2ffa81a718dba27bf0",
-    "third-party/nsh/crates/nsh-cli/src/web/index.html": "e31fd8c3e4d72b3b82cf47e79c3dc5fc8405321b9c26558ec86904a90703e49a",
+    "third-party/nsh/crates/nsh-cli/src/web.rs": "9990b4ddd89ef561c10da236f9613ce731b2aca9bd91c4b867452012c34332e0",
+    "third-party/nsh/crates/nsh-cli/src/web/dev.html": "e4ff740d6776686ce3fea4f9520f4e811b4adfdd04fe76dbbf3ed167fbf31f06",
+    "third-party/nsh/crates/nsh-cli/src/web/index.html": "706946ee021a1b0e62c11200242b526d351fb1cde61711fdc449512ad9610dbc",
     "third-party/nsh/crates/nsh-cli/src/windows.rs": "81b1e8af3c91b3d45f7c0d05ac2abef4ca426403e30f7c71c8029d74bfde6b24",
     "third-party/nsh/crates/nsh-core/Cargo.toml": "9e5e298894de41f52334ba29bec2c3cbc37ce8676f754701c9f4680f99f7a7de",
-    "third-party/nsh/crates/nsh-core/src/access.rs": "5e880fb21cc43c8c28fe42bd06a08f56520d4eca33c8e404b199cdc83ac3d764",
+    "third-party/nsh/crates/nsh-core/src/access.rs": "9b45558ea450cab93f570d28de3d9ec521b2f6f5f82700bdcc63188202a4e3ec",
     "third-party/nsh/crates/nsh-core/src/animation/animator.rs": "a5ccd0ab2bf2f353bf7e31c0e8ef11151f443d3a551ee63ee7487f77b87a7506",
     "third-party/nsh/crates/nsh-core/src/animation/driver.rs": "68f951a4572f81335911c19b65329b3f920a85d9ad402d566716f84c1372311e",
     "third-party/nsh/crates/nsh-core/src/animation/graph.rs": "a4fe8ca39d20fffce8f2114ee8d834c82044053ad5ef34c17e3bfa037c7e1fe2",
@@ -1218,18 +1218,18 @@
     "third-party/nsh/crates/nsh-core/src/clipboard.rs": "36b1c87f8762288ba9f8282daa03a5deb5541769afe75d58a7ba0cf8fe7defb1",
     "third-party/nsh/crates/nsh-core/src/color.rs": "93afe9bf38e8f7b12108fedc4863f8e1bee3a55ae7d0c75e788b79b02fb72a93",
     "third-party/nsh/crates/nsh-core/src/core/classes.rs": "eeea690ba30e01bd1b74aebb83358475a62265fe2d902b119cb97389b84d8765",
-    "third-party/nsh/crates/nsh-core/src/core/components.rs": "93f3cb23f3a570c620d8204bf46a125885f35cebe71e56e637eaafcb673dd057",
+    "third-party/nsh/crates/nsh-core/src/core/components.rs": "26fff1f2071b57c68ef227ea1ef544a4327259cdb43cd72622ac335fc929b628",
     "third-party/nsh/crates/nsh-core/src/core/layout_tree.rs": "d28e3c5657e308c9cc254529457a025b057735d743e529bcf5e11734b8b91109",
-    "third-party/nsh/crates/nsh-core/src/core/list.rs": "e709dcf029ac3d8d0c4bc87b4e1984ce07f50e18266d0759eaed773de1902684",
+    "third-party/nsh/crates/nsh-core/src/core/list.rs": "c9876f0abeeeed004070f6eddcdbf7b46c830c95d2d4418a9e1a802fd409a25a",
     "third-party/nsh/crates/nsh-core/src/core/measure.rs": "c6692abd3002576afef5b6d777d7694e28e4754bf7ada0adf8ed1c92419289c1",
-    "third-party/nsh/crates/nsh-core/src/core/mod.rs": "396f2523801a88fcf050ef3e80a6e0fc8eea15b19315049e88166852f9a2d3fe",
+    "third-party/nsh/crates/nsh-core/src/core/mod.rs": "46bd7396e508dc399a6e84d9d272627be10f05258ff9900da58f97889e574109",
     "third-party/nsh/crates/nsh-core/src/core/overlay.rs": "4a65103b3cf7a35e11036532866b171dafd0bddbfe6517849520010e39d01239",
     "third-party/nsh/crates/nsh-core/src/core/parent_viewport.rs": "af5798fb792c837a9cbd0654bb04aeb867ecf784d140ea159ebce326cbaf6e7d",
     "third-party/nsh/crates/nsh-core/src/core/reading.rs": "3bb3d25dc8b97f35eb73dcbaf2d6f1b5dff8cdbdd012a8edcef7fab6661f3d9f",
     "third-party/nsh/crates/nsh-core/src/core/renders.rs": "eba08379b95272762630258bba8c9d1908f8e8ec94d9518bfd59470edf49b5cd",
     "third-party/nsh/crates/nsh-core/src/core/services.rs": "8812eefd00661841ff2b1de5d4f8cf68dd667da4151b4c79ed966cc940934752",
     "third-party/nsh/crates/nsh-core/src/core/sticky.rs": "2c508038c1677fc10f11b5e6c1bf245cb78a75a02beaac24d0b09b2033a4beaa",
-    "third-party/nsh/crates/nsh-core/src/core/tests.rs": "9df804c36eecc30b8f8efad2c3b60ef67e1401c17f81b1ead276d9c504b6dd9c",
+    "third-party/nsh/crates/nsh-core/src/core/tests.rs": "c37326de457e69cc2e5a804342c1b286df9e1df0b6b4c1a86236841174da3b06",
     "third-party/nsh/crates/nsh-core/src/core/touch.rs": "a8559c023a443a6defad135af2f319e60130e7df23af2a8353239b0862e494a8",
     "third-party/nsh/crates/nsh-core/src/core/views.rs": "c32c59533d5ee33bc16677b0459816f7670c224f3f8de3507c72029989660834",
     "third-party/nsh/crates/nsh-core/src/css.rs": "56760520c706f64505849d5a32a1eada2d9fd243a5c70e07f21bc8ed0105217f",
@@ -1252,20 +1252,20 @@
     "third-party/nsh/crates/nsh-core/src/input.rs": "9ef67d528e7a00bb4b325a425aa50e20ee3ebbf7f62325201520b6a3f4ffab07",
     "third-party/nsh/crates/nsh-core/src/js/animation.rs": "e2bb991a9e414e81f1d4e9449b4c017d42a5a54f529f4713651e1c5badca32d8",
     "third-party/nsh/crates/nsh-core/src/js/canvas.rs": "348900df6c3d12e2061e09d7d7c1d50fc409cb1f42fe4da2e1400e89fab93679",
-    "third-party/nsh/crates/nsh-core/src/js/host.rs": "3ff652f77711cb48a1255805fdd51d34d5c514e14ba9d1d4ad3cb8c192c97613",
+    "third-party/nsh/crates/nsh-core/src/js/host.rs": "04e7899db07ffd3b5d8f7a5f3b33b43fc8a36af3b044cd3d229a2fe44c301317",
     "third-party/nsh/crates/nsh-core/src/js/host_values.rs": "f8da8a7af5cf78c4e514357c7d15b5cec5d32b60dfdfd103a28a32bbc89dac09",
     "third-party/nsh/crates/nsh-core/src/js/limits.rs": "44bafb43b6fc14a6899f7f7219fb8d7505485179bc0ec470f76d9266d9b35fb3",
-    "third-party/nsh/crates/nsh-core/src/js/mod.rs": "0377b9d8f67c77acc47a467dc709090d3c5951a3f4ae6118abe680e97bfef9ca",
+    "third-party/nsh/crates/nsh-core/src/js/mod.rs": "33d6ee5852f3727ceb666f28f823cca49ec50a01b7f8c0e7877f98f63539079a",
     "third-party/nsh/crates/nsh-core/src/js/ops.rs": "c43398b95c3d9a6d58027b19bd3fb4d073287986939d9b894e9d4d1c905c4cd4",
     "third-party/nsh/crates/nsh-core/src/js/protocol.rs": "19c4c34368d2a05a196e580d5813159f44e3d8183b9c6e9168f9b3ab3db4f91a",
     "third-party/nsh/crates/nsh-core/src/js/style.rs": "8b80f7b45468d22da5403a25092087d42340ca119e6940309e38f22ef1651926",
-    "third-party/nsh/crates/nsh-core/src/keymap.rs": "c55d58813d5d84d2c33dc9a9cc9d9ef95e3e5eaef4e03a1af42c863674281685",
+    "third-party/nsh/crates/nsh-core/src/keymap.rs": "826371d8465de044dae0cf0cd7fba4f0e6928e38e228089813d010c87eefeaa6",
     "third-party/nsh/crates/nsh-core/src/lib.rs": "5106d92ca05d9f46573efa426d1abeef8d7b5cddf71d4b1f7e3f7beda339c82f",
     "third-party/nsh/crates/nsh-core/src/mount.rs": "d8b4f34e10db29189b29538041345b7c3cc90f2d743e48f6d20ad453d51471d3",
     "third-party/nsh/crates/nsh-core/src/navigation.rs": "d79dd1d10a04157cbc5a383dc7b01aee820e6a33a16c682d3e43dc5d4909f5ea",
-    "third-party/nsh/crates/nsh-core/src/ops.rs": "cc2c363638171332617f74ea3534c6da6180c4cfb6ab1ef2cf065b79ccc43d2e",
+    "third-party/nsh/crates/nsh-core/src/ops.rs": "65377dc000c3b54f93a49ffa18c1e870ddf435801206f02847d5793a8721c6eb",
     "third-party/nsh/crates/nsh-core/src/overlay.rs": "e3029e8e8827a4365a4ac91162c77baa2a8e76fcbf22ded7c46cafac8cc33b13",
-    "third-party/nsh/crates/nsh-core/src/platform.rs": "d0acf7489905e456a648aac2cd2e892a0a7b0a34ead24de921a7cb33bd0029e3",
+    "third-party/nsh/crates/nsh-core/src/platform.rs": "8a0a76a5d9d94d1a991a4a72b60b2bec90265d8dff3ba6b3e5b6240ad90472b5",
     "third-party/nsh/crates/nsh-core/src/platform/clipboard.rs": "42bad81067d3339c38fbd45de16611c92bb6dcbf0c400c5486962ca19e70f282",
     "third-party/nsh/crates/nsh-core/src/props.rs": "88d70945adcdbcf384e6b13ed1105d6f683b877e7a429a9b0804b9b356415963",
     "third-party/nsh/crates/nsh-core/src/reactive.rs": "0d2d4c952be9b7f1c6c9c12898aa5d63b30e9401e8621194fda80904470e41df",
@@ -1276,8 +1276,9 @@
     "third-party/nsh/crates/nsh-core/src/runtime/clipboard.rs": "916e9bfcf05cc6b8702f0fccaa20e7e1062a71dcacc820d625fc34ecfccecab8",
     "third-party/nsh/crates/nsh-core/src/runtime/desktop.rs": "9f178654e9ddb48d258ccf198b5faac58f68cee3c3ef9fbeb079ed9523b87b46",
     "third-party/nsh/crates/nsh-core/src/runtime/fs.rs": "b7f6770ccaefe5a04b034d649ef639601876651bece06c03b5ed4e5265d05d97",
+    "third-party/nsh/crates/nsh-core/src/runtime/host.rs": "e66077432dc0ec52f61f3b3b1b8e3a0a80779e22323dc16f539ee8d7498060d8",
     "third-party/nsh/crates/nsh-core/src/runtime/image.rs": "df044f7d2f061309859de8b1c0f293747c85721eaae7ee265805da8c0517b811",
-    "third-party/nsh/crates/nsh-core/src/runtime/mod.rs": "6bb4ba9ac5272e343f2b3453c56151388bc2a49a108a53d11f8b7b12449de0bc",
+    "third-party/nsh/crates/nsh-core/src/runtime/mod.rs": "e16f88133118e0c7152d757929636d3775da374f9546a085e9574583d13b5642",
     "third-party/nsh/crates/nsh-core/src/runtime/net.rs": "698fadd3e298e8da089e4d4c88ed5f9a714028b021e5155c89fc6f5e86bf143c",
     "third-party/nsh/crates/nsh-core/src/runtime/os.rs": "741a77c8a93dc6343873aea7b90a951f9340bd9a61ddaf185e6e6a3108bb81ea",
     "third-party/nsh/crates/nsh-core/src/runtime/process.rs": "53eaea00026fce5689ee9d500aba7508f1e79d9ea3e4867232f9fdc281978efb",
@@ -1307,7 +1308,7 @@
     "third-party/nsh/crates/nsh-core/src/time.rs": "c342c2e1d620cd2e8d44691e10064e56505253b696f543c1223c6ca4e34a137d",
     "third-party/nsh/crates/nsh-core/src/transform.rs": "7a5fef9eb9b544d114ccf0f3c11e272aaed77eea1124da06bf247ef354e3b07c",
     "third-party/nsh/crates/nsh-core/src/transition.rs": "f468b6d0a6e07d12ff76d7abbfef4c00233f69484fdfa4bb6eb7f288116375d9",
-    "third-party/nsh/crates/nsh-core/src/ui/ambient.rs": "864367f4f2d4999056e9d804ea0584807c37b377dfada1f5864756817d6bea7a",
+    "third-party/nsh/crates/nsh-core/src/ui/ambient.rs": "5c2307a880fa1750f6dce07b1a3fa1008adc96f47303f11f40a7456ce59791a5",
     "third-party/nsh/crates/nsh-core/src/ui/animated.rs": "b5e802d1f29181f663e375cbf6d6f09cb1706e4141ebf0b9289f25cc48579344",
     "third-party/nsh/crates/nsh-core/src/ui/asset.rs": "ecc4b40dbe52c239f69de438eab9444768bb5d7e4069aa15a51c3adc7477702e",
     "third-party/nsh/crates/nsh-core/src/ui/back.rs": "ef98d58af2314de48ec9fef54677a69d663a6fbd6fcbe90ba5f80f8c253bbce7",
@@ -1320,7 +1321,7 @@
     "third-party/nsh/crates/nsh-core/src/ui/element.rs": "0caf17597339f476132700eb31fba3a992ab4e43818b83e51b10113801daa535",
     "third-party/nsh/crates/nsh-core/src/ui/element_ref.rs": "18505f479859e82aeaabe2522318adf0004f2301ba9d7119bfa032f1fc5bc233",
     "third-party/nsh/crates/nsh-core/src/ui/engine_context.rs": "fc9f4487a2ec47939119d2c6f5c75dc081ede1feb0422be8127b19fcb129fa1a",
-    "third-party/nsh/crates/nsh-core/src/ui/env.rs": "6560f20704070b7bb08c1e8e7c58f63c86afc53c150aa41fc571460a0c8e6b37",
+    "third-party/nsh/crates/nsh-core/src/ui/env.rs": "86521fc681a7fc9e0dafb8bc76e38e675f11ed8c61605e8cd75e470650fad203",
     "third-party/nsh/crates/nsh-core/src/ui/events.rs": "40d4d1b562781ac13732685d5decdffb81860010790469c9b4219cb3df9736fa",
     "third-party/nsh/crates/nsh-core/src/ui/flow.rs": "3984b86ad1aaba5a1dd8fc32f93c139dcf3260d86500f6791ac8dd15d6de756d",
     "third-party/nsh/crates/nsh-core/src/ui/host.rs": "03366afe31db0cef32adf10f5bd9eccb2406365bf1f5df711ad0e063ace1f408",
@@ -1339,11 +1340,11 @@
     "third-party/nsh/crates/nsh-core/src/ui/responder.rs": "b5e419ced70b1457743c4c9fd61e3cc51c93db348581cc9bd14b61fe695e0e43",
     "third-party/nsh/crates/nsh-core/src/ui/rows.rs": "a5fd4db97517c39b1e4d898c19cb4a8e60429ffc6de1021527df0f2cd53bb365",
     "third-party/nsh/crates/nsh-core/src/ui/scrollable.rs": "704ae082a1f10e2b344e20299e52177ee77990bacf83eb71c6523e0419f8ec6a",
-    "third-party/nsh/crates/nsh-core/src/ui/services.rs": "df1e10972fbdea7b4af78016948c214eb393c49ef8d4e023e9f55d022e91a8d6",
+    "third-party/nsh/crates/nsh-core/src/ui/services.rs": "3009cbdcb6dd76d922d1295759c019bd200e084b3112c944d904b2b1a9e8b498",
     "third-party/nsh/crates/nsh-core/src/ui/status_bar.rs": "2fff306388c47bfee440e1042f9143251fe0b210cd57a370e5e8a8247c537acc",
     "third-party/nsh/crates/nsh-core/src/ui/style.rs": "3a39cfbd53c8a754d59685e73ccb7cf1da431063c8b7a4ec0379bb9fd039498c",
     "third-party/nsh/crates/nsh-core/src/ui/system.rs": "077e9dada158047e159eae15a86eadbdff62875e473e11255afacf32a20f9607",
-    "third-party/nsh/crates/nsh-core/src/ui/task.rs": "e19a244fd39fb716a71ad33f653f3f24cc3e390c40159224ad341aa51c37e174",
+    "third-party/nsh/crates/nsh-core/src/ui/task.rs": "9416e77ea03b568807b95a3c84bd85b3353b7d5788adf6d4bc605be3d17404d7",
     "third-party/nsh/crates/nsh-core/src/ui/tokens.rs": "c95b983afc27c65290b22f5db7e9ece7b48d05e7f4c769c2e882e81695ab4756",
     "third-party/nsh/crates/nsh-core/src/ui/value.rs": "81804c80fb2a357b40f55fa4586c55a818370cb641e8c074e6f3f8f6046ab0b8",
     "third-party/nsh/crates/nsh-core/src/ui/virtual_list.rs": "e3d0ae1417072a65323d978a8fdf7911d936d82f4304993e5ed74b2589335abe",
@@ -1362,7 +1363,7 @@
     "third-party/nsh/crates/nsh-engine/src/access.rs": "37cb4b18aa2f1d47325969b035bd08f5d6cb9db6c5b437a6a9aa7bd2677f6707",
     "third-party/nsh/crates/nsh-engine/src/app_directory.rs": "bf99d20949b392e5b919f8185a6e234308d53ae7db02c29b0d18ce678b74986f",
     "third-party/nsh/crates/nsh-engine/src/error.rs": "54bce4d455c05a1c37b3e0ce8e46271492369855482283ffae3851605b44bc51",
-    "third-party/nsh/crates/nsh-engine/src/lib.rs": "e443d068bc5197bbc89df2e6bb648a3ba1c2cc679baeaef15eb110f43bc04252",
+    "third-party/nsh/crates/nsh-engine/src/lib.rs": "d25fb01cb74b95291649adf6abe7c1a7237433f4f6e2da2817af01ad0b85d579",
     "third-party/nsh/crates/nsh-engine/src/local_storage.rs": "bf75d06d4d917a14b6d4f08abb10d385c68ed4ab50b6c70c4851cbc72ad3f631",
     "third-party/nsh/crates/nsh-engine/src/net.rs": "3a1fe7de92639eb17aac9e97156b7a9c6cf9384bbd9a973548c12434b48910f9",
     "third-party/nsh/crates/nsh-engine/src/perf.rs": "29aeede364da40ee8cbdf2acb750abaab7cee523b2df34870b7bbeaf291f1a33",
@@ -1373,24 +1374,25 @@
     "third-party/nsh/crates/nsh-engine/tests/engine/class_bench.rs": "48429824b5efc1a8a8a08776fdf3280139c9a2b450aacc7437a663bd16bf4ba6",
     "third-party/nsh/crates/nsh-engine/tests/engine/clock.rs": "b11f9c4240f2787b39dc819bdbe90b2d3666b4ff3f89d267ec90d5356780495f",
     "third-party/nsh/crates/nsh-engine/tests/engine/compiled.rs": "ed35fbe5be966d3bdd3d3cf6f7d090dce664e1312a8484b36cdab2dbbbeaaa54",
-    "third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs": "3d9625ac6a683620aff4c277addb251f724ce1fdc20fc1dfd228a7b3fbf17aa4",
-    "third-party/nsh/crates/nsh-engine/tests/engine/components.rs": "8bc03ffd81b3ef86d101cac1d2fbcab88df98aa198bb8d55fe01a1e65653a21e",
+    "third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs": "18b53b2ebb2459d48f799c7b1a1850527f97381fbd97f05748d347db945293f2",
+    "third-party/nsh/crates/nsh-engine/tests/engine/components.rs": "9096a7c4c447ed32dc3db53530daba1f4d0f2bffea872f08399a1ab681189d22",
     "third-party/nsh/crates/nsh-engine/tests/engine/counter.rs": "1b0f9981461d121290e72789c2efe495c46dd19df138a9dc908977fa7364aa05",
     "third-party/nsh/crates/nsh-engine/tests/engine/errors.rs": "a050ab6f85a865b7701563de2230c2c316cf94a40eea15a17702a020d2657c42",
     "third-party/nsh/crates/nsh-engine/tests/engine/external_wake.rs": "4c114be3b7489e607e9826c8264bbf9bdd161479fbda8f598da6595661ab09ff",
     "third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs": "30a4ad7fea1aa1f3e481d9774ed3aefcd92832f36f3347841a8ead672e05368d",
     "third-party/nsh/crates/nsh-engine/tests/engine/fs.rs": "71f693539ff71b4ddccddd925739a52a695e2fd22335cfad396304395147b01f",
     "third-party/nsh/crates/nsh-engine/tests/engine/handle_bench.rs": "fdfb1c00cbae1dfa73b3b5fcd373bd839774d2f137642a5dd1b09bbc8ad12675",
+    "third-party/nsh/crates/nsh-engine/tests/engine/host.rs": "b9d3364dd822cb84fae74a159fcb1d51ef13279907d1e86ab527d6b26124a498",
     "third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs": "261558f3b5badd08e89586af65893603f195588508b99e3d6164f572bc045cba",
     "third-party/nsh/crates/nsh-engine/tests/engine/hot.rs": "98e44b2518e746f86a61ef503af030e7d460becfa3b66891d3a98228bcf8be46",
     "third-party/nsh/crates/nsh-engine/tests/engine/hover.rs": "555c2bf8043e52d181dae2e4a35d01182fe44258d5051be1c5412ad1613b7482",
     "third-party/nsh/crates/nsh-engine/tests/engine/images.rs": "775dad08ac5ca247b8cb5388e40c9a70b3583636bcc1bf8bc4d09551aacad372",
     "third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs": "dee2b2ac3299459a90ef785e24081e0fd280dc9aa91d650de69e8c938ce61888",
     "third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs": "85fe9f59f13bc07c66148a1d2cf8ef62858e914a83d84dd451c4d6af7d8ad1a4",
-    "third-party/nsh/crates/nsh-engine/tests/engine/list.rs": "757a325fb32271922cf977d35307431e6c8fb6e1db19d4991cc9cb783e06e1bd",
-    "third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs": "df4cb2074bf17123776168d8650ce2575595a3b7162796b0dfe793c880e022c6",
-    "third-party/nsh/crates/nsh-engine/tests/engine/logs.rs": "e40d974c261a3b025b80dbbbb2be83e2d58f8ccd07ef9a77b82f740d5795bd9b",
-    "third-party/nsh/crates/nsh-engine/tests/engine/main.rs": "0db1654e25ae62e4d496446e38e8c0b369fc82d1435ea8b791ed5c0e8ab703f1",
+    "third-party/nsh/crates/nsh-engine/tests/engine/list.rs": "0b46510028a25d574cc3cebf014b85c85d1975d85215a5c834276139772c6961",
+    "third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs": "54cb3ab6e5b415e02cc7f8fb6e79f0a2c3ca2f3fc022fcb5f4a545f25c179080",
+    "third-party/nsh/crates/nsh-engine/tests/engine/logs.rs": "22201bd9432b8e3801064aba2a5b77d3a64f4a4b148133af5a39e2e26a33c0e2",
+    "third-party/nsh/crates/nsh-engine/tests/engine/main.rs": "cd4bd38e3eee7064bbad3d25eb8a72f9db14bcb1d0d0d4883c319a343cd09249",
     "third-party/nsh/crates/nsh-engine/tests/engine/native.rs": "91e9f9255e84e0f4f93e40e72fe1953d4bf305818cee95b102be715ba9bf6fc3",
     "third-party/nsh/crates/nsh-engine/tests/engine/net.rs": "d6b6b5cb734f7a0ded0063d193919ea8ca1d783511102d5078bb4e8e6c07ceb7",
     "third-party/nsh/crates/nsh-engine/tests/engine/package.rs": "5dd7e22c6d63c010b1ed3d701810442d112120b143fef6481cd44d33c43f09fa",
@@ -1414,7 +1416,7 @@
     "third-party/nsh/crates/nsh-engine/tests/engine/storage.rs": "271620e86942712de84bbc3c0ed71763f38d8e9f56bee3717fa38ab6494131f2",
     "third-party/nsh/crates/nsh-engine/tests/engine/system.rs": "fc1b5c7d5417d256cdeb51451110b728f31b85862aab377e3730540df13d4a63",
     "third-party/nsh/crates/nsh-engine/tests/engine/test_ui.rs": "e5e6f8b9acd4124668deec4fc8860ab1849c1f3be82c1244330ace6e4599b5fb",
-    "third-party/nsh/crates/nsh-engine/tests/engine/theme.rs": "fb247e36f8ebf78c568fa6319e4dd4b5532f2f973783737f6c8778246be97c08",
+    "third-party/nsh/crates/nsh-engine/tests/engine/theme.rs": "32eaa004b7b5ace88ec4b855d7734f6e9dddf5dbc0db170058f00cdd1e34cf55",
     "third-party/nsh/crates/nsh-engine/tests/engine/websocket.rs": "d598ae8ba425749118c9269d6cde057c38fae0f029758b58538d8e79368cf133",
     "third-party/nsh/crates/nsh-engine/unitless.txt": "608551ec4de1da091099bad8e64d961a352681da134c4af9b58b9a29a9e43018",
     "third-party/nsh/crates/nsh-engine/untyped.txt": "0e94c66fb47844aa29a7b5deab131001f3ab89ea46e79c1d75679bf618788c10",
@@ -1438,16 +1440,16 @@
     "third-party/nsh/crates/nsh-js/src/dom.rs": "b75190400a363ed60f5d6737be4cb4d14f8d69e819201bb090bfc4dedb98d56d",
     "third-party/nsh/crates/nsh-js/src/env.rs": "571522044cb3d43799d185e363edf87622a03580487db85e5827ea59229d0ac5",
     "third-party/nsh/crates/nsh-js/src/error.rs": "01a5f911656faa39aa30dbcd71789ce30c35b3e740c5f3ec5125c87b85282bd5",
-    "third-party/nsh/crates/nsh-js/src/host.rs": "3740d306e43b9eb5c81f30623044f44ee166828c9100425f691bc3d244e94931",
+    "third-party/nsh/crates/nsh-js/src/host.rs": "e0f22e5685dc0a3ef8dfb5bb6c03d2e51bbf2ccb0c1af5398e596390d5648789",
     "third-party/nsh/crates/nsh-js/src/hot.rs": "6286e13ee994063c1c72292bfdb44c964dad45b127edb24a2d76461bd163d621",
     "third-party/nsh/crates/nsh-js/src/input_action.rs": "eb70b0792eff82ccda139f63700656167ac04753872fca2aa63485673d9ab047",
-    "third-party/nsh/crates/nsh-js/src/keymap.rs": "cf6a81a283c6f3ea77b07dabc1ab8ed3d64b88cdc3d51e8889cff6cc757806a3",
+    "third-party/nsh/crates/nsh-js/src/keymap.rs": "898484cb0e28f040f1dc95fe6dfe4675cf2acdba5d31aca5094391cacfff580f",
     "third-party/nsh/crates/nsh-js/src/lib.rs": "396374fe32e378c38d5ad936b6146569fcf183c16f5133e6ba8d46b231ba29d1",
     "third-party/nsh/crates/nsh-js/src/list.rs": "dafec1262d993ed59e80067ed925ba3e1ee4481a68a1a53dd604126e4e81a998",
-    "third-party/nsh/crates/nsh-js/src/modules.rs": "c23d93fbf0bd5d5680fa02e402ed2c95bd574f6c3d6a900e9722bdf55230362e",
+    "third-party/nsh/crates/nsh-js/src/modules.rs": "9890faad1da6d6077f8bb0ef953ac7903176fd1282c14d04f7a3ba21217e1871",
     "third-party/nsh/crates/nsh-js/src/navigation.rs": "23d6bce4611eff5da1516561caf7d89082059b2aaae0cec47f72efe955998401",
     "third-party/nsh/crates/nsh-js/src/net.rs": "2c1e993f6e58ecb095a78ceac05357ab747cf8d21769507ebbc3c74c63fe6950",
-    "third-party/nsh/crates/nsh-js/src/platform_modules.rs": "26c25867b012588b1d5fc0783a99b0c650915a9a48d797a57f73ecae34008d09",
+    "third-party/nsh/crates/nsh-js/src/platform_modules.rs": "223ab3c2389e33d3ce4460588de838234e177b6f5389d02cdd9b7310d821fcbd",
     "third-party/nsh/crates/nsh-js/src/protocol.rs": "058fa87197c16aa0bbc76345244eddef44f0fb7a8c669ff974486889648ac4dd",
     "third-party/nsh/crates/nsh-js/src/reactive.rs": "36773de3b3e8e1368cb9960fe3925d7d15e746dc85df03c659c55527d7683e67",
     "third-party/nsh/crates/nsh-js/src/rejections.rs": "7873bcd5daa9b755951b0ded491e72ba9c9a82f77526dfc41df829a998fda7da",
@@ -1556,34 +1558,35 @@
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Json.java": "80d814fa3a0357463394fb0d84d98ec02687abd42b2d3521037db2e1ad20db27",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ModalHost.java": "a40b13eadf1061ae52d9ef92ea55352d836af64e0d846a48a3e9856c56a4ef79",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java": "6ae84283cfb51b9976a3c1c59550e826c13add4c0c7f00ae4ed59b85dea69465",
-    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "b97b3c79261f9362e07c70cf73c288fe27ea20e7a5f66e9a48a6bf4a8e1017a3",
+    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "0148c6c03a8da5b1fed7a4dbdd5bd41a50c4a6c5ecc1ca968200aae0314c1fac",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Net.java": "891adea10cb4f8cf0020ed3854eac241572b00b32506e129987715db2a183149",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Node.java": "d16e8e7d2375e3633c3cff5edeba08cf38d08726801f67721b12ba51bcce4e5c",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshComponent.java": "70005073c746cc06ea2421a788443abd20447dacbdbe62a9cd4e8b2cbb4b3011",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshComponentContext.java": "2c1a1fcb84cb5c9742ff2a51021be1c304a8dd7db5e1841a6078bc3a0fab0d97",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshPackage.java": "8f15ba9b542631a20ff0edba6c2a159ff547fe713ea98e1a160e8b52c0c76474",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshTrust.java": "9a9027f5eab16e5f54641c72914b319c53e0de2f2502a1baaf3eec941f2ea527",
-    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": "f9e25b8234d2786c8bb13073f02b958b7739d8a38db4f5c122450e2b4a29ebb6",
+    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": "5f6259c51630cc857ce95526d433f5d5eda3fc8e54ae3dba772a1a6c1fdb059e",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Pressable.java": "5bb17da3a0e1fd7519d34498752183f4e86fdc6e8d17102cd2e5a31fb3bdf98d",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java": "9fa43f2b9a68c711fbafde8e7c7eda2ac44782838288b1e6ca42c05472d6f9d3",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/SelectionArea.java": "376a3abe901a71c03ca95edd4189a7fce1b3c620b5b4830b269ff68c99de213e",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/SelectionHandle.java": "5c0ad0957390540d21c4b9e351c343a2c7f0532934f6ed54b8cb621102d169a8",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/StackBox.java": "f712333f289b2eba41611a7ef36b677d1f12a7850d69a9fa389e77959ec5eb1c",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/SystemUi.java": "6001517437d9828a2b5cf65f9b4c7bea96380decf5f5f02c81de4991934dc208",
-    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": "aebaf9af4985c6502a043f4dad054b0453b84cc4a2f772a031ae00a653c917cf",
+    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": "26b3bcdfcd9d872ebe55429f561026d8361fc8a7a9f5ceb4f63c78613d14a795",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TextBox.java": "6ad8195352f29338e96c144985a4797a8894e3b0fcbf473bf7a1ea297c3ec5a5",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TextLayouts.java": "bc02e66bdb1408a904d3c799f2d228487742cfa7ceb9fe24391c80fb4e368c42",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TouchTracker.java": "29d218455c922e29bf69e58d48cc797637e5030c768dfb846e6cb7969cc99621",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/VideoBox.java": "2e7c5fed70f1227353f2f6363dcd6c4fa4d5f277a2739515fe13bba2f675e3f7",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/WebBox.java": "5f49a4b3748f8df0180ec45cb3b3c296694ca0ba3fdf2c7d117cb4e021b39965",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/WidgetBox.java": "51340bdf442b737f2271e091ec2114e0550b005e8a5eba02ad0947c53c574120",
-    "third-party/nsh/crates/nsh-platform-android/src/agent.rs": "7ef85fa373ab3d4516afa3b9b6fc221de133edd12d092366d0ec37a372f3e6b5",
+    "third-party/nsh/crates/nsh-platform-android/src/agent.rs": "c67465629a6d49e13dad3c12bc44550d1d500bcea1222905634df8b39c68b97b",
     "third-party/nsh/crates/nsh-platform-android/src/animation.rs": "c1fef8de76e942b48c05e108723effc49ed4a57cbe726d9b73037b77e6704a26",
     "third-party/nsh/crates/nsh-platform-android/src/dev.rs": "fc60e6257001b3e5c27802d4320e138bebf21f66a561928297a68bbc5de0d5da",
-    "third-party/nsh/crates/nsh-platform-android/src/encode.rs": "1f7b9bb75a90b6c882577af186dd72c41b342f358262f3453100187a07f4b115",
-    "third-party/nsh/crates/nsh-platform-android/src/host.rs": "15101195191e4d8b60fcb04f0a7ae1b02f488dd368c216908256f89151c60b3f",
+    "third-party/nsh/crates/nsh-platform-android/src/encode.rs": "053353b68306cc26811df3a0e8d63f607efca8e47e80b943f167619befe37c0c",
+    "third-party/nsh/crates/nsh-platform-android/src/host.rs": "9da0fb7d5ecf1729bc42127f7007c749c80321599ef723ab88864d8b030dc034",
     "third-party/nsh/crates/nsh-platform-android/src/java.rs": "342ea89f068da2261731704ba4cc3628835a1c06f5b8db07e071abcac8d4ef3f",
-    "third-party/nsh/crates/nsh-platform-android/src/lib.rs": "f7dd6f6e125ff9a6c488fb9322cea08da0fa84a234e7e03a9e251007b874a352",
+    "third-party/nsh/crates/nsh-platform-android/src/keymap.rs": "7710df8e27d117d3902ab7788a425406cc6b2b2226f3a42abbc60f16239b4f6d",
+    "third-party/nsh/crates/nsh-platform-android/src/lib.rs": "10282c14a4d56f60291ae8bb6c07593275592ad7ce034b63ec91c96d5d41660c",
     "third-party/nsh/crates/nsh-platform-android/src/logger.rs": "7fe95c849533e54aa753757090b5747c1131e0523f2f5b24f260fb77bd0aa2f8",
     "third-party/nsh/crates/nsh-platform-android/src/net.rs": "e51b9e715af5652a6d0697beaa707d021935128c06751010e773c0bda9baaa49",
     "third-party/nsh/crates/nsh-platform-android/src/package.rs": "f69038eb79ac0e6885cf831fce67572e4b068fb164c21ff64abd40c82ef56fa0",
@@ -1592,8 +1595,8 @@
     "third-party/nsh/crates/nsh-platform-android/src/text.rs": "5ca4425a2ab7a7030667c0b012ba3c85b3a126f3f015fafa785c5343cc3b6b8a",
     "third-party/nsh/crates/nsh-platform-ios/Cargo.toml": "0fe3eabb15eceffb19a896318ce623e6f0752da8b8abb174dfd517162c5dd3c6",
     "third-party/nsh/crates/nsh-platform-ios/include/module.modulemap": "687c700460603d8f9ca214ff058f91d0d6edc9e3a7350d981015c73872e5cea1",
-    "third-party/nsh/crates/nsh-platform-ios/include/nsh.h": "b8505a1f2a0cb882be5dc798d475249b7bb2ae2850cf5575137a8c9e1c5d174f",
-    "third-party/nsh/crates/nsh-platform-ios/src/agent.rs": "adb7c1b931ad01ccdb88dd42aa6ba9f27e407d7dbbecb34181ef4f4e5ee1e45a",
+    "third-party/nsh/crates/nsh-platform-ios/include/nsh.h": "20924d096a81b7a91ee4ccdafad44d0ce76b13eaa3f17c59046c911a0cd60104",
+    "third-party/nsh/crates/nsh-platform-ios/src/agent.rs": "a94f761b87e51ce68ebd13fa24477a452f04a6ad9b96005da6851aa52c526be3",
     "third-party/nsh/crates/nsh-platform-ios/src/animation.rs": "662ffef6013aa336d05648576e149e7736a35c059ab549cc50f26cbfd75f966f",
     "third-party/nsh/crates/nsh-platform-ios/src/app.rs": "b307e39e915d38961129c41a5a5c3858198a38b2f9e0e28655d2c020658e513d",
     "third-party/nsh/crates/nsh-platform-ios/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1",
@@ -1601,36 +1604,37 @@
     "third-party/nsh/crates/nsh-platform-ios/src/component.rs": "b53803ac0876795ea7b574dab2cfa771c18f30360ebcb2ad2989d716d52e9e66",
     "third-party/nsh/crates/nsh-platform-ios/src/decor.rs": "bf54501f9a913cd17d03eea457fa3e20a90a765ff7171208bbee11136e5c7249",
     "third-party/nsh/crates/nsh-platform-ios/src/dev.rs": "6a4d83df5efd1b0690558d817a37d2404459f855c389e7103463e06dc08e46c3",
-    "third-party/nsh/crates/nsh-platform-ios/src/embed.rs": "3e9aa5fbcc4996305604aeee1a90863303e09405864968506205034fadddc64d",
+    "third-party/nsh/crates/nsh-platform-ios/src/embed.rs": "6b45045a29a9c5cf78b4754943f618df68b3ef957b0b6faa712f36876930214a",
     "third-party/nsh/crates/nsh-platform-ios/src/image.rs": "67dab1f77c0809b63ffb798989f51b595b0663d3ef1bd3f6ffa93cc2dc982b34",
-    "third-party/nsh/crates/nsh-platform-ios/src/input.rs": "5d6f6d43a3d011c8a513f9208590789408f31b0eed2067845d6ccbd7851712b2",
+    "third-party/nsh/crates/nsh-platform-ios/src/input.rs": "d32418441866e41b8a38df75beba6060acd18acc13ce8da11c3c6a6087a1e38a",
     "third-party/nsh/crates/nsh-platform-ios/src/interaction.rs": "10b85e580a1c507a884d62c222c3f0b115ff82eac0f38e944954e17f9219d0ec",
-    "third-party/nsh/crates/nsh-platform-ios/src/lib.rs": "25c0ad2a5615c46eca35d5420d02c3e24d869b092533023569d5599c5a32f6a9",
-    "third-party/nsh/crates/nsh-platform-ios/src/mount.rs": "8c85c83c95e1e3eb9a29b79ea49820ec37b11955ee4998175d6c293b102eace2",
+    "third-party/nsh/crates/nsh-platform-ios/src/keymap.rs": "578fb8e85781ffa08f53ff1aaf65baa1ea80a08dc6892f4d6f180d649aaf54d3",
+    "third-party/nsh/crates/nsh-platform-ios/src/lib.rs": "57025f16e8c3081ad070eaad14458e64bf0059f8c195e4e6653faf0dd8c7f901",
+    "third-party/nsh/crates/nsh-platform-ios/src/mount.rs": "4eda9f5a722b53e6ac26cfa14b2adee1895081cdf09a6acc26679c0297b5ac19",
     "third-party/nsh/crates/nsh-platform-ios/src/native_view.rs": "e73867cf00a330e184a6be6830d7b25894db62c1149e85fbe97b316f374831e6",
     "third-party/nsh/crates/nsh-platform-ios/src/navigation.rs": "f881a30c8db5b5b01d360b1c497547dd1cbaa228ecfee7c55ccccece87d2a3bb",
     "third-party/nsh/crates/nsh-platform-ios/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760",
     "third-party/nsh/crates/nsh-platform-ios/src/press.rs": "3a68966f15bb361d8eba789b9a3cce2b4877c7fa9a7e8aca4f9868e2faeb2536",
     "third-party/nsh/crates/nsh-platform-ios/src/selection_area.rs": "d492cfabf02baaeac68898eaf8a231141f635c5875d8336a401414346edbdfc8",
-    "third-party/nsh/crates/nsh-platform-ios/src/surface.rs": "554efab1eafd60337541b6fca2278807c182ab525533169717ab33f2fbeb8efa",
-    "third-party/nsh/crates/nsh-platform-ios/src/synth.rs": "db7aef0e5815132a49d334f2f3bc5324ffe00a3913825672af87936854321c8b",
+    "third-party/nsh/crates/nsh-platform-ios/src/surface.rs": "93f4e78ceddc9142713ac7bb7157e6b5308e59026a296247c268ba7476c8e7f1",
+    "third-party/nsh/crates/nsh-platform-ios/src/synth.rs": "5ed5e9e78a632820a05ae9114d275283a5b7bccb782d1b1f80031ca013c69a52",
     "third-party/nsh/crates/nsh-platform-ios/src/system.rs": "3c96018a1a03e54ccad60d4b3cdb3848d4a687864e865558c257ff4a4e4abd2f",
-    "third-party/nsh/crates/nsh-platform-ios/src/target.rs": "994f0a282f57b553a1c392b191a2853dbd50b453b7155fa4e13d7c771e810545",
+    "third-party/nsh/crates/nsh-platform-ios/src/target.rs": "6dae6b295faeb46f663b167c0b45c70dcd418c4e2013a9a3058cc6a602fa8c59",
     "third-party/nsh/crates/nsh-platform-ios/src/text.rs": "142376f7efed62e4d4c2ed8059d7ab0642356ce95d32db4767cb4f756905377c",
     "third-party/nsh/crates/nsh-platform-ios/src/text_selection.rs": "cd68c073fed1d2baed7121fd72d2e703e7c14e5b9963fbc4f75098f38a00b6b2",
     "third-party/nsh/crates/nsh-platform-ios/src/text_view.rs": "b7bd9d38bab972227360ba7bee4050d895283e406de2bc5e153f29dcf2465d1b",
     "third-party/nsh/crates/nsh-platform-ios/src/touch.rs": "cb910d75ca29435063749c0620df765b116f739e19d79889497572a5c9461f42",
     "third-party/nsh/crates/nsh-platform-ios/src/video.rs": "aad3d01262fc4520b9523eabff0a68fa6373c71433f0331bf6200e69b6907bf1",
-    "third-party/nsh/crates/nsh-platform-ios/src/view.rs": "042e66b4200c4713809decb8bc0833d15dfffd6b8a2f0f77581732463b2f071a",
+    "third-party/nsh/crates/nsh-platform-ios/src/view.rs": "1807d29d3965e290e0c2d94751acc04f2e27e8833ebe9978c2c31de19fc607b5",
     "third-party/nsh/crates/nsh-platform-ios/src/visual.rs": "dc968d6bbfa977b5791ae1f9cf3d859d3aef5567799f02ac3588b3f280744480",
     "third-party/nsh/crates/nsh-platform-ios/src/web.rs": "2bf4ec4a10212c735585347f72175af0e784b7fe50b7e44037093c48e56c2844",
     "third-party/nsh/crates/nsh-platform-ios/src/zoom.rs": "274010fd16832c2c93bde502c2c1d6ad308d59e594cdf859d28d87b5ec6ab865",
-    "third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift": "052e146f7bc8c99509f459fd908efa4718ce1e9a0dc5a9c0948786598d162df7",
+    "third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift": "6058b673239941e863b8cc97180d09b4fbd8a4957cd32cedfb1e2d1ae6104d87",
     "third-party/nsh/crates/nsh-platform-linux/Cargo.toml": "2ea41863aacd26d6bd568126f0c6f089f682555b4adc8fac5e2a7bd49dde007c",
     "third-party/nsh/crates/nsh-platform-linux/src/access.rs": "9b9aa4d728b4451dfd80e0c3306deca2d7abf1308bb3acc3c2f0957257ed06b0",
     "third-party/nsh/crates/nsh-platform-linux/src/agent.rs": "433d0a4d9b12c7373a2941d255c5f65103a34639d27b9c60caf9e6799c3c23d8",
     "third-party/nsh/crates/nsh-platform-linux/src/animation.rs": "7fc1b4f7ec5b1f2010216bfbe8f4a773b7ac08549b326d439e266f0188be514b",
-    "third-party/nsh/crates/nsh-platform-linux/src/app.rs": "bdc83b899efa9d5aed4ede07303e7ef82803b0ce7b4648f4fa6e0c5edc2b3e52",
+    "third-party/nsh/crates/nsh-platform-linux/src/app.rs": "3a1acad9886b53f25f263cd39cf29d9c2ad12c02f254b12aae0088b25fb891a6",
     "third-party/nsh/crates/nsh-platform-linux/src/canvas.rs": "555850bb5a9cb0b9f09438ce360f460fb9f2144c4701001ba0bfd7dd91726dfe",
     "third-party/nsh/crates/nsh-platform-linux/src/chrome.rs": "b0b1ba6b3e5280a5ede9afe7cc629738c1f99b3f57af3a5864c41ca5038e309e",
     "third-party/nsh/crates/nsh-platform-linux/src/decor.rs": "9250081c55b8248278ccdcc8971184d6bfb820f2e6893d407ab76e84c21f37c1",
@@ -1646,7 +1650,7 @@
     "third-party/nsh/crates/nsh-platform-linux/src/navigation.rs": "77be5eef778948105eeea4d91306fbd70f2afede4d43d5432bff377db6b49d93",
     "third-party/nsh/crates/nsh-platform-linux/src/scroll.rs": "fb7b5cf5843a20d67f1a0550bbb53a1ac807684660ae691367d9bcbef2894310",
     "third-party/nsh/crates/nsh-platform-linux/src/selection_area.rs": "f38e30a766c3d7f52954c6b0c8aca3a52dbdee94c90c4ad6f21b0b6aff66ea4d",
-    "third-party/nsh/crates/nsh-platform-linux/src/surface.rs": "3182b74b110d3a684fad43616c7542bc61e6bd0017a2932fe974f101045833cd",
+    "third-party/nsh/crates/nsh-platform-linux/src/surface.rs": "f2087a9d1df07a1f5a94a49037fa0acd3409a873c334b66a858cff67161a617c",
     "third-party/nsh/crates/nsh-platform-linux/src/synth.rs": "ca932e93904d5b3b734a874be5337c2f6eeeea72c35e4c5bf57c04d864c30df2",
     "third-party/nsh/crates/nsh-platform-linux/src/system.rs": "fea65629cfbcf37eb050fdf9bb9f736e092af0375bfa8dd4f6ea425390f378be",
     "third-party/nsh/crates/nsh-platform-linux/src/text.rs": "87d1b461b411da98a59e120e13290cc41d665bbd9c7eeb65d62db58c81fcc6ff",
@@ -1657,7 +1661,7 @@
     "third-party/nsh/crates/nsh-platform-macos/src/access.rs": "5bafcc7cb9aa39c5d931f5c0fa5fef547b3d32ee6bcb1b38212cd7f1915c0b25",
     "third-party/nsh/crates/nsh-platform-macos/src/agent.rs": "db50231031040a91216b29a9b41b6a936016d6ff2d1c8cac2399dc08d8f9b6e7",
     "third-party/nsh/crates/nsh-platform-macos/src/animation.rs": "2458ba49973e39a739ad031482ef4c158a29e80c69413cf06bc615add190ad29",
-    "third-party/nsh/crates/nsh-platform-macos/src/app.rs": "c1562689dc7c467a0e763a39b874babfebd92b0201b6709ce7c3dadc08af60c3",
+    "third-party/nsh/crates/nsh-platform-macos/src/app.rs": "f4866d3554882d2e91c78725a8da7191d9a1f52e743c975f97f713862bcb6580",
     "third-party/nsh/crates/nsh-platform-macos/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1",
     "third-party/nsh/crates/nsh-platform-macos/src/canvas.rs": "6134e252a2751484fb7a6db63f9c5ed95364126f38f988fc016a846de3a54963",
     "third-party/nsh/crates/nsh-platform-macos/src/chrome.rs": "83129a8a83998c110487f496c04ad9c5e8363c6d6ea6f64666789ac13d1fbb6c",
@@ -1676,7 +1680,7 @@
     "third-party/nsh/crates/nsh-platform-macos/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760",
     "third-party/nsh/crates/nsh-platform-macos/src/scroll.rs": "824c167ffa1e9b6cfee91ab7aa1504b557da5c71003b18f736ab96f0872e1bdc",
     "third-party/nsh/crates/nsh-platform-macos/src/selection_area.rs": "0425f31d882802bdee2f7bc2275dfcffa8274f194c2fe2a5a096efc8b91df4e6",
-    "third-party/nsh/crates/nsh-platform-macos/src/surface.rs": "3068eae4156da32035457523a212c0f03a40096043d6972ca6bcd97fd79fbbca",
+    "third-party/nsh/crates/nsh-platform-macos/src/surface.rs": "52cb73ba0201bcb9fef631535a07144b9ca32f87bad03265070732404e75d054",
     "third-party/nsh/crates/nsh-platform-macos/src/synth.rs": "c6510dcd66cb600e382e74d2d86352196188303bd86a85f12afb21213cf51776",
     "third-party/nsh/crates/nsh-platform-macos/src/system.rs": "1e7be08090cecf5741c52799b7770a0b0dc82ffe78a752abee8ff98f2321577f",
     "third-party/nsh/crates/nsh-platform-macos/src/text.rs": "ff5de5d8db0099f156a8dd643f7d85a804325f78bb9d3b7ea965e36c97fbaabf",
@@ -1708,17 +1712,17 @@
     "third-party/nsh/crates/nsh-platform-tui/src/script.rs": "a0fb944fabd26af711306bdee37342b13fdba26c5066e5752e180e4e916d09a6",
     "third-party/nsh/crates/nsh-platform-tui/src/scroll.rs": "3a5f2de17609b5e93aecaf3ba89a403c35dc4046d2814106a40c0c00a31caaea",
     "third-party/nsh/crates/nsh-platform-tui/src/services.rs": "e871114faa1c56ae24ab047ff2da7ae5df4a728cff17797c8bb5e3009904ec1a",
-    "third-party/nsh/crates/nsh-platform-tui/src/surface.rs": "db4cfb9f6bb0cbff8fdb346b0810762a7975a7b1d8d82f0529c2268c6a55abc5",
+    "third-party/nsh/crates/nsh-platform-tui/src/surface.rs": "bc47eabd0c826400c3469054752369862a4dd10c0e6c2da021f8ff992bd62c68",
     "third-party/nsh/crates/nsh-platform-tui/src/text.rs": "c1e2a055250529ed1cc2888cfe444e306e1ea13c62b3e52f36f1afc1586f5ee1",
     "third-party/nsh/crates/nsh-platform-tui/src/worker.rs": "0eef61c1cc330015df84793e604d984af732f10fb14e1520c7f989253d354bb5",
     "third-party/nsh/crates/nsh-platform-web/Cargo.toml": "d49263fc6c477f87e42e5e6c8852c792bf79068c411b40c98055f6415b616167",
     "third-party/nsh/crates/nsh-platform-web/build.rs": "6e60cd4d9737c2766e2631ff775410cc0aca1fc53d8463356ccce49f3a11f803",
     "third-party/nsh/crates/nsh-platform-web/src/animation.rs": "b19491130e0b1009a483abccfd0a335684973072137c8e14d500827f7acf65b0",
     "third-party/nsh/crates/nsh-platform-web/src/canvas.rs": "9fc4c10800eaa2b69ae34903fbc8aa0a3e4a65e53b8151d3390ee883bd8cb4b9",
-    "third-party/nsh/crates/nsh-platform-web/src/encode.rs": "b776129aee4d45596a5d6c1a6c2631a055e6aceb2a8161a96e96dd870a3c8a30",
+    "third-party/nsh/crates/nsh-platform-web/src/encode.rs": "ee16f1f491eb67549e5f0479fffdf762e1f4e1d16fb556e66237c00a169e4a07",
     "third-party/nsh/crates/nsh-platform-web/src/ffi.rs": "1ef25ae33cc562695438e574d7fead4a8b9d1536ce9ed78e8619d73c4c616d23",
     "third-party/nsh/crates/nsh-platform-web/src/filesystem.rs": "34cb25848b20fe11967437368e5f5feda3f1297982232eb54aa27ab2611cfccb",
-    "third-party/nsh/crates/nsh-platform-web/src/lib.rs": "4470f61a9480768ceea2654edc371997e558cdd6ca2001e1696deca8480dc778",
+    "third-party/nsh/crates/nsh-platform-web/src/lib.rs": "1b862bfb6b6f6eccdaa4460ff5d0528469aa22ebbf13974422bcee39fc50306f",
     "third-party/nsh/crates/nsh-platform-web/src/local_time.rs": "63f2781b1a5364c3914bbefb1bf0538d77876a1324a2e7a9548231711b8241f4",
     "third-party/nsh/crates/nsh-platform-web/src/net.rs": "6ad23669ffb7f920da1d4c0acd9cdf6bf6a1b8f33317dfd27eb40ce0b133fe9e",
     "third-party/nsh/crates/nsh-platform-web/src/storage.rs": "fdff261938b0d75b6b1ff633b4788cc386de23aede2b563fb226508042a3c27a",
@@ -1728,7 +1732,8 @@
     "third-party/nsh/crates/nsh-platform-web/web/local-time.test.mjs": "e8b62dde3208d979eeb281bad19fcb7c19566732500e969ab2ae5c4cf373c9b2",
     "third-party/nsh/crates/nsh-platform-web/web/mouse-browser.test.mjs": "8f3e5d180558a4d4553d01be86dd080ae80638734c83095d90b5507e2ee65420",
     "third-party/nsh/crates/nsh-platform-web/web/mouse.test.mjs": "b6db2b6bb88c0e5b13bab082027c0d84162156350e1c86bde91cbf1f98f63644",
-    "third-party/nsh/crates/nsh-platform-web/web/nsh.js": "55bfc70c093b86520a668cb46db9082265fa534b2965363f7c782787f2157cf0",
+    "third-party/nsh/crates/nsh-platform-web/web/nsh.js": "2ef82397994e5a5ee1fae4e08ed8c8c0e964f014411335a5a324e2608ed97adc",
+    "third-party/nsh/crates/nsh-platform-web/web/nsh.test.mjs": "f67b256bcb312be5ac46a7e0c11e07ee52fdb4e467f41551db460def709062f0",
     "third-party/nsh/crates/nsh-platform-web/web/scroll-browser.test.mjs": "3f8150ebd2b69d7ab306307868b938b99435308ff37c2c999cf297a0f94fd30d",
     "third-party/nsh/crates/nsh-platform-web/web/scroll.test.mjs": "4ea1e845ddd2e480a284caef06f2fcb4dd26f85d0d0808ac8216977cfaff0b30",
     "third-party/nsh/crates/nsh-platform-windows/Cargo.toml": "ca891951f7e5d38889c956e0f00aacffa11da045ed4bb849065c781950c75d9f",
@@ -1736,7 +1741,7 @@
     "third-party/nsh/crates/nsh-platform-windows/src/access/uia.rs": "9b37dace78dd3ec658728ff71c73b61d79da7e7852dbaa573a8588d02769ea9c",
     "third-party/nsh/crates/nsh-platform-windows/src/agent.rs": "a4094f8b42c1096461dd13e2b6145fd497f2dfbf7b171ad35cda3d349ce45e99",
     "third-party/nsh/crates/nsh-platform-windows/src/animation.rs": "5e849ee6e81ef77bfcd70e255228431f3ea2b67266530a10d962582541b78c29",
-    "third-party/nsh/crates/nsh-platform-windows/src/app.rs": "5f98ffac0f08e7dfc58d21f90cf6d4f7e5f4a346b4456cfe47b76d99c6899ff6",
+    "third-party/nsh/crates/nsh-platform-windows/src/app.rs": "29fe32d6642335242fee5afda1859f905947a55c4af8cb979e350df1af877072",
     "third-party/nsh/crates/nsh-platform-windows/src/canvas.rs": "ada51da124bade5308b0dbdc3301b5001c0ae0963ec47164800b316174c21f17",
     "third-party/nsh/crates/nsh-platform-windows/src/chrome.rs": "1d7cddfb2cbcb9e6fd0c02e4ff72ee468caf89871142c3a6a8ffb5207e9717e0",
     "third-party/nsh/crates/nsh-platform-windows/src/compose.rs": "fb996e3c8fa97934361aa72c27f599255f242fc98c79ee1c6226b4ee94c314e9",
@@ -1760,7 +1765,7 @@
     "third-party/nsh/crates/nsh-platform-windows/src/render.rs": "50cc1fd242bd15fc495a77ca2383251ce5ed712688a552de908e3fc268d3d9f8",
     "third-party/nsh/crates/nsh-platform-windows/src/scroll.rs": "28279bf52c6a8e36f173e9e3501470f1c7a17d5b7899390905a08f66202da461",
     "third-party/nsh/crates/nsh-platform-windows/src/selection_area.rs": "79cc6116c2cfd1d33dd5b5e6bdc15222c3460aeb6eddf288fccc09489417b3fe",
-    "third-party/nsh/crates/nsh-platform-windows/src/surface.rs": "680f0210bcfa893a1f4c182a097b4a96dd18d4095c5ad67f27ba0ff539fdae63",
+    "third-party/nsh/crates/nsh-platform-windows/src/surface.rs": "2477b05ae6b5711c8614c240251f04b7d5b7799aa012267341026714b7107808",
     "third-party/nsh/crates/nsh-platform-windows/src/synth.rs": "27fc28c8d4d0b54dec35abfaeaa283f39bc3fc33eeb9d5b1cd9c280a6511966a",
     "third-party/nsh/crates/nsh-platform-windows/src/system.rs": "0466f5714b717e08d7fbaec35cc7109be0bac8d30d2a997db13420c7eb23436b",
     "third-party/nsh/crates/nsh-platform-windows/src/text.rs": "da82b3aba6af2b92c2615dbd27bc40ac7cdda0009027a7190faa349a8d2070e7",
@@ -1768,24 +1773,25 @@
     "third-party/nsh/crates/nsh-platform-windows/src/transition.rs": "dac21868ca439f92c4ecb41197fec60f4b8cf54e0364213dbcf4b8ae9e01cc24",
     "third-party/nsh/crates/nsh-platform-windows/src/view.rs": "064cccd47c5b13724fafe4082dc86954b676b746186c01100e466ad764294ea1",
     "third-party/nsh/crates/nsh-platform-windows/src/window.rs": "84b85a4d05b9194198b647a7244f4dbeec4323df9a85ff6475f73c3ffec46ce3",
-    "third-party/nsh/crates/nsh-testing/Cargo.toml": "a32d7aa17b13e244f7e9c2c6c8a8859cbdec3e878c1ddf1655fdd4b5b7113c0e",
-    "third-party/nsh/crates/nsh-testing/src/agent.rs": "dadd07658ee16f5d6e57f89629ad14233e380ca4b614645d3816df7c6755aee3",
+    "third-party/nsh/crates/nsh-testing/Cargo.toml": "8cab49bdd6a4fce1584e25482263ccb82327a95fde6a2b83b32d9f9447fd974a",
+    "third-party/nsh/crates/nsh-testing/src/agent.rs": "be0bc6365aa19cbe444fbd8522675d99d6e907ef12ec71a5de86c8228fe86961",
     "third-party/nsh/crates/nsh-testing/src/agent/forward.rs": "6e62c09570ba63b910caa280eba059fd2ef87f056bca59d2e5413b4c313ad4b2",
     "third-party/nsh/crates/nsh-testing/src/driver.rs": "9a97ff5d9f7796965e7659298c2b885f214e826ff218c946d64f25f3d7712895",
     "third-party/nsh/crates/nsh-testing/src/headless/animation.rs": "a38102dc120bd208331b64d94c28cfb2b1aba52845bf31d7f387fa552a6ad18a",
-    "third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs": "986176b5894e26d549dc9d6a1db119e06a4c7b224ff32e4e13dcb77f73e4c44e",
+    "third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs": "57f9729e15ab46479abbac9926d81f3c751cacd3be3e2b6136009a11b6dfb34d",
     "third-party/nsh/crates/nsh-testing/src/headless/live.rs": "97e9ebda01076dbbc525618303316f379397af8f07a4e0fcc3372a89096f6597",
     "third-party/nsh/crates/nsh-testing/src/headless/menus.rs": "79276a0066e4505bcf3e5043f2bb800415a84e537415633f79815235b122461f",
-    "third-party/nsh/crates/nsh-testing/src/headless/mod.rs": "fc6ed20e9dd3f5ebb55d03ea8129f43d213934c6ba3adf307308c348fa12bdf8",
+    "third-party/nsh/crates/nsh-testing/src/headless/mod.rs": "7d91786f0375f981748d58887bd81c1b41ea24f835a7ed16cd6986afdfc17df1",
     "third-party/nsh/crates/nsh-testing/src/headless/net.rs": "05b51b76f89a71f11b085001950812cd581bc9f13f9111a867b6cb3dc608f3c4",
     "third-party/nsh/crates/nsh-testing/src/headless/net_fetch_tests.rs": "798c72ea94333a2ce720d313a95b7e5612cf73c1830c9c8aed4ea5550e52f6e9",
     "third-party/nsh/crates/nsh-testing/src/headless/pointer.rs": "b535b3b01c7d7803b8c92aca81258db65caedeff7d13b59d28f8874def6f6087",
     "third-party/nsh/crates/nsh-testing/src/headless/stub.rs": "4f6a52cea583a94614c5552ec02b188d40878e69e65cd5b04ff8904a9f73ff3b",
-    "third-party/nsh/crates/nsh-testing/src/lib.rs": "ac12cd696637cae87b62c08396b334a01b565ae9d0698383ebb2623e1f1eb160",
+    "third-party/nsh/crates/nsh-testing/src/lib.rs": "f2ba1344b98370030cd612d5c03c7a8cdfed8c5d346fd1f9005eb091e66cc837",
     "third-party/nsh/crates/nsh-testing/src/menu.rs": "4bc761c4ef832b71e25630b3232d0b0a5367394b6632b2745edabb807248486e",
     "third-party/nsh/crates/nsh-testing/src/module.rs": "209147852295109003570770a3383fd938a10175849fb8e2be80823c6a660394",
-    "third-party/nsh/crates/nsh-testing/src/run.rs": "69b39dc5e84d927b39269e73b921c7c497b88db2bd073f7475c7ad578e5794c5",
-    "third-party/nsh/crates/nsh-testing/src/runner.rs": "9ac5462e7834dacf12916681b65e2edb7b482f350dba658d8ee338594b220447",
+    "third-party/nsh/crates/nsh-testing/src/page_host.rs": "6c8c8d516a491dc41568cde09fda8bc7f5974bcbce3350b4d907260eaf3eb2e8",
+    "third-party/nsh/crates/nsh-testing/src/run.rs": "0b1c17a4059417d52ca40aa080311db5df08c95bca4f95cd0fd05abfe77dd730",
+    "third-party/nsh/crates/nsh-testing/src/runner.rs": "320e1368ddaf6e60da66ff7faef74f753bcdc5953d4cbff0dc07170dbb7a2534",
     "third-party/nsh/crates/nsh-testing/src/screen.rs": "04cd1b008006e1b5aae501c31ac139443bdbff1b901b21038033fa746fb20895",
     "third-party/nsh/crates/nsh-testing/src/selector.rs": "9567a7d5ecba869684cbe4a9b4d7441c074dab2e6d564ca6463302da0dd40599",
     "third-party/nsh/crates/nsh-testing/src/storage.rs": "72eb5c29dec32f9a8d9afaa3b8d4053a8fc8cc76b0f2bf31f1f76cfd5ad4020e",
@@ -1797,7 +1803,14 @@
     "third-party/nsh/crates/nsh-testing/tests/testing/clock/App.tsx": "88a13af9015a64565d9d5a2b208e29a3f92bf5ad42c26f30fb6b52fa588b7e8d",
     "third-party/nsh/crates/nsh-testing/tests/testing/environment/App.test.tsx": "187831fa2adc650723de6e17ed53e1bd88f80169e91daa946c2b0277c3714077",
     "third-party/nsh/crates/nsh-testing/tests/testing/environment/App.tsx": "84583720c58d63385b056e0f2145d35b22b6facc1d5eeb6a49ddff86d83cbefb",
-    "third-party/nsh/crates/nsh-testing/tests/testing/main.rs": "9be3af39d046d30e1ca4c5ec969292480178bb1f033037bd605ab7e604941c8b",
+    "third-party/nsh/crates/nsh-testing/tests/testing/host/App.tsx": "4e0cbba5c962cd49fc8d4133296be0dbcc25eead3401fa2ec25b93e0409a0c7f",
+    "third-party/nsh/crates/nsh-testing/tests/testing/host/Broken.test.tsx": "38fc46c28004adf81e13f73e8e47322ba73c0b8b20fae40b4e221210a3037c7b",
+    "third-party/nsh/crates/nsh-testing/tests/testing/host/Host.test.tsx": "af3b65cc75680b47ea1c1cb73f53a34842c0bbb4ac693042652a5aa8f5206cb7",
+    "third-party/nsh/crates/nsh-testing/tests/testing/host/NoHost.test.tsx": "57da9b0c7f7dede954ad2def033eb98936f3133ad436d7ae49b695b841793990",
+    "third-party/nsh/crates/nsh-testing/tests/testing/host/host.js": "8d127bcc8a8bc959cb23c48a53fedc83f106dbfd0fd575b8812a1095bffeb1b7",
+    "third-party/nsh/crates/nsh-testing/tests/testing/host/none.js": "56c2d2a72379cc68ea04dbf5acb31d79a2c7673d3c272b2fb6a4ad6965a74ad6",
+    "third-party/nsh/crates/nsh-testing/tests/testing/host/nsh.json": "76e6426bcd425eb7673cb1e68d59e91acbb2f055e8c13ffaf9b05e21d727589c",
+    "third-party/nsh/crates/nsh-testing/tests/testing/main.rs": "31dffcf89c9c0efdff611143476e2ea6ff31fdec888e94d90a65f143ab6a9576",
     "third-party/nsh/crates/nsh-testing/tests/testing/platform/App.test.tsx": "0126c606f69d8fd7b880b0a4c046f0da6187db574ab492a21715fedcc65d8277",
     "third-party/nsh/crates/nsh-testing/tests/testing/platform/App.tsx": "d0a26d35006662ae6f6410493a647defeb5a01dbed7f7fdf78ed9b573b72b956",
     "third-party/nsh/crates/nsh-testing/tests/testing/queries/App.test.tsx": "cfc603ff619f179002576cd89994cd09c3f07cec4782ebf33538aef55c687449",
@@ -1837,31 +1850,31 @@
     "third-party/nsh/docs/dev/compatibility.md": "26336d6349cbedd0a70990dd5a4d2e7e48c604242974ac164e68bbeb4ad1cddd",
     "third-party/nsh/docs/dev/components.md": "9f4a41c405a016275002fc9d850a222dcf941eb47f96201a1a384ba3f8a34914",
     "third-party/nsh/docs/dev/dock.md": "51527992aeec0387fe7ebdb82b7d24ed4ab6f0dc19b3332e23236cb7a3ad1041",
-    "third-party/nsh/docs/dev/elements.md": "94da584a4b2a7dfbd54c83799329ed05220ce8f2c86107df1e556148dd2b790f",
-    "third-party/nsh/docs/dev/embedding.md": "db9490c56c428005722ff46a5c1a2e632b6f6dfd4a1283b99b766629cca58c5f",
+    "third-party/nsh/docs/dev/elements.md": "e0e23205ae83d711e9a67e5203e421af27ddd2d0d992dc2f87d54a4d8e828f45",
+    "third-party/nsh/docs/dev/embedding.md": "b833df433ce8eef19587bde3d2805abe41859811c6c0794b99e37499227fcd2f",
     "third-party/nsh/docs/dev/file-drop.md": "ee9e7e263e32789d03893108e68b47d2447a0fa19b29be6b348f269884cb81b0",
     "third-party/nsh/docs/dev/geometry.md": "cc747bc4cea9d983b1ac3d634be58a8eac27254ecb912c2940173f168de8f9a9",
     "third-party/nsh/docs/dev/gpui.md": "15cb649e427c84858f177b0d85ae0540dcd4ae23a3b8b3df414a9f1dab6b0284",
     "third-party/nsh/docs/dev/images.md": "9e797fd623bf317a5e96fdc7d04ac1bf85f2fc82d267a2d220e3e39c9a6f187a",
-    "third-party/nsh/docs/dev/js-modules.md": "991718901c2e0ee19c3776a7f806d30717c0a1842a5f4c5f1bfb18343795ebb8",
+    "third-party/nsh/docs/dev/js-modules.md": "f142385e5d7e0bd8eac89814f0c3ca6d539d86b9d9acc58bf5717ed43470974e",
     "third-party/nsh/docs/dev/layerfs.md": "df9b16b2f5f1e61f9693f63b61438a51461d05b535a0046d50ac9ba1ade12b8b",
     "third-party/nsh/docs/dev/linux.md": "4441ec64b6edc5c03084bd203aa5114c066f9e2c0541ee320be8f17b6f82c3fe",
-    "third-party/nsh/docs/dev/macos.md": "827c809b5774681c77daf05ac6603342c027dfd459e78f55558044861711dcd3",
+    "third-party/nsh/docs/dev/macos.md": "c2b332f4a6e72b60520ebf34d8875a6776662ddc9d1ffa62963744a62961c988",
     "third-party/nsh/docs/dev/native-pipeline.md": "07f803365af6ac90d5247c70d2e85ce9e03393f0e9e122c46690f85e381a0c55",
-    "third-party/nsh/docs/dev/performance.md": "6d3f36e2e44b5ccba8e1110b0b6fdfe315768444af325451bcebf019734e6d11",
-    "third-party/nsh/docs/dev/pitfalls.md": "80fc820be5136534546eee9790dbc3b824cd1053fe45432f0187f4d25641bdb3",
+    "third-party/nsh/docs/dev/performance.md": "4a49973e31848b26dbb8a8e238bf8417bd7015a6b323f75e2551645078651f76",
+    "third-party/nsh/docs/dev/pitfalls.md": "ec02fe363e800a5f338afed88e16a83d21971549fe47c4da553d4e865cb84d21",
     "third-party/nsh/docs/dev/reactive-components.md": "cd447fe8cfbe9026d7cadd5fa42d9c387e4c1b1b7e1c5adddd55c3f1beb94a4d",
     "third-party/nsh/docs/dev/rust-classes.md": "1f70015213dbcf56e6d6e4641019092003c22eb57ca324d9c4a85578b4468139",
     "third-party/nsh/docs/dev/rust-modules.md": "f793bfb12272ded93e80876fd2c72ff8dc0f1bd0337977f39b750af495a83592",
-    "third-party/nsh/docs/dev/sandbox.md": "4dd889e136fc64a6024a60e07372868e9b25753128a0cf3925966f083daf7f27",
-    "third-party/nsh/docs/dev/scrolling-and-lists.md": "158f7fc20773ad96683db9f6c6b2588f97274e7c9ed4e2958dc925b1ecf0e750",
+    "third-party/nsh/docs/dev/sandbox.md": "86768ef1e0d3aeb6468fa075eb9a3a00adbe4ac4f576586d82fc495ca398fe5b",
+    "third-party/nsh/docs/dev/scrolling-and-lists.md": "23c10d81c623a3b2271b240843080c5cc9da0a5a9ef85c79411570fd6f2cba69",
     "third-party/nsh/docs/dev/signed-packages.md": "6e762f506428d53d347106e3619921a5595391105128305090da8eb8a20c220e",
     "third-party/nsh/docs/dev/styles.md": "fcb5410f36c3d0ec08c300a7d624b19ad9d62404f665e9f4a2c61dda001ae532",
     "third-party/nsh/docs/dev/text-view.md": "ef89f0a8af990356b33b5bfdd0f79e9d86342024e04ad8e08aba9e9ab13c6693",
     "third-party/nsh/docs/dev/text.md": "7a4f09386899d5e6a5c3dd7c886063db73fcb5b288b47d0ff37cbfc8b4911dfd",
-    "third-party/nsh/docs/dev/tooling.md": "1e605160ca6bb185136e1ef28f1cb703c4db5ac24647570c2c7b5a7c80f65fee",
+    "third-party/nsh/docs/dev/tooling.md": "265562522caf036982b8494712949387f8edbd90dfabd6e5355efa1f5624aae2",
     "third-party/nsh/docs/dev/tui.md": "47b56095f90ba9caa3efc65acc10bb3e255b674d9cf05d71fd5a30f7a43b1770",
-    "third-party/nsh/docs/dev/web.md": "7c6f900d89b7f46f840cbaf271664f5979130ec9175b5695eed8afc30cccbbf4",
+    "third-party/nsh/docs/dev/web.md": "fd9039c76d0f8259cfdcb894add27d2cbcd04eda67d06c57d614b9f0ce6c1ade",
     "third-party/nsh/docs/dev/windows.md": "099245b28187723d0ce93f9b6f0b4775bb19f25c625e8b3db558fe175dd0bb6e",
     "third-party/nsh/examples/bench/GridTs.tsx": "e1388190424c85d044232f9dfdc897acc69fc4464b23b57db149f9276336e3b4",
     "third-party/nsh/examples/bench/README.md": "75d32dc5f73955e42cd171baaeb7df39b19cbdd25e67e661df7976a887ca3506",
@@ -1875,8 +1888,8 @@
     "third-party/nsh/examples/counter/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "third-party/nsh/examples/desktop/App.tsx": "069c5a14642c56403f8786b8b96187d226f08e4078f37a7551f253433c65b49d",
     "third-party/nsh/examples/desktop/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
-    "third-party/nsh/examples/embed-android/README.md": "2694fb610d63a1e2bd816533240fb0bac3c66d203a0be7e076d06098c75f2573",
-    "third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": "482e4f1f734450f2a4acb9dfacee0995ef69947e70ff25e7fa2ce93c6cb2c782",
+    "third-party/nsh/examples/embed-android/README.md": "d74d883aa1e65a564c01e897a0781adc8279aa5c6e13f5e247b117bcf2f223ab",
+    "third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": "ce94b18d9bb436c129cc38ee579c8823b1bbaa216bc7e11a5ff6d344e732dcbf",
     "third-party/nsh/examples/embed-gpui/Cargo.lock": "16bedb298e480edba2cf2548b07cd3583c7471c3ef7aac852c86da70da7b2886",
     "third-party/nsh/examples/embed-gpui/Cargo.toml": "a1f4727ec5fbc760f3678bcabfb14e3d34d37d71fb0b243e40920e55b8ed1f21",
     "third-party/nsh/examples/embed-gpui/README.md": "bf86ae2881ad77ff9bbd0292d64e2572927b0b197461fdb52c67a049f99c9dea",
@@ -1894,9 +1907,9 @@
     "third-party/nsh/examples/embed-gpui/src/trading.rs": "d74b621e04b6a04a622033c319574c8eca80051bb7bb2619a872377c42ebdefd",
     "third-party/nsh/examples/embed-gpui/src/watchlist.rs": "7d46e105a6f00e31a3d225e5b1430616f0360cac5b7ac8a63add0ebdb6397ce2",
     "third-party/nsh/examples/embed-gpui/themes/violet.json": "7da650a48cebde43683ef6f50bbb27a5000dd79911763e46982688160a59e2ab",
-    "third-party/nsh/examples/embed-ios/README.md": "a5f2a52371ad660b6c5e24333f7282475e4b544a564770035f10efbcb1a6937d",
-    "third-party/nsh/examples/embed-ios/main.swift": "6c2bb633521fc06e45351115ff8b34d5b182106e7171e5ab4c8d376585962020",
-    "third-party/nsh/examples/embedded/App.tsx": "11c06c478613046c7ebb3b936366d08abd66e4f82e3b45037ea1648d01cacb27",
+    "third-party/nsh/examples/embed-ios/README.md": "99c5f76f3c3a2acb2008796fb71ca864ac853a293583005acb82ca155d5da527",
+    "third-party/nsh/examples/embed-ios/main.swift": "fbb648c731f509cdb9ce20a70c91090291863a8f643482ab8727a32324a0e3f7",
+    "third-party/nsh/examples/embedded/App.tsx": "416fbcb5da5df7d68448da5e3a620efec9afa8f730efc432345e9fe7ebdbf26b",
     "third-party/nsh/examples/embedded/nsh.json": "3ee671a3404ac47b07142fc0803df84417f265279c139a0616c37f772580c8ce",
     "third-party/nsh/examples/embedded/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "third-party/nsh/examples/gallery-rs/App.test.tsx": "7aaa088af8ed0749b5dda6bb90e7c8746db0735d0706b7475ff0b38e1d8bc27e",
@@ -2042,12 +2055,14 @@
     "third-party/nsh/examples/gallery/tests/error-boundary.test.tsx": "f71d9c316fcbea37f15a7b3be31b7e82aacb09c6b550553f3636dccd9534e94d",
     "third-party/nsh/examples/gallery/tests/fixtures/error-boundary.tsx": "94516c85cc83f4824d71bcfac24ba972d5dc3bf6ea39060752a5d6e097ecca7a",
     "third-party/nsh/examples/gallery/tests/fixtures/list-search.tsx": "ead5f110c6bf3885ea230b10c2a80fb0891d26c69ba46e33a437c6e89c381143",
+    "third-party/nsh/examples/gallery/tests/fixtures/mention.tsx": "329706d1ac88a20f6f60fd82bc4a8ba97dd0092838110355463588036c7420af",
     "third-party/nsh/examples/gallery/tests/fixtures/press.tsx": "a91869ba8b693abaa3ca130a46bea568a4c251f20e4cfa7db6965a45a456181e",
     "third-party/nsh/examples/gallery/tests/form.test.tsx": "8a82127b536f73bf3fbcf23881cf802b30d0346314d2783bcf9da52d45e3a49b",
     "third-party/nsh/examples/gallery/tests/gestures.test.tsx": "adf7fcf2afe5029f2441eaf048251a1d33530467df91747f170968fbdc6f3a08",
     "third-party/nsh/examples/gallery/tests/hover.test.tsx": "dae609cc2f03243b33ef3d46f58b1dadef73645b1223f1796c3f246606baa5bb",
     "third-party/nsh/examples/gallery/tests/links.test.tsx": "db1b97338bc81b437228d2015926f55a469fb1ffef35cdd57961977952003963",
     "third-party/nsh/examples/gallery/tests/list-search.test.tsx": "80eaf7e15d7f285662f1c29c662ac075722f04a0cb44220f8a1a5c55033f1ac6",
+    "third-party/nsh/examples/gallery/tests/mention.test.tsx": "ca76505adb487f51a89b7272bcd8afa4a750807df1a9c10d8c006f780446476f",
     "third-party/nsh/examples/gallery/tests/menus.test.tsx": "2db06b0b97d2a3dfcaae959fcec37452980158e93a97a94ceda2e318a2f39313",
     "third-party/nsh/examples/gallery/tests/navigation.test.tsx": "76fcd06532791d18abb88afef2c951ab950adf148f10ecd1e8fe32cc3a718569",
     "third-party/nsh/examples/gallery/tests/network.test.tsx": "aaab1989a62f65e9c92c69ef8fe56fdba65677db382a00796b37b21199bd7d15",
@@ -2077,6 +2092,11 @@
     "third-party/nsh/examples/hopper/theme.ts": "635f518b81e7408e5969ca59dbd25860381857f37e9980d225a1a54ba373a38b",
     "third-party/nsh/examples/hopper/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "third-party/nsh/examples/hopper/ui.tsx": "d896dfce94d4c96585275f3b47e2e418e2867ef8aeec5c6148adf3401155f8a0",
+    "third-party/nsh/examples/host-echo/App.test.tsx": "ccd51aa627dc36087d36614b02c97dd1f08513146261b2b282a7997d1594d8f0",
+    "third-party/nsh/examples/host-echo/App.tsx": "b71e5c39233a5bd6891ced4f713af67e414607daa119bd3e28bff38fc0597a54",
+    "third-party/nsh/examples/host-echo/host.js": "da4dfc0d7be297fdb12ad686bcd251d9da4036ecd2aa5b2058d685f4b7ebfa9b",
+    "third-party/nsh/examples/host-echo/nsh.json": "c0059e3ecaeb57be9f29502f727184234d7166e1e0e1f9fb7c22a80f97000430",
+    "third-party/nsh/examples/host-echo/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "third-party/nsh/examples/rust-exports/App.test.tsx": "98df2fa881eb3b6d1c383c0b6f77d6753d178e93df556111258dbdfc66db7cec",
     "third-party/nsh/examples/rust-exports/App.tsx": "3c64df906f96cc4495f481c133713d33e9dd95c493f449a0e7aa7342a34b315b",
     "third-party/nsh/examples/rust-exports/README.md": "6e6e2798773876a6c586ce914d31680f2a2890c0d65c3067c794dd946d86c2c8",
@@ -2147,7 +2167,7 @@
     "third-party/nsh/tests/compat/cases/core/core-error-boundary.tsx": "c2de89bed501a5483446830f8ec19e0a1fe7131fe0af8c0fbf8dcb6b9942979a",
     "third-party/nsh/tests/compat/cases/core/core-input-action.tsx": "9e6c67b4c7cad5580219045666bd1f5d91ea377c3a371717adb9d25950194a59",
     "third-party/nsh/tests/compat/cases/core/core-list-binding.tsx": "b9e52b8e5f875188a98b09226d410410318846c3df9f97b1852365e063fe7dbd",
-    "third-party/nsh/tests/compat/cases/core/core-list.tsx": "1851cf6e7c7b0bdc7794eb2fa12b316b8e0d96fea3dd1e99398e54d1c013ec9e",
+    "third-party/nsh/tests/compat/cases/core/core-list.tsx": "360ac05ea133de4ba541e4147b220e350d6c081eeccf8b15c2a10b290ad5b626",
     "third-party/nsh/tests/compat/cases/core/core-overlay.tsx": "96a006df0796850f1b9dfa68cc4fe5ed6868988635ec7f47713523c47fa303ea",
     "third-party/nsh/tests/compat/cases/core/core-primitives.tsx": "2dc25a3b00f0f6c3450128d85b52c6354e42618f0ce6599cd7b3196bea81df12",
     "third-party/nsh/tests/compat/cases/core/core-reactive.tsx": "bcde98c407709730db5a6d2a620f01f322804d9184f0f727626034660ad2f5d3",
@@ -2155,7 +2175,7 @@
     "third-party/nsh/tests/compat/cases/core/core-resource.tsx": "cc3d33c1321a04615b7fef7ac0d759fa5d76e7a3f5aae671c5ebff050a2769b5",
     "third-party/nsh/tests/compat/cases/core/module-url.tsx": "8bf4a7a606496d4d5f321a8f722892de01e845d56bec5f42d9767ab3c516bb1d",
     "third-party/nsh/tests/compat/cases/core/nsh.json": "c4354909c4780abb723666fe31c2c82d50cbe3839f62c169dc0ec2f145d8a728",
-    "third-party/nsh/tests/compat/cases/core/script.txt": "46009ab58c932240d2aa0997b14d47eac6a9d01c92cbe845b73df3abcd7d0bd2",
+    "third-party/nsh/tests/compat/cases/core/script.txt": "819fa77036fdaeb5fbaf17e1d2d47317e88c7196396031212486c8e49d20f873",
     "third-party/nsh/tests/compat/cases/core/steps.tsx": "bcfbdf96ffeb91d9d4cc5abd0a562cf612fe73c04ddf9d8bfdeb5a7d14aacc37",
     "third-party/nsh/tests/compat/cases/core/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "third-party/nsh/tests/compat/cases/events/App.tsx": "cec0a2e9ebfbf3c0c0b1d6e05760023d66bad7b30ec1e27b0213ad6752eeee38",
@@ -2189,18 +2209,19 @@
     "third-party/nsh/tests/compat/cases/macos/mod-standard-system.tsx": "17363588e50524931b2a21b06b7319c39864ef77c35d65cd3708524c185337e5",
     "third-party/nsh/tests/compat/cases/macos/nsh.json": "81592a22d477fd7aa971a58119b710cd6b49d58517f6dac65e2f091567c4c020",
     "third-party/nsh/tests/compat/cases/macos/script.txt": "adfe51bb062be578136db927b767640d286ee3b4e8847f3446b0cb47a5847514",
-    "third-party/nsh/tests/compat/cases/modules/App.tsx": "a96f5194e1ccd1fbfe3c40d03ab18d4edc78465ffd15c00e768bf041372d40da",
+    "third-party/nsh/tests/compat/cases/modules/App.tsx": "48cdf7cfd2b54f36c5b3b920879411583c898e4656c2c96443b962cbc899503a",
     "third-party/nsh/tests/compat/cases/modules/mod-accessibility.tsx": "139148dc45f9402dee9aaaf78587690fa4ede4a209ec39c23c2e3eb379ae1e6b",
     "third-party/nsh/tests/compat/cases/modules/mod-android.tsx": "0bc2aca9677c91fa0d1e3693110581c5f76fe32ea2e0eb3f8fe5d4470395c8f9",
     "third-party/nsh/tests/compat/cases/modules/mod-clipboard.tsx": "0ca63a3ad7e0b77672e688fb3e5ba3d1fe79904fbd5640646074c1ca22d4951c",
     "third-party/nsh/tests/compat/cases/modules/mod-desktop.tsx": "23c2fa95911c0ac2be36fd1fddf78c1c7aacfcf7e79e66f0df89b416e8cc3909",
     "third-party/nsh/tests/compat/cases/modules/mod-device.tsx": "6b384f6830bb0da03344986ec02a1e394d307a5b5ef13dc1fd53ba272d04de34",
+    "third-party/nsh/tests/compat/cases/modules/mod-host.tsx": "6fb70e966bb0208e1fb9c2dc847146ad720227a3fb421e4b5695a2d6c525037c",
     "third-party/nsh/tests/compat/cases/modules/mod-ios.tsx": "15398c7e334c6067bce708ef6303d534380b1b2d3cf9ca90c7fdd6423fa85b3f",
     "third-party/nsh/tests/compat/cases/modules/mod-keymap.tsx": "d3d40c3ec2a9d72a42a33335255b93703e1b87f6c94a693837ab79b69ff16ae1",
     "third-party/nsh/tests/compat/cases/modules/mod-standard-access.tsx": "c6f8c9d94ac06655950666e356af86d219379f95b4d6978c9b81a07077f5fd5d",
     "third-party/nsh/tests/compat/cases/modules/mod-storage.tsx": "cfa8ffaf50301e957f0da33a075c6f4a8be93e758555d720dab07f7ae473410d",
     "third-party/nsh/tests/compat/cases/modules/nsh.json": "8aded49d92a6f2cb8db2e005d097aa70f60f26e5e6639b98f45cd89154f3feb2",
-    "third-party/nsh/tests/compat/cases/modules/script.txt": "25dcfb68b52245396ca162aa57b002b0ce100c6c10c5fd48b472de9f9b9bb8aa",
+    "third-party/nsh/tests/compat/cases/modules/script.txt": "8deea2cbd8b57c68f4be2b9cbc53198aa2229ffcdc05fcd132fdc310aca3c037",
     "third-party/nsh/tests/compat/cases/paint/App.tsx": "c8bcbb4b62d5ecabe0fc98c7e6524e451193952701215ebe614a92168107c316",
     "third-party/nsh/tests/compat/cases/paint/nsh.json": "56f891ce05e7aefa7949e3445a603cb477313be037897c75acb12f136c1a85a8",
     "third-party/nsh/tests/compat/cases/paint/paint-borders.tsx": "388fb9d77dced948c4a014ce42af65f3509153347ad574778983b0263277a428",
@@ -2302,7 +2323,7 @@
     "third-party/nsh/third-party/icy_sixel/src/encoder.rs": "9f2941a073f3df5d1216bb4f71487a3c43b5ceded1ec88cd13a804b4d7e31999",
     "third-party/nsh/third-party/icy_sixel/src/lib.rs": "126c4edada7c430377a73839c9da93cae95f9191be69c1401fb4ac1cad9cbf5e",
     "third-party/nsh/third-party/icy_sixel/src/sixel_image.rs": "1e968370dd0ff8c69751213a1ed11cfce6783feabb30c2b5074ac1c4ba3d8503",
-    "third-party/nsh/upstream.json": "af929e730cc5f51898817f8524d59ae37b193bb961c02a8fcab3c21f06958b49",
+    "third-party/nsh/upstream.json": "ec5aafbfe7590e0a6e37af8d64eb8f554dd32c91cd3c830e8c3a0334fa352580",
     "third-party/nsh/website/DESIGN.md": "484ab5b78233c1c57b0d46e631d2341031b2b34a74740abbb88a275b4c8134b7",
     "third-party/nsh/website/README.md": "50de9faed8cd3b0751991d6168ac6e33a6437dfd36aa15dc30d35b13dfb94284",
     "third-party/nsh/website/astro.config.mjs": "0cfae54c7791bd915865dc535669b311e4f76001efed09fdb2772d332812924f",
@@ -2368,11 +2389,11 @@
     "third-party/nsh/website/demos/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "third-party/nsh/website/demos/ui-navigation.tsx": "0ede3abc087bc9a66a69d3efb9f1997dfc0da76008e7c88311b5a13ee4c2e01d",
     "third-party/nsh/website/docs/accessibility.md": "acc7f899e192a16af5d3a07395a26c87e3ada2e05dfbad8bf453779e461fcb51",
-    "third-party/nsh/website/docs/android.md": "6056ebd29e675f8dce7acd16d711b9a7c66c5860bf3665d20b6125e31103817d",
+    "third-party/nsh/website/docs/android.md": "ded49e2a009b7e012a7d7f0e37f15c00e04c82333a99a7ca2ffbdfd6f87df83e",
     "third-party/nsh/website/docs/animation.md": "74859043103a0e12b3e2cf22f5b29ecf3cca7dd9e4009801b38c9d5d5aa2ceed",
     "third-party/nsh/website/docs/architecture.md": "1a7f3fa3704a5e1c5f4dd6c2df4986c07cdaa247977f7b1e3a13464bfd82a8e5",
     "third-party/nsh/website/docs/canvas.md": "5589e8b7ceae1856e21081b30d3dc424dc977064fcbb711331f25165c962b4e7",
-    "third-party/nsh/website/docs/cli.md": "a62a929b93aba13d6074966228300854273c7cec3e00d9c631f6465cda98017d",
+    "third-party/nsh/website/docs/cli.md": "9fca8064b0726580a9a357044ec9a5955b93ffd028eeb2a3ec5fe15b915ee2ee",
     "third-party/nsh/website/docs/coding-guides.md": "38953161931200894317fdb0aaa25e5fd65a52b28ac2ac03e2196f771050dca9",
     "third-party/nsh/website/docs/comparison.md": "80f11e255cf3463f29d5610213b57808e1ffa92c5de021ebff9e9e153455dca5",
     "third-party/nsh/website/docs/compatibility.md": "f2a0af8428dd5232052921e94a6a08914ee4e523c8f3dbac0db51f25c0da6650",
@@ -2386,13 +2407,13 @@
     "third-party/nsh/website/docs/images-and-assets.md": "25b3f1455863969f1ce2dcb8961a30783c425c3d8015fe51a4ef5a134cfc2aa6",
     "third-party/nsh/website/docs/index.md": "31a039507f14aaf881b9fb351b18a837c5a53650ee8449b14afd91ba1fc22281",
     "third-party/nsh/website/docs/installation.md": "a7b1a6804f151719d35b8b62eb5b3861712a24cd90175730266ee5fb0874027d",
-    "third-party/nsh/website/docs/ios.md": "2b6907f65fbe688850c11bda89d54caf4cd43f0e350d34160544fb3b8e9f5811",
+    "third-party/nsh/website/docs/ios.md": "a7a5d71586c90f020360c0287071a404b2905acc89353e857ca124d568ea6c81",
     "third-party/nsh/website/docs/jsx-and-components.md": "75c24c3cb56503befc7c06d9eaf67c844318890d9fc989427231ecc9e6e9e627",
     "third-party/nsh/website/docs/layerfs.md": "827ff94d53cb27ed7a686776b2532de70d0cfaebf0cfc7ba39b433f42ec5342b",
     "third-party/nsh/website/docs/layout.md": "80c8f0995e88d01959766d6d95e86ead1034599b848e6e572ddc0c554fe5bf89",
-    "third-party/nsh/website/docs/linux.md": "6df424ab04997a1c29fa85ec34ec883c150aaab1f1165d631445f0699a217616",
+    "third-party/nsh/website/docs/linux.md": "c5432744bf1ebf18d8afa74075ccb0581e98f5e98255a07cb9c34ab00153b917",
     "third-party/nsh/website/docs/lists.md": "b59033e583a9c147c0a7cf7309bf22b3744946b0a6a6bac3ee1a3f079aa51d54",
-    "third-party/nsh/website/docs/macos.md": "83aeb6c4d8214a401cb6413c32cb74b0d62243d59bbbececa32a6dd4ddd18420",
+    "third-party/nsh/website/docs/macos.md": "4b36f58b9662268707a5868a0443eaa55b44ad59778a25b71e421c970143412f",
     "third-party/nsh/website/docs/native-components.md": "cd8272bfb271dece1f395009077a762c972c282bbd9246265ee9f6a4ced0e656",
     "third-party/nsh/website/docs/navigation.md": "8a4a9ac5d9a44ce8e9a30680b4ab9c0a4d6d4bfd556b8c7347074eefb3ba6357",
     "third-party/nsh/website/docs/networking.md": "7447d4ffeefffb33318ada3594160ce30a47adabb4c59bb66bd360a465c5a2b4",
@@ -2400,21 +2421,21 @@
     "third-party/nsh/website/docs/performance.md": "f6c4652eef83d81abeee6f2d2dfb370b14a7bb78b2dbb25313afe3a25a20dece",
     "third-party/nsh/website/docs/platform-specific.md": "acca9b1bce6444f64370e60eb2911b6e8467a21dbf2078823236ab22c95ad872",
     "third-party/nsh/website/docs/platforms.md": "a52bc237b38320867f6f1e7edc65aec2d5006d2b412fc443bc6634a635dda93e",
-    "third-party/nsh/website/docs/project-structure.md": "8465844846b69eddb6a2bf532b2cafa8a63e583c5b1ce246952c73d972588230",
+    "third-party/nsh/website/docs/project-structure.md": "5f65867b10167d7efe437147d20e794264deff24a647f9924d956e5d8a0f88a0",
     "third-party/nsh/website/docs/reactivity.md": "8c6731f9556ec0c3a4f401c7861c7198cca53a4bd5e95b57f2f773e7acdbf7a8",
     "third-party/nsh/website/docs/rust.md": "b3d18c6b202c136d7efbba0f7f87e9d0ae26d2469551f1bb09f47e955333ed34",
-    "third-party/nsh/website/docs/sandbox-and-capabilities.md": "4938ef6da08859de906c236ead2a8e70a478c599580dbbfb3ed2201de4bb0dc1",
+    "third-party/nsh/website/docs/sandbox-and-capabilities.md": "1860458dbe33fad7df623efa372bb9568b6e839aad269429d7595b5733cfb0d9",
     "third-party/nsh/website/docs/scrolling.md": "54356ccb5d65b3f37263339c91e1fb0ee2bc5db32729a9e9b235f2cce51e6850",
     "third-party/nsh/website/docs/signed-packages.md": "203da373bbcf21d84240b2626d91860e4f3d745733cbeebc22a42a042d0185ee",
     "third-party/nsh/website/docs/styling.md": "30ecef6fd8f7e881867574898cd05dfb7bb943a996a8da48db2dbfa8af1c4bd3",
-    "third-party/nsh/website/docs/tailwind.md": "0bfcc244e1cd81cdb3fdbed95c36af126a1284a2f559712007005927c4bea4af",
-    "third-party/nsh/website/docs/testing-and-trace.md": "a0713c55a7c8c1091c85209fd43fd74911a1bba4bf78f871de0676a894b9112a",
+    "third-party/nsh/website/docs/tailwind.md": "e3c48864cf4018b4908bed97b92f69ac66395c4cf74600d84b3d1650de843292",
+    "third-party/nsh/website/docs/testing-and-trace.md": "b4afd8ac17ad3fe7a41a4979495a30575fe19392f5e7b159626479d9737cc5d4",
     "third-party/nsh/website/docs/text.md": "afecd14b1f27f340c52dd22666b26f014edd698f1d4ce96dd61188d5203e74c1",
     "third-party/nsh/website/docs/touch-and-gestures.md": "4733ec6aad04835324c174900e099737e39bb6cffa84227959193d95c5d2c39c",
     "third-party/nsh/website/docs/tui.md": "28f225fcce009393ab1a3a1ac672b3cf3a599e3712fbd344516ac0d3b8a2f7a4",
     "third-party/nsh/website/docs/web-view-and-video.md": "249dc38d55b3549052396f782f8087d3abae3e81c57fc64b24589f352acadd5e",
-    "third-party/nsh/website/docs/web.md": "1aa3728395fd9a551a0f8fa34d02d384191d8fa82ab52ed85e852078951ba817",
-    "third-party/nsh/website/docs/windows.md": "e715393ff39d08901c3a21add178277da9fdf6c38cf1b32d7a2921aab3896ea2",
+    "third-party/nsh/website/docs/web.md": "73e74007aae291bab262fb1372f41c8f61eb723005780b911555e2a45be0723b",
+    "third-party/nsh/website/docs/windows.md": "5c52b9a5e1c03a11a8d13a1686b27a082c21c9663f88050375b44f8f4b55ac03",
     "third-party/nsh/website/elements/alert-dialog.md": "ee0f2bcd0d4a622a5e1521dbd9c854cf3e315b5848104ef3f07ac8f6033d9e5c",
     "third-party/nsh/website/elements/button.md": "0ee605f47eb3588b4efeb8771bf36b63962101cacc1ed1883d8d22a7a3568da9",
     "third-party/nsh/website/elements/canvas.md": "c55d6fc031c3b04f91962ea80714c0593db133fe60b228412097b4c97ca20490",
@@ -2448,7 +2469,7 @@
     "third-party/nsh/website/elements/view.md": "a532351633e057acff510fa3dc45c6380724fb166a5784ed15b928b77a13dc37",
     "third-party/nsh/website/elements/web-view.md": "7a9ad687ee2afb124f317f4e103572eb07fa8a71d7202d09c8f971130feea591",
     "third-party/nsh/website/modules/globals.md": "2c4c6b555c0cd2d0aea5dbebd478d1eb0597a419c379fda4853d55d8f79b8215",
-    "third-party/nsh/website/modules/index.md": "ecd4c0b82d978205fa9e75673e854f04b9602550f0056cd30f2fd864a65f6fa8",
+    "third-party/nsh/website/modules/index.md": "38620a429fd176cb882a3f7e958a92e6ffeb57b576540707182cfe4b6d92dd7a",
     "third-party/nsh/website/modules/nsh-accessibility.md": "c446bab2d7fe6becb274bf60546d49e13cd4bab1fe17a80ab494c2f7b3ee775e",
     "third-party/nsh/website/modules/nsh-android.md": "3bc295d3913732b6f1b75d78a8e879e32fd00f8e65424c7b8468ecb80185a8bb",
     "third-party/nsh/website/modules/nsh-animated.md": "f50131304ae97c0537af4927bf4ab62524c91068806ce81f3b9daeae6f09662e",
@@ -2458,9 +2479,10 @@
     "third-party/nsh/website/modules/nsh-desktop.md": "74d5b00ac1d1e27da1e439aed901745108146f9495bed44ef114696e1abb5c85",
     "third-party/nsh/website/modules/nsh-device.md": "5a49c9c522b883559d6f56ec648dd8502833c69223d52737eebf74bc8f12460e",
     "third-party/nsh/website/modules/nsh-gesture.md": "242251b48db3e0c70247cfdb243468a0e2641608e70b66914b696450cfca8bfa",
+    "third-party/nsh/website/modules/nsh-host.md": "5056a6c3c92b50b37db6a20aeded716aa2fcd23b93992eee5a1615179e1dcc1d",
     "third-party/nsh/website/modules/nsh-image.md": "9291d338ebcc168cedbfc597188691994ac25d7ac800da3659bcc83b356a197e",
     "third-party/nsh/website/modules/nsh-ios.md": "2c6ea1a8c6c25bb06064e6cd6924cd3ab8a890921b151107d206880ab870eea6",
-    "third-party/nsh/website/modules/nsh-keymap.md": "b45421fbeb6179a5910a4bf2c2e001ddffbd22037a4e87d7e623a63d32d7c206",
+    "third-party/nsh/website/modules/nsh-keymap.md": "16bb290cbbf6f6f3395f32063dcb9257d4013dd3eb5514b589a6935f29080b9a",
     "third-party/nsh/website/modules/nsh-list.md": "91d2b6895835c0327bda430508c483f08d1403d5218e99988555eb2928b38b0d",
     "third-party/nsh/website/modules/nsh-native.md": "6bcbcbebb59cafdf01ab56b9c71c6731b941bd8f8431e353cb87bbafd18b18c5",
     "third-party/nsh/website/modules/nsh-storage.md": "4d2918fcd5d8a19ff6a2716fcd5717191350f66b39af22c4ca37985387a4c1e2",
@@ -2555,11 +2577,11 @@
     "third-party/nsh/website/tests/storage.test.ts": "4711c006141dfea8792fbb1fe2c219746155f57a32650e0fc80aa56b404d1804",
     "third-party/nsh/website/tsconfig.json": "7b292fd267352f901284513544cd818de4a8a8fe15880f662b3a0900b2a93c99",
     "third-party/nsh/website/zh-CN/docs/accessibility.md": "63977505bc163dfe40b4d0e40168944c28153dde29a73be1db9931d90bb29bde",
-    "third-party/nsh/website/zh-CN/docs/android.md": "b64f4fdecf8de8eff2feec7eb185de3982f2d98e78e30cc5aa5eab7399d95aed",
+    "third-party/nsh/website/zh-CN/docs/android.md": "eba50396501d0dde571dedf1d10ce224d93edc781652025d83e366e5a421c4c5",
     "third-party/nsh/website/zh-CN/docs/animation.md": "a6d266e73b250cb3274784b48dfcc99ad791897ffdc5d6788e80a11db4fdebd2",
     "third-party/nsh/website/zh-CN/docs/architecture.md": "6759f09942a51486476a68a37e12c833be9430012e8b2da137d9123632c00294",
     "third-party/nsh/website/zh-CN/docs/canvas.md": "8a237d25ef877ce00715d88a03e50493667a9961ccfe045372485b0ff48c7b77",
-    "third-party/nsh/website/zh-CN/docs/cli.md": "d6971d9ce2f9875fcd1073a15c2171ad71d767a4365669ad172844c6d6ef3967",
+    "third-party/nsh/website/zh-CN/docs/cli.md": "d61a977ebbad616432c8fdede8049d7b0ea1d44f54319356fc58ffd05eb79c58",
     "third-party/nsh/website/zh-CN/docs/coding-guides.md": "34af3e5c4f13861a7336aca26e636aad3a4a2a71ebbc5d806d25135c7882a716",
     "third-party/nsh/website/zh-CN/docs/comparison.md": "05e6ab8a44d3db224895d7029e029dbb48a9e74092e5135099ab0179a6ee974c",
     "third-party/nsh/website/zh-CN/docs/compatibility.md": "1990079dc6a9ed2c4c72a0ccd6a10aae154e5ca722f2c6d899d5874345f62d76",
@@ -2573,13 +2595,13 @@
     "third-party/nsh/website/zh-CN/docs/images-and-assets.md": "de6564e02b94897bfa6865353acfa8d007b331f0dde1abe572db12281c654d63",
     "third-party/nsh/website/zh-CN/docs/index.md": "bdde9f1324a8a2511bc1b740f3a1e42be071ab1390363a7152a4bde1705c2deb",
     "third-party/nsh/website/zh-CN/docs/installation.md": "149ece60315c3d00485a5307421a5b2d4898d6259ff59f1c9861295a56a079d9",
-    "third-party/nsh/website/zh-CN/docs/ios.md": "615b26e421eb981ece4f360544732b81e9ca4ace2440b6edf05880f142091953",
+    "third-party/nsh/website/zh-CN/docs/ios.md": "09170b3c662d4c158705a0c95bc3bafbb74432ad58e4a556c576b96dcfe7e70c",
     "third-party/nsh/website/zh-CN/docs/jsx-and-components.md": "7c80e941fdd795698c237edcfd5f600d748a870f4671b05973d1bc0c2279bf97",
     "third-party/nsh/website/zh-CN/docs/layerfs.md": "817b28c2b67d90fa5bc754f7b7dcebf07edcb8328d814e56f394ee91989c8217",
     "third-party/nsh/website/zh-CN/docs/layout.md": "34c9288812aa015497363034a38d338a3138808ea936c21948b1db1fca72733b",
-    "third-party/nsh/website/zh-CN/docs/linux.md": "b92ff6039e94e50758c13feb3c3fbe38dea346f6e644d35b5863a2bfef8c1298",
+    "third-party/nsh/website/zh-CN/docs/linux.md": "04a113952bfbc8666cfaab4a12ce971d09d5699118b169115b0bb78896e4ffd5",
     "third-party/nsh/website/zh-CN/docs/lists.md": "7f70739117e058a511ac1a5b824cd4d109ce407f17b8aacc0f1f3203112ee907",
-    "third-party/nsh/website/zh-CN/docs/macos.md": "fb469d4765f3fc5e448f28655841f670cc292878766958b604e913caaf508def",
+    "third-party/nsh/website/zh-CN/docs/macos.md": "7defc3eca134507e1c50b4593f14a121924913df0f22fa2ad645fa6926e28c1b",
     "third-party/nsh/website/zh-CN/docs/native-components.md": "53687a8c5bde004f6f8be3f15ab2a82b5c063dc4acadc7a69713c9c150512831",
     "third-party/nsh/website/zh-CN/docs/navigation.md": "cf0b0ed2c31d94591a764e54789a8016b248a71d6bbc2e83961c6febd28b7305",
     "third-party/nsh/website/zh-CN/docs/networking.md": "3f0d3eaf3d29565c18c92b227cececba879063672f66aeede14fdb05da1c61bf",
@@ -2587,21 +2609,21 @@
     "third-party/nsh/website/zh-CN/docs/performance.md": "51e47481cf34e1d5b80cb63214a1f13ad89ad2ad902d0ba476f5b52f93a6a7af",
     "third-party/nsh/website/zh-CN/docs/platform-specific.md": "418a89f4880460fd661451290ce6c5d583c6fbee7423978b1d5ac90d098e6412",
     "third-party/nsh/website/zh-CN/docs/platforms.md": "a245c546c662981a85865b1a62dc2677301954470933b676d32fd2e5192a13b8",
-    "third-party/nsh/website/zh-CN/docs/project-structure.md": "1006d1463cf46514347f888024a9a3a492486952fe6d2fe162c0f16c43d74f9a",
+    "third-party/nsh/website/zh-CN/docs/project-structure.md": "a22b70ff32192cd92edc0bced0a192bad4275ea9f686bbe77a11c0634597fe59",
     "third-party/nsh/website/zh-CN/docs/reactivity.md": "2f5ab6ca8af99fe3b6c5aa885e3ea5ee878e612bba4b15cd75de37b2c7f4dbb3",
     "third-party/nsh/website/zh-CN/docs/rust.md": "4d8eebbf32c628977ad3d87b1c227a056a6d7be8cd140ababd3ea6294d70d473",
-    "third-party/nsh/website/zh-CN/docs/sandbox-and-capabilities.md": "9c021c767938186c7f75cfcb08bfd5200829aa755f013491933e17701dbfb741",
+    "third-party/nsh/website/zh-CN/docs/sandbox-and-capabilities.md": "878c315de9764988f997e6c7c8929e2b4b73eac8303362c737065b31cc96fdb3",
     "third-party/nsh/website/zh-CN/docs/scrolling.md": "8e6237c42f3b0d82d87c3c3163661382b00517a6866af57bdd807ddcfe71bf7f",
     "third-party/nsh/website/zh-CN/docs/signed-packages.md": "2e48499114cf8cfee2cfbfb866b46afa8b5dc9c615f2640462295e0fbaf0b47d",
     "third-party/nsh/website/zh-CN/docs/styling.md": "9bea733d0c0389b8d095b41f57ddaa10eb18a702a964708699c63833c530a798",
-    "third-party/nsh/website/zh-CN/docs/tailwind.md": "36506cf5250db89d07f86389cf2bcdf86e15a0107a4a6424e8f0585fd0b59d8b",
-    "third-party/nsh/website/zh-CN/docs/testing-and-trace.md": "1f570db23c1e31467dd358d62d7e692f1b279b0205b839828c9fa789c8aa1dc1",
+    "third-party/nsh/website/zh-CN/docs/tailwind.md": "ddb45309d81505a86bea22c582c5ae6ba17561d871c9f3a546d30286ed0fd00c",
+    "third-party/nsh/website/zh-CN/docs/testing-and-trace.md": "e32c9cb30062fc83bf33d8a680d9137709169fbd689cae429a93b5ee76dac05d",
     "third-party/nsh/website/zh-CN/docs/text.md": "3e0c854979fe43694024b1c80a9a6dc8d2632009fb2ca1468d978b7084e2ff33",
     "third-party/nsh/website/zh-CN/docs/touch-and-gestures.md": "6c52f75dee8de1b1a4db4359e2c155a11131f35bac36f3dfc8b90e758fed2cd1",
     "third-party/nsh/website/zh-CN/docs/tui.md": "759679285a6efb60adc31bddc0b29a712a8375b947f1405cd1f923345f4cf59f",
     "third-party/nsh/website/zh-CN/docs/web-view-and-video.md": "45ab7af5e283a3021de62b39b7ffe9f64414fd41fe5337d9e51c30656f3e1fb1",
-    "third-party/nsh/website/zh-CN/docs/web.md": "f0f47e4b4a0cf26d44807d9e782176f391a7134f1dd5ab192132b0215a9c5cf9",
-    "third-party/nsh/website/zh-CN/docs/windows.md": "6694ce31c742015a5f2d26d746039f5d73081ddcdd556b8c8cbd8ef988531747",
+    "third-party/nsh/website/zh-CN/docs/web.md": "d22060420052d788555373fecb5297f242542e62a388bc696da88cfb9abf1331",
+    "third-party/nsh/website/zh-CN/docs/windows.md": "2bc90fb626da5f2c2d65fc39714b21e9c38fefd32154cd16f81693f804ff34a5",
     "third-party/nsh/website/zh-CN/elements/alert-dialog.md": "ddee942832b7ddb4afbfcaec5af36245af4189b995a65d55d762634176d4213e",
     "third-party/nsh/website/zh-CN/elements/button.md": "322208c0b3b5664f6d5ed83828f77b22c7f7f7a532e84664da7ead9179d144b6",
     "third-party/nsh/website/zh-CN/elements/canvas.md": "69200ec9827d47f515d4f2fc2559cff30371d040712ce11341c1de6ab26b11c7",
@@ -2635,7 +2657,7 @@
     "third-party/nsh/website/zh-CN/elements/view.md": "07ceb854787d0ccda316a08c2f0ad34523f28f85a0b74c4adef29be612d898a2",
     "third-party/nsh/website/zh-CN/elements/web-view.md": "16fc5b2d4e1a2158f7838d96fa8361c8401f14c5ce5f36a474daef0e246d2fd9",
     "third-party/nsh/website/zh-CN/modules/globals.md": "8fe3c160dfea2a833e3af37266399a79db319bd3a8279f1bbd0f7173cc33aeea",
-    "third-party/nsh/website/zh-CN/modules/index.md": "04185943e6282fdc4cf7073f88cc080399934cc0e1636877b8c3422d0b9ff62d",
+    "third-party/nsh/website/zh-CN/modules/index.md": "7648b4fe4cc43384c7080513b144f5dbf9b23d1ffd9f6096f8fdd63eebb6a35e",
     "third-party/nsh/website/zh-CN/modules/nsh-accessibility.md": "53b1949ebe056f6e8d2cde899f6160ce6ac5f78b8e9b404b86c257513391f360",
     "third-party/nsh/website/zh-CN/modules/nsh-android.md": "5732783701f5b8eba5134b9d7f4a87be6930e3cd9a567a5d21063c479f8c9e31",
     "third-party/nsh/website/zh-CN/modules/nsh-animated.md": "0fc4cd367f3b4d87bf228017fbf8f1d941cd439f9583a8d9641738d5d222b2b8",
@@ -2645,9 +2667,10 @@
     "third-party/nsh/website/zh-CN/modules/nsh-desktop.md": "d69cebeb28d6d77643af44d53c01f2d5f135ea096b8381b0b631691fb74e05a9",
     "third-party/nsh/website/zh-CN/modules/nsh-device.md": "2aef14241d7a65e204194fe1663499820a8857b98ad0c8450bac2887beea0d25",
     "third-party/nsh/website/zh-CN/modules/nsh-gesture.md": "016833be47b4af7906a3b30834f18d2a0daa031f344ef7f655197870be3b0934",
+    "third-party/nsh/website/zh-CN/modules/nsh-host.md": "c550fb10e43d254b20b78590bbac0a27a828deaee6259ce72ecc616df0985f08",
     "third-party/nsh/website/zh-CN/modules/nsh-image.md": "ba5af499d466a18bdba503b45d7957c2bcfa64a9454538ab92e271c31cf795be",
     "third-party/nsh/website/zh-CN/modules/nsh-ios.md": "e1220439df4359e9ba96b0457169683d68f92559fc77544fc741768c45a34ed1",
-    "third-party/nsh/website/zh-CN/modules/nsh-keymap.md": "ce0661d9b5d0c1c359d984d03c721cd1d23f9b695c5e4ca01f581914993a94df",
+    "third-party/nsh/website/zh-CN/modules/nsh-keymap.md": "d040b1daaa7beecb21a3f4523639557b078f9ed89654bc2b4c6fa22c84481614",
     "third-party/nsh/website/zh-CN/modules/nsh-list.md": "bca6d00c4c39da107b74accbd135653d0ef18845fa5aa4248262e53bffa7cdb1",
     "third-party/nsh/website/zh-CN/modules/nsh-native.md": "f4938e9e7310c840b6bb5517570d7b23d19cf97d7fdd5dd5827cfd148ddb5c0b",
     "third-party/nsh/website/zh-CN/modules/nsh-storage.md": "3a4b00515785ced4dab774b340a333487f9b7c4ec5bcf64a7ff00b1eab5b7041",
diff --git a/third-party/nsh/AGENTS.md b/third-party/nsh/AGENTS.md
index 82913a4679..f61f34b127 100644
--- a/third-party/nsh/AGENTS.md
+++ b/third-party/nsh/AGENTS.md
@@ -82,7 +82,8 @@ crates/
   nsh-testing/         Headless tests: the driver (`headless/`: an engine without a window, display frames, a model of the views,
                        the steps, stand-in network and storage, fetch on ureq; no QuickJS, for Rust apps' `cargo test`); feature
                        `js`: `nsh test`'s side, the `nsh/test` module test files import (`module.rs`, Rust), each test file on an
-                       engine of its own and each app it opens on a headless one (`run.rs`) or a real host (`driver.rs`, `--target`),
+                       engine of its own and each app it opens on a headless one (`run.rs`; the web page's host module,
+                       nsh.json `web.host`, answers `nsh/host` there: `page_host.rs`) or a real host (`driver.rs`, `--target`),
                        apps' headless runners (`runner.rs`); feature `bundle`: building entries in process (TSX entries of
                        `cargo test`, the CLI's compiler); feature `agent`: a real host's test agent (`agent.rs`, the dev hosts'),
                        which takes the steps with the platform's input
@@ -158,7 +159,8 @@ examples/             Example apps (gallery: a demo index, one module per demo,
                       inputs, a long list, stubbed requests, dark mode, a manual clock); gallery-rs: the same in Rust, the app's crate in `native/`,
                       TypeScript tests of its root (`*.test.tsx`, about a demo each); rust-exports: a TSX app using one of each export of its crate, the reference
                       for exporting components, modules and classes, with Rust and TypeScript tests of the TSX entry; counter,
-                      with a test), embedding samples
+                      with a test; host-echo: an app calling its web page's host (`nsh/host`, nsh.json `web.host`), the host
+                      module and their tests), embedding samples
                       (embed-ios, embed-android; embed-gpui: a GPUI Kit app, its own workspace; `run/` the root's
                       `cargo run -p example-embed-gpui`) and the app they run (embedded: uses a native component the samples
                       register); bench: component benchmarks
diff --git a/third-party/nsh/Cargo.lock b/third-party/nsh/Cargo.lock
index 4677a2e449..a7008f4246 100644
--- a/third-party/nsh/Cargo.lock
+++ b/third-party/nsh/Cargo.lock
@@ -3558,6 +3558,7 @@ dependencies = [
  "nsh-dev",
  "nsh-engine",
  "nsh-js",
+ "quickjs-jit",
  "regex",
  "serde",
  "serde_json",
diff --git a/third-party/nsh/SOURCE.md b/third-party/nsh/SOURCE.md
index f40fcb760f..d40d985abd 100644
--- a/third-party/nsh/SOURCE.md
+++ b/third-party/nsh/SOURCE.md
@@ -26,7 +26,7 @@ at `de176695240d550f2b303643b8019146c7989e7f`. The upstream project is
 
 `upstream.json` keeps that initial `repository` and `revision`. Its
 `upstream_revision` records the latest integrated `longbridge/nsh` main revision:
-`44278a653d73b7f8626e964f7895b8a4c4a964dc`. The first upstream synchronization
+`7ed244990097dbbec6b177e26dbc357cb0dba654`. The first upstream synchronization
 starts from `6bb0b7908043132067456e4a21dd57c90a0bdce5`, already included in the
 initial fork import.
 
@@ -43,6 +43,10 @@ from the upstream layout tree's reusable slab index. This prevents a delayed
 pointer observation from activating a newly allocated hint or anchor; the
 existing hint-lifetime regression covers release and reuse. Keep this adaptation
 until an upstream owner-identity mechanism provides the same guarantee.
+The list integration also applies published source counts and splices before
+issuing more paced bindings. This retains the fork's source-publication fence
+while using upstream visible-first scheduling; regressions cover source changes
+inside both visible and overscan row constructors.
 
 Maka's workspace uses these local crates, including NSH's offscreen renderer and
 image codec; changing them here changes the code built and tested with Maka.
diff --git a/third-party/nsh/crates/nsh-base/src/lib.rs b/third-party/nsh/crates/nsh-base/src/lib.rs
index bf579c9c7a..e2bc2a7b0b 100644
--- a/third-party/nsh/crates/nsh-base/src/lib.rs
+++ b/third-party/nsh/crates/nsh-base/src/lib.rs
@@ -85,7 +85,7 @@ pub use crate::{
         TextViewEvent, TextViewLinkPressEvent, TextViewMath, TextViewPart, TextViewParts,
         TextViewProps, TextViewStyles, text_view,
     },
-    theme::{DEFAULT_THEME, Theme, ThemeError, theme, tokens_of},
+    theme::{DEFAULT_THEME, Theme, ThemeError, theme, theme_overrides, tokens_of},
     tooltip::{Tooltip, TooltipElement, TooltipEvent, TooltipProps, tooltip},
 };
 
diff --git a/third-party/nsh/crates/nsh-base/src/theme.rs b/third-party/nsh/crates/nsh-base/src/theme.rs
index 3a427200e9..50d7acbb5f 100644
--- a/third-party/nsh/crates/nsh-base/src/theme.rs
+++ b/third-party/nsh/crates/nsh-base/src/theme.rs
@@ -322,12 +322,31 @@ fn font_family(value: &str) -> Option<&str> {
     (!generic).then_some(first)
 }
 
+/// The tokens theme file `json` sets, and only those: an embedding app's
+/// overrides of the app's theme (the platforms' `AppView`s, the iOS and
+/// Android SDKs), which win over the app's tokens of the same names and
+/// leave its others as they are (`Core::set_host_tokens`). The file is the
+/// one [`Theme::from_json`] reads, shadcn's `cssVars`; a color it names
+/// (`var(--ring)`) and does not set is the default theme's.
+pub fn theme_overrides(json: &str) -> Result<Tokens, ThemeError> {
+    let mut tokens = Tokens::default();
+    apply_onto(&mut tokens, &DEFAULT_TOKENS, json)?;
+    Ok(tokens)
+}
+
 /// Applies theme file `json` (shadcn's `cssVars`) over `tokens`.
 fn apply(tokens: &mut Tokens, json: &str) -> Result<(), ThemeError> {
+    let base = tokens.clone();
+    apply_onto(tokens, &base, json)
+}
+
+/// Sets the tokens of theme file `json` in `tokens`; a color it names and
+/// does not set is `base`'s.
+fn apply_onto(tokens: &mut Tokens, base: &Tokens, json: &str) -> Result<(), ThemeError> {
     let value: JsonValue =
         serde_json::from_str(json).map_err(|e| ThemeError(format!("not JSON: {e}")))?;
     if let Some(vars) = value.get("cssVars") {
-        return apply_css_vars(tokens, vars);
+        return apply_css_vars(tokens, base, vars);
     }
     Err(ThemeError(
         "expected shadcn's `{ \"cssVars\": { ... } }`".into(),
@@ -341,7 +360,7 @@ fn apply(tokens: &mut Tokens, json: &str) -> Result<(), ThemeError> {
 /// file's or the one under it. Values of no kind a token has (shadows,
 /// letter spacing) are left out, as they are of no Tailwind utility NSH
 /// draws with a token.
-fn apply_css_vars(tokens: &mut Tokens, vars: &JsonValue) -> Result<(), ThemeError> {
+fn apply_css_vars(tokens: &mut Tokens, base: &Tokens, vars: &JsonValue) -> Result<(), ThemeError> {
     let section = |name: &str| -> Result<FxHashMap<String, String>, ThemeError> {
         let Some(v) = vars.get(name) else {
             return Ok(FxHashMap::default());
@@ -374,7 +393,6 @@ fn apply_css_vars(tokens: &mut Tokens, vars: &JsonValue) -> Result<(), ThemeErro
     let mut dark_vars = light_vars.clone();
     dark_vars.extend(dark.iter().map(|(k, v)| (k.clone(), v.clone())));
 
-    let base = tokens.clone();
     let mut names: Vec<&String> = light_vars.keys().chain(dark.keys()).collect();
     names.sort();
     names.dedup();
@@ -388,7 +406,7 @@ fn apply_css_vars(tokens: &mut Tokens, vars: &JsonValue) -> Result<(), ThemeErro
             continue;
         }
         let resolve = |vars: &FxHashMap<String, String>, dark: bool| {
-            resolve_color(name, vars, &base, dark, &mut FxHashSet::default())
+            resolve_color(name, vars, base, dark, &mut FxHashSet::default())
         };
         match (resolve(&light_vars, false)?, resolve(&dark_vars, true)?) {
             (Some(l), Some(d)) => tokens.set_color(name, AdaptiveColor { light: l, dark: d }),
@@ -579,4 +597,31 @@ mod tests {
         // The default's, in each appearance.
         assert_eq!(theme.color("line").unwrap(), Theme::default().border);
     }
+
+    #[test]
+    fn overrides_are_the_tokens_the_file_sets() {
+        let tokens = theme_overrides(
+            r##"{ "cssVars": {
+                "theme": { "radius": "0.75rem" },
+                "light": { "primary": "#e11d48", "ring": "var(--primary)", "focus": "var(--border)" },
+                "dark": { "primary": "#fb7185" }
+            } }"##,
+        )
+        .unwrap();
+        let primary = tokens.color("primary").unwrap();
+        assert_eq!(
+            (hex(primary.light), hex(primary.dark)),
+            (0xe11d48, 0xfb7185)
+        );
+        // The file's own, per appearance.
+        assert_eq!(tokens.color("ring").unwrap(), primary);
+        // One it does not set: the default's.
+        assert_eq!(tokens.color("focus").unwrap(), Theme::default().border);
+        assert_eq!(tokens.number("radius"), Some(12.0));
+        // Nothing else: the app's tokens stay.
+        assert_eq!(tokens.colors().count(), 3);
+        assert_eq!(tokens.color("background"), None);
+        assert!(theme_overrides("{}").is_err());
+        assert!(theme_overrides("not json").is_err());
+    }
 }
diff --git a/third-party/nsh/crates/nsh-bundler/src/bundle.rs b/third-party/nsh/crates/nsh-bundler/src/bundle.rs
index 469d5e72f1..8c0f92bd35 100644
--- a/third-party/nsh/crates/nsh-bundler/src/bundle.rs
+++ b/third-party/nsh/crates/nsh-bundler/src/bundle.rs
@@ -439,6 +439,9 @@ pub async fn bundle_async(options: &BundleOptions) -> Result<Bundle, BundleError
     assets.sort();
     assets.dedup();
     let natives = std::mem::take(&mut *natives.lock().expect("natives lock"));
+    if let Some(warning) = ungranted_host(&natives, capabilities.as_ref()) {
+        log::warn!(target: "nsh::build", "{warning}");
+    }
     Ok(Bundle {
         code,
         source_map,
@@ -450,6 +453,22 @@ pub async fn bundle_async(options: &BundleOptions) -> Result<Bundle, BundleError
     })
 }
 
+/// The warning for an app that imports `nsh/host` and grants itself none
+/// of the host's functions (`capabilities.host`): each of its calls is
+/// refused, as the host's functions are never granted by default.
+fn ungranted_host<'a>(
+    natives: impl IntoIterator<Item = &'a String>,
+    capabilities: Option<&nsh_core::access::Capabilities>,
+) -> Option<String> {
+    let imports = natives.into_iter().any(|n| n == "nsh/host");
+    let granted = capabilities.is_some_and(|c| !c.host.is_empty());
+    (imports && !granted).then(|| {
+        "the app imports nsh/host and nsh.json declares no `capabilities.host`: every `host.call` is refused \
+         (list the host's functions it calls, prefixes ending in `*`, or `true`)"
+            .to_owned()
+    })
+}
+
 /// The chunks of `imports` (the files a chunk imports statically; native
 /// modules are not files), the entry chunk left out.
 pub(crate) fn chunk_imports<'a>(imports: impl Iterator<Item = &'a str>, own: &str) -> Vec<String> {
@@ -709,6 +728,9 @@ pub async fn bundle_hot_async(
     assets.sort();
     assets.dedup();
     let natives = std::mem::take(&mut *natives.lock().expect("natives lock"));
+    if let Some(warning) = ungranted_host(&natives, capabilities.as_ref()) {
+        log::warn!(target: "nsh::build", "{warning}");
+    }
     Ok(HotBundle {
         modules,
         natives: natives.into_iter().collect(),
@@ -724,6 +746,24 @@ pub async fn bundle_hot_async(
 
 #[cfg(test)]
 mod tests {
+    #[test]
+    fn apps_calling_the_host_without_a_grant_are_warned() {
+        use nsh_core::access::Capabilities;
+        let natives = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
+        let caps = |json| Capabilities::from_json(&serde_json::from_str(json).unwrap()).unwrap();
+        let host = natives(&["nsh", "nsh/host"]);
+        assert!(
+            super::ungranted_host(&host, None)
+                .unwrap()
+                .contains("capabilities.host")
+        );
+        assert!(super::ungranted_host(&host, Some(&caps("{}"))).is_some());
+        assert!(super::ungranted_host(&host, Some(&caps(r#"{"host": false}"#))).is_some());
+        assert!(super::ungranted_host(&host, Some(&caps(r#"{"host": ["a"]}"#))).is_none());
+        assert!(super::ungranted_host(&host, Some(&caps(r#"{"host": true}"#))).is_none());
+        assert!(super::ungranted_host(&natives(&["nsh"]), None).is_none());
+    }
+
     use super::*;
 
     /// An app whose screens are imported on demand: the first imports what
diff --git a/third-party/nsh/crates/nsh-cli/src/dev.rs b/third-party/nsh/crates/nsh-cli/src/dev.rs
index d9c5da64ee..783ccb67ac 100644
--- a/third-party/nsh/crates/nsh-cli/src/dev.rs
+++ b/third-party/nsh/crates/nsh-cli/src/dev.rs
@@ -66,7 +66,8 @@ impl DevSession {
         // --target web` builds it in a checkout).
         let rust_root = crate::rust_app::is_root(&entry);
         if let Ok(Some(runtime)) = crate::runtime::web(crate::runtime::WebKind::Dev, false) {
-            let (page, files) = crate::web::dev_host(&runtime, rust_root)?;
+            let (page, files) =
+                crate::web::with_page_host(crate::web::dev_host(&runtime, rust_root)?, &entry)?;
             server.serve_web(page, files);
         }
         let session = DevSession {
diff --git a/third-party/nsh/crates/nsh-cli/src/main.rs b/third-party/nsh/crates/nsh-cli/src/main.rs
index 5a605beac3..1d6c1393b7 100644
--- a/third-party/nsh/crates/nsh-cli/src/main.rs
+++ b/third-party/nsh/crates/nsh-cli/src/main.rs
@@ -164,6 +164,11 @@ enum Command {
         /// apps in (default: the one installed on this machine).
         #[arg(long, value_name = "PATH")]
         browser: Option<PathBuf>,
+        /// The web page's host module (default: `nsh.json` `web.host`), which
+        /// exports `createHost(player)`: in headless runs it answers the
+        /// `nsh/host` calls of each app the tests open and sends its events.
+        #[arg(long, value_name = "JS")]
+        web_host: Option<PathBuf>,
     },
     /// Type check an app with TypeScript's native compiler (TypeScript 7,
     /// downloaded once from the npm registry; no Node needed).
@@ -589,6 +594,7 @@ fn dispatch(command: Command) -> anyhow::Result<()> {
             junit,
             target,
             browser,
+            web_host,
         } => test::test(
             &path,
             &entry,
@@ -597,6 +603,7 @@ fn dispatch(command: Command) -> anyhow::Result<()> {
             junit.as_deref(),
             target,
             browser.as_deref(),
+            web_host,
         ),
         Command::Dev {
             entry,
diff --git a/third-party/nsh/crates/nsh-cli/src/package.rs b/third-party/nsh/crates/nsh-cli/src/package.rs
index d17ad85028..b1dc1e0722 100644
--- a/third-party/nsh/crates/nsh-cli/src/package.rs
+++ b/third-party/nsh/crates/nsh-cli/src/package.rs
@@ -109,6 +109,25 @@ pub(crate) struct AppConfig {
     /// A Rust app's root component in its crate (`rust_app::RootConfig`).
     #[serde(default, rename = "root")]
     _root: Option<serde::de::IgnoredAny>,
+    /// What the web's packages add to the page (`web.rs`).
+    #[serde(default)]
+    pub(crate) web: WebConfig,
+}
+
+/// `nsh.json` `"web"`.
+#[derive(Debug, Default, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub(crate) struct WebConfig {
+    /// The page's host: a JS module, relative to `nsh.json`, whose
+    /// `createHost(player)` returns what `nsh/host` calls reach
+    /// (`createPlayer`'s `host`).
+    pub(crate) host: Option<PathBuf>,
+    /// The hosts the page's host module reaches (patterns, as
+    /// `capabilities.network.hosts`): the page's `connect-src` allows them
+    /// too, while the app's own requests stay checked against its
+    /// `capabilities`.
+    #[serde(default)]
+    pub(crate) connect: Vec<String>,
 }
 
 /// Options of `nsh package`.
@@ -139,9 +158,28 @@ pub(crate) fn read_config(entry: &Path) -> anyhow::Result<AppConfig> {
     let mut config: AppConfig =
         serde_json::from_str(&text).with_context(|| format!("invalid {}", path.display()))?;
     config.icon = config.icon.map(|icon| dir.join(icon));
+    config.web.host = config.web.host.map(|host| dir.join(host));
     Ok(config)
 }
 
+/// `nsh.json` `web.host` (`read_config` made it absolute), canonical: the
+/// file must be inside the app's directory, symbolic links resolved, as the
+/// page ships it as it is.
+pub(crate) fn web_host_module(entry: &Path, host: &Path) -> anyhow::Result<PathBuf> {
+    let dir = dunce::canonicalize(entry.parent().unwrap_or(Path::new(".")))
+        .with_context(|| format!("the app's directory of {}", entry.display()))?;
+    let module = dunce::canonicalize(host)
+        .with_context(|| format!("nsh.json web.host {}", host.display()))?;
+    if !module.starts_with(&dir) {
+        bail!(
+            "nsh.json web.host {} must be inside the app's directory {}",
+            host.display(),
+            dir.display()
+        );
+    }
+    Ok(module)
+}
+
 /// The app's display name and id: from the config, else the entry's
 /// directory name and `com.example.<name>`.
 pub(crate) fn name_and_id(entry: &Path, config: &AppConfig) -> (String, String) {
@@ -996,6 +1034,44 @@ mod tests {
         );
     }
 
+    #[test]
+    fn the_web_host_module_stays_in_the_app_directory() {
+        let root = std::env::temp_dir().join(format!("nsh-web-host-{}", std::process::id()));
+        let app = root.join("app");
+        fs::create_dir_all(app.join("web")).unwrap();
+        fs::write(app.join("web/host.js"), "").unwrap();
+        fs::write(root.join("outside.js"), "").unwrap();
+        let entry = app.join("App.tsx");
+        let config = |host: &str| {
+            fs::write(
+                app.join("nsh.json"),
+                serde_json::json!({ "web": { "host": host } }).to_string(),
+            )
+            .unwrap();
+            read_config(&entry).unwrap().web.host.unwrap()
+        };
+        let inside = web_host_module(&entry, &config("web/host.js")).unwrap();
+        assert_eq!(
+            inside,
+            dunce::canonicalize(app.join("web/host.js")).unwrap()
+        );
+        assert!(web_host_module(&entry, &config("./web/../web/host.js")).is_ok());
+        let outside = root.join("outside.js");
+        let refused = [config("../outside.js"), config(outside.to_str().unwrap())];
+        #[cfg(unix)]
+        let refused = {
+            std::os::unix::fs::symlink(&outside, app.join("link.js")).unwrap();
+            let mut refused = refused.to_vec();
+            refused.push(config("link.js"));
+            refused
+        };
+        for host in refused {
+            let error = web_host_module(&entry, &host).unwrap_err().to_string();
+            assert!(error.contains("inside the app's directory"), "{error}");
+        }
+        fs::remove_dir_all(&root).unwrap();
+    }
+
     /// App icons are drawn at 1024 pixels, which `sips` alone cannot
     /// convert.
     #[cfg(target_os = "macos")]
diff --git a/third-party/nsh/crates/nsh-cli/src/test.rs b/third-party/nsh/crates/nsh-cli/src/test.rs
index 6268b9f08e..29c164023e 100644
--- a/third-party/nsh/crates/nsh-cli/src/test.rs
+++ b/third-party/nsh/crates/nsh-cli/src/test.rs
@@ -39,6 +39,10 @@ const IDLE_MS: u32 = 1200;
 /// Runs the tests `path` names, of the app whose entry is `entry`: a test
 /// file, the test files in a directory (an app's), or an app's entry with
 /// the steps standard input gives.
+#[allow(
+    clippy::too_many_arguments,
+    reason = "the flags of `nsh test`, one each"
+)]
 pub(crate) fn test(
     path: &Path,
     entry: &Path,
@@ -47,6 +51,7 @@ pub(crate) fn test(
     junit: Option<&Path>,
     target: Option<crate::run::Target>,
     browser: Option<&Path>,
+    web_host: Option<PathBuf>,
 ) -> anyhow::Result<()> {
     let app_dir = entry.parent().unwrap_or(Path::new(".")).to_path_buf();
     let work_dir = app_dir.join(".nsh/test");
@@ -79,6 +84,25 @@ pub(crate) fn test(
     if browser.is_some() && target.is_none() {
         bail!("--browser goes with --target web");
     }
+    if web_host.is_some() && target.is_some() {
+        bail!(
+            "--web-host goes with headless runs: --target web takes nsh.json's web.host, the other targets have no page host"
+        );
+    }
+    // The page's host of headless runs: `--web-host`, else nsh.json's.
+    // Absolute: an app's headless runner opens it too.
+    let page_host = match web_host {
+        Some(module) => Some(
+            dunce::canonicalize(&module)
+                .with_context(|| format!("the web host module {}", ui::path(&module)))?,
+        ),
+        None if target.is_none() => crate::package::read_config(entry)?
+            .web
+            .host
+            .map(|host| crate::package::web_host_module(entry, &host))
+            .transpose()?,
+        None => None,
+    };
     let host = target
         .map(|target| crate::test_host::starter(target, browser, &work_dir))
         .transpose()?;
@@ -94,6 +118,7 @@ pub(crate) fn test(
                 filter,
                 trace,
                 verbose: ui::verbose(),
+                page_host,
             };
             in_runner(&app, &request, &shown, &mut tally)?;
         }
@@ -105,6 +130,7 @@ pub(crate) fn test(
                 filter,
                 trace: trace.then(|| Arc::new(Mutex::new(Box::new(std::io::stdout()) as _))),
                 host,
+                page_host,
             };
             for file in &files {
                 let shown = shown(file);
diff --git a/third-party/nsh/crates/nsh-cli/src/test_web.rs b/third-party/nsh/crates/nsh-cli/src/test_web.rs
index f5502eb8f7..8efd0c6861 100644
--- a/third-party/nsh/crates/nsh-cli/src/test_web.rs
+++ b/third-party/nsh/crates/nsh-cli/src/test_web.rs
@@ -87,7 +87,9 @@ fn start(
     options: &HostOptions,
 ) -> Result<WebHost, String> {
     let server = Arc::new(crate::test_host::serve(entry, options)?);
-    server.serve_web(host.0.clone(), host.1.clone());
+    let (page, files) =
+        crate::web::with_page_host(host.clone(), entry).map_err(|e| format!("{e:#}"))?;
+    server.serve_web(page, files);
     let browser = {
         let mut running = running.lock().unwrap_or_else(|e| e.into_inner());
         match &*running {
diff --git a/third-party/nsh/crates/nsh-cli/src/web.rs b/third-party/nsh/crates/nsh-cli/src/web.rs
index 3de70ca741..263753207d 100644
--- a/third-party/nsh/crates/nsh-cli/src/web.rs
+++ b/third-party/nsh/crates/nsh-cli/src/web.rs
@@ -13,6 +13,7 @@
 //! snippets.<digest>/<crate>-<hash>/ the JS snippets the glue imports, under their own paths
 //! app.<digest>.nshc                the app's bundle, compiled to QuickJS bytecode
 //! harden.<digest>.nshc             the sandbox's hardening, compiled likewise
+//! host.<digest>.js                 the page's host (`nsh.json` `web.host`), when the app has one
 //! chunks/<name>.<digest>.nshc      what `import()` loads, fetched when first imported
 //! assets/<path>.<digest>.<ext>     `asset:/<path>`, loaded from its URL when used
 //! icons/<size>.<digest>.png        favicon, apple-touch-icon, the manifest's
@@ -146,6 +147,16 @@ pub(crate) fn package(
         }
         assets.insert(asset.path.clone(), url.into());
     }
+    // The page imports it as it is: one module, its imports absolute URLs.
+    let host = match &config.web.host {
+        Some(path) => {
+            let path = crate::package::web_host_module(options.entry, path)?;
+            let bytes = fs::read(&path)
+                .with_context(|| format!("read nsh.json web.host {}", path.display()))?;
+            Some(site.add("host.js", &bytes)?)
+        }
+        None => None,
+    };
     let icons = match &config.icon {
         Some(icon) => Icons::write(&site, icon)?,
         None => Icons::default(),
@@ -167,6 +178,7 @@ pub(crate) fn package(
         "fonts": fonts,
         "chunkFonts": chunk_fonts,
         "chunks": chunks,
+        "host": host,
     });
     if let Some(glue) = &glue {
         app["glue"] = glue.as_str().into();
@@ -185,7 +197,11 @@ pub(crate) fn package(
             .map(|path| assets[path].as_str().unwrap_or_default().to_owned())
             .collect::<Vec<_>>(),
         app: &app,
-        connect: connect_src(built.capabilities.as_ref()).as_deref(),
+        connect: connect_src(
+            built.capabilities.as_ref(),
+            &page_connect(&config.web.connect)?,
+        )
+        .as_deref(),
     });
     fs::write(dir.join("index.html"), html)?;
     step.done(format!(
@@ -275,7 +291,8 @@ pub(crate) type Files = Vec<(String, Vec<u8>)>;
 /// one when its Rust code changes (the session starts again), and a page
 /// that reconnects to a server with another one loads again.
 /// `rust`: a Rust app's runtime (`nsh.json` `"root"`), whose page runs its
-/// root rather than the bundles the server sends.
+/// root rather than the bundles the server sends. The page still names its
+/// host module as `{{host}}`: [`with_page_host`] fills it in for an app.
 pub(crate) fn dev_host(runtime: &WebRuntime, rust: bool) -> anyhow::Result<(String, Files)> {
     let read = |path: &Path| fs::read(path).with_context(|| format!("read {}", path.display()));
     let ui_sets = serde_json::to_string(&ui_sets())?;
@@ -326,6 +343,34 @@ pub(crate) fn dev_host(runtime: &WebRuntime, rust: bool) -> anyhow::Result<(Stri
     Ok((page, files))
 }
 
+/// The dev host's page and files ([`dev_host`]) for the app at `entry`:
+/// with its page's host module (`nsh.json` `web.host`) as `nsh-host.js`,
+/// read now (a change takes another session), or none.
+pub(crate) fn with_page_host(
+    (page, mut files): (String, Files),
+    entry: &Path,
+) -> anyhow::Result<(String, Files)> {
+    let module = crate::package::read_config(entry)?
+        .web
+        .host
+        .map(|host| crate::package::web_host_module(entry, &host))
+        .transpose()?;
+    let host = match module {
+        Some(path) => {
+            let bytes = fs::read(&path)
+                .with_context(|| format!("read nsh.json web.host {}", path.display()))?;
+            files.retain(|(name, _)| name != PAGE_HOST);
+            files.push((PAGE_HOST.to_owned(), bytes));
+            format!("\"./{PAGE_HOST}\"")
+        }
+        None => "null".to_owned(),
+    };
+    Ok((page.replace("{{host}}", &host), files))
+}
+
+/// Where the dev host serves the page's host module ([`with_page_host`]).
+const PAGE_HOST: &str = "nsh-host.js";
+
 /// The app's appearance (`nsh.json` `appearance`): the page's background
 /// and theme color before the app draws.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -520,14 +565,33 @@ fn manifest(name: &str, id: &str, appearance: Appearance, icons: &Icons) -> Stri
     serde_json::to_string_pretty(&manifest).unwrap_or_default()
 }
 
+/// `nsh.json` `web.connect`, checked as `capabilities.network.hosts` are:
+/// patterns of letters, digits, `.`, `-`, `:` and brackets (nothing that
+/// could end a CSP source).
+fn page_connect(hosts: &[String]) -> anyhow::Result<Vec<nsh_core::access::HostPattern>> {
+    hosts
+        .iter()
+        .map(|host| {
+            nsh_core::access::HostPattern::new(host)
+                .map_err(|e| anyhow::anyhow!("nsh.json web.connect: {e}"))
+        })
+        .collect()
+}
+
 /// The page's `connect-src` when the app restricts the hosts it reaches
 /// (`capabilities.network`): the browser then refuses every request, and
 /// every redirect hop, to another host, which nsh cannot see (the browser
 /// follows redirects before telling). `'self'` stays: the page loads the
 /// runtime, the code and the fonts with `fetch`, and the app's relative
 /// URLs reach its own origin (nsh still checks those against the
-/// declaration). `None`: no declaration, or one that allows any host.
-fn connect_src(capabilities: Option<&nsh_core::access::Capabilities>) -> Option<String> {
+/// declaration). `page`: the hosts the page's host module reaches
+/// (`web.connect`), allowed to the page alone: nsh checks the app's
+/// requests against its declaration before the browser sees them. `None`:
+/// no declaration, or one (or `page`) that allows any host.
+fn connect_src(
+    capabilities: Option<&nsh_core::access::Capabilities>,
+    page: &[nsh_core::access::HostPattern],
+) -> Option<String> {
     let network = &capabilities?.network;
     let mut sources = vec!["'self'".to_owned()];
     let mut add = |scheme: &str, host: &str, port: Option<u16>| {
@@ -548,7 +612,7 @@ fn connect_src(capabilities: Option<&nsh_core::access::Capabilities>) -> Option<
             sources.push(source);
         }
     };
-    for host in &network.hosts {
+    for host in network.hosts.iter().chain(page) {
         let host = host.as_str();
         if host == "*" {
             return None;
@@ -730,6 +794,37 @@ mod tests {
         assert!(hashed_name(".env", b"x").starts_with(".env."));
     }
 
+    #[test]
+    fn the_dev_page_runs_the_apps_page_host() {
+        let dir = std::env::temp_dir().join(format!("nsh-web-dev-host-{}", std::process::id()));
+        fs::create_dir_all(&dir).unwrap();
+        let entry = dir.join("App.tsx");
+        let base = || ("const HOST = {{host}};".to_owned(), Files::new());
+        // No nsh.json, or one without `web.host`: no host.
+        let (page, files) = with_page_host(base(), &entry).unwrap();
+        assert_eq!(page, "const HOST = null;");
+        assert!(files.is_empty());
+        fs::write(dir.join("host.js"), "export function createHost() {}").unwrap();
+        fs::write(dir.join("nsh.json"), r#"{ "web": { "host": "host.js" } }"#).unwrap();
+        let (page, files) = with_page_host(base(), &entry).unwrap();
+        assert_eq!(page, "const HOST = \"./nsh-host.js\";");
+        assert_eq!(
+            files,
+            [(
+                "nsh-host.js".to_owned(),
+                b"export function createHost() {}".to_vec()
+            )]
+        );
+        // Outside the app's directory: refused, as `nsh package` does.
+        fs::write(
+            dir.join("nsh.json"),
+            r#"{ "web": { "host": "../host.js" } }"#,
+        )
+        .unwrap();
+        assert!(with_page_host(base(), &entry).is_err());
+        fs::remove_dir_all(&dir).unwrap();
+    }
+
     #[test]
     fn the_dev_page_knows_its_runtime() {
         let dir = std::env::temp_dir().join(format!("nsh-web-dev-{}", std::process::id()));
@@ -752,7 +847,8 @@ mod tests {
             page.contains(&format!("const RUNTIME = \"{first}\";")),
             "{page}"
         );
-        assert!(!page.contains("{{"), "{page}");
+        // The page's host is the app's (`with_page_host`).
+        assert!(!page.replace("{{host}}", "").contains("{{"), "{page}");
         assert!(page.contains("const UI_SETS = [\"nsh/base\"];"));
         fs::write(&runtime.wasm, "two").unwrap();
         assert_ne!(version(&dev_host(&runtime, false).unwrap().1), first);
@@ -974,9 +1070,9 @@ mod tests {
     fn requests_are_limited_to_the_declared_hosts() {
         let caps =
             |json: serde_json::Value| nsh_core::access::Capabilities::from_json(&json).unwrap();
-        assert_eq!(connect_src(None), None);
+        assert_eq!(connect_src(None, &[]), None);
         assert_eq!(
-            connect_src(Some(&caps(serde_json::json!({ "clipboard": true })))).as_deref(),
+            connect_src(Some(&caps(serde_json::json!({ "clipboard": true }))), &[]).as_deref(),
             Some("'self'")
         );
         let declared = caps(serde_json::json!({ "network": {
@@ -984,7 +1080,7 @@ mod tests {
             "http": [{ "host": "api.example.com", "methods": ["GET"] }],
         } }));
         assert_eq!(
-            connect_src(Some(&declared)).as_deref(),
+            connect_src(Some(&declared), &[]).as_deref(),
             Some(
                 "'self' http://api.example.com:* https://api.example.com:* ws://api.example.com:* wss://api.example.com:* http://*.cdn.example.com:* https://*.cdn.example.com:* ws://*.cdn.example.com:* wss://*.cdn.example.com:* http://localhost:8080 https://localhost:8080 ws://localhost:8080 wss://localhost:8080"
             )
@@ -995,7 +1091,7 @@ mod tests {
             { "host": "legacy.example.com", "methods": ["GET"], "schemes": ["ws"], "port": 9000 }
         ]} }));
         assert_eq!(
-            connect_src(Some(&constrained)).as_deref(),
+            connect_src(Some(&constrained), &[]).as_deref(),
             Some(
                 "'self' https://secure.example.com:8443 wss://secure.example.com:8443 https://upload.example.com:9443 ws://legacy.example.com:9000"
             )
@@ -1004,7 +1100,7 @@ mod tests {
             { "host": "*", "methods": ["GET"], "schemes": ["https"], "port": 443 }
         ]} }));
         assert_eq!(
-            connect_src(Some(&wildcard)).as_deref(),
+            connect_src(Some(&wildcard), &[]).as_deref(),
             Some("'self' https://*:443 wss://*:443")
         );
         let ipv6 = caps(serde_json::json!({ "network": {"http": [
@@ -1012,11 +1108,30 @@ mod tests {
             { "host": "[::2]", "methods": ["GET"], "schemes": ["https"] }
         ]} }));
         assert_eq!(
-            connect_src(Some(&ipv6)).as_deref(),
+            connect_src(Some(&ipv6), &[]).as_deref(),
             Some("'self' http://[::1]:9877 ws://[::1]:9877 https://[::2]:* wss://[::2]:*")
         );
         let any = caps(serde_json::json!({ "network": { "hosts": ["*"] } }));
-        assert_eq!(connect_src(Some(&any)), None);
+        assert_eq!(connect_src(Some(&any), &[]), None);
+        // The page's host module's hosts (`web.connect`) join the app's; any
+        // host there lifts the restriction too; none without a declaration.
+        let page = page_connect(&["auth.example.com".into()]).unwrap();
+        assert_eq!(
+            connect_src(Some(&caps(serde_json::json!({}))), &page).as_deref(),
+            Some(
+                "'self' http://auth.example.com:* https://auth.example.com:* ws://auth.example.com:* wss://auth.example.com:*"
+            )
+        );
+        assert_eq!(connect_src(None, &page), None);
+        let anywhere = page_connect(&["*".into()]).unwrap();
+        assert_eq!(
+            connect_src(Some(&caps(serde_json::json!({}))), &anywhere),
+            None
+        );
+        // Nothing that could end a CSP source.
+        for bad in ["a.com; script-src *", "'unsafe-inline'", "a b", ""] {
+            assert!(page_connect(&[bad.into()]).is_err(), "{bad}");
+        }
     }
 
     #[test]
diff --git a/third-party/nsh/crates/nsh-cli/src/web/dev.html b/third-party/nsh/crates/nsh-cli/src/web/dev.html
index d02fa1c3dc..78a540aabf 100644
--- a/third-party/nsh/crates/nsh-cli/src/web/dev.html
+++ b/third-party/nsh/crates/nsh-cli/src/web/dev.html
@@ -42,6 +42,9 @@
   const RUST = {{rust}};
   // The runtime's wasm-bindgen glue (an app's crates that call JS through it).
   const GLUE = {{glue}};
+  // The page's host (`nsh.json` `web.host`): its `createHost(player)` runs
+  // once the player exists, before the first bundle, as on a packaged page.
+  const HOST = {{host}};
   const status = document.getElementById("status");
   const problem = document.getElementById("problem");
   const decoder = new TextDecoder();
@@ -88,14 +91,25 @@
     return data;
   }
 
+  let host;
   const player = await createPlayer(document.getElementById("app"), {
     wasm: "./nsh.wasm",
     glue: GLUE ?? undefined,
+    host: HOST ? { call: (name, args) => host.call(name, args) } : undefined,
     assetBase: "/assets/",
     readAsset,
     onLog: (level, message) => send({ type: "log", level: levels[level] ?? "info", target: "js", message }),
   });
   window.player = player;
+  if (HOST) {
+    try {
+      host = await (await import(HOST)).createHost(player);
+      if (typeof host?.call !== "function") throw new Error("createHost(player) returned no call(name, args)");
+    } catch (err) {
+      showProblem("nsh.json web.host", String(err?.stack ?? err));
+      host = { call: () => Promise.reject(new Error("nsh.json web.host did not load")) };
+    }
+  }
 
   function device() {
     const ua = navigator.userAgent;
diff --git a/third-party/nsh/crates/nsh-cli/src/web/index.html b/third-party/nsh/crates/nsh-cli/src/web/index.html
index 6c865152b2..82e03140fe 100644
--- a/third-party/nsh/crates/nsh-cli/src/web/index.html
+++ b/third-party/nsh/crates/nsh-cli/src/web/index.html
@@ -34,7 +34,9 @@
   // code (bytecode, with the hardening it runs first) and its fonts in
   // parallel, then runs the app as a release; a chunk's own fonts come
   // with it. A Rust app (`app.rust`) has no code: its runtime mounts its
-  // root.
+  // root. The page's host (`app.host`, nsh.json `web.host`) answers the
+  // app's `nsh/host` calls: its `createHost(player)` runs once the player
+  // exists, before the app starts, so no call comes earlier.
   import { createPlayer } from "./{{runtime}}";
 
   const app = {{app}};
@@ -77,10 +79,12 @@
   };
 
   try {
-    const [loaded, code, harden] = await Promise.all([
+    let host;
+    const [loaded, code, harden, hostModule] = await Promise.all([
       createPlayer(document.getElementById("app"), {
         wasm: take(app.wasm),
         glue: app.glue,
+        host: app.host ? { call: (name, args) => host.call(name, args) } : undefined,
         assets: app.assets,
         readAsset: (uri) => fonts.get(path(uri)) ?? null,
         prepareChunk: (id) => Promise.all((app.chunkFonts[id] ?? []).map(loadFont)),
@@ -92,9 +96,14 @@
       }),
       app.rust ? null : get(app.code).then((r) => r.arrayBuffer()),
       app.rust ? null : get(app.harden).then((r) => r.arrayBuffer()),
+      app.host ? import(`./${app.host}`) : null,
       ...app.fonts.map(loadFont),
     ]);
     player = loaded;
+    if (hostModule) {
+      host = await hostModule.createHost(player);
+      if (typeof host?.call !== "function") throw new Error("nsh.json web.host: createHost(player) returned no call(name, args)");
+    }
     status.hidden = true;
     if (app.rust) player.runRust();
     else player.run(code, { release: true, harden, capabilities: app.capabilities ?? "", appId: app.id ?? "", chunks: app.chunks });
diff --git a/third-party/nsh/crates/nsh-core/src/access.rs b/third-party/nsh/crates/nsh-core/src/access.rs
index 26f58e63fd..21cd8ed1e5 100644
--- a/third-party/nsh/crates/nsh-core/src/access.rs
+++ b/third-party/nsh/crates/nsh-core/src/access.rs
@@ -16,7 +16,8 @@
 //!     "sockets": ["mqtt.example.com:8883"]
 //!   },
 //!   "webview": { "hosts": ["*.example.com"] },
-//!   "statusBar": true
+//!   "statusBar": true,
+//!   "host": ["getUserInfo", "bridge.*"]
 //! }
 //! ```
 //!
@@ -28,10 +29,14 @@
 //! `network.sockets` grants raw TCP (`net.connect`) to exact `host:port`
 //! pairs only: no wildcards, and neither `hosts` nor `http` grants imply
 //! one. `webview` lists the hosts a web view may show and navigate to (its
-//! pages' own subresources are the web content's business). An app without
-//! `"capabilities"` (a standalone app, its developer's own) retains
-//! unrestricted HTTP, web views, the status bar and the clipboard; raw
-//! sockets, processes and the filesystem always require an explicit
+//! pages' own subresources are the web content's business). `host` lists
+//! the functions of the embedding app `nsh/host` may call: names (1 to 128
+//! of `[A-Za-z0-9_.:-]`, without `..`; the core refuses calls of any other
+//! name), or prefixes of them ending in `*` (`true`: all of them); the
+//! host's events need no grant, the host sends them. An app without `"capabilities"` (a
+//! standalone app, its developer's own) retains unrestricted HTTP, web
+//! views, the status bar and the clipboard; raw sockets, processes, the
+//! filesystem and the host's functions always require an explicit
 //! declaration.
 
 //! A `network.http` rule names a `host` (a pattern, as in `hosts`); every
@@ -349,6 +354,10 @@ pub struct Capabilities {
     pub status_bar: bool,
     /// The app may read and write the system clipboard (`nsh/clipboard`).
     pub clipboard: bool,
+    /// The functions of the embedding app `nsh/host` may call: names, or
+    /// prefixes ending in `*` (`"*"`: all); empty: none, also for apps
+    /// without a declaration ([`Capabilities::all`]).
+    pub host: Vec<String>,
 }
 
 /// The parts of a URL the checks look at.
@@ -411,8 +420,8 @@ pub fn parse_url(url: &str) -> Option<UrlParts<'_>> {
 
 impl Capabilities {
     /// Legacy unrestricted APIs for apps without a declaration. Socket,
-    /// process and filesystem grants stay empty and always require explicit
-    /// opt-in.
+    /// process, filesystem and host function grants stay empty and always
+    /// require explicit opt-in.
     pub fn all() -> Self {
         let any = vec![HostPattern("*".into())];
         Self {
@@ -424,6 +433,7 @@ impl Capabilities {
             webview: Some(any),
             status_bar: true,
             clipboard: true,
+            // The host's functions are never granted without a declaration.
             ..Self::default()
         }
     }
@@ -449,13 +459,14 @@ impl Capabilities {
     /// `dropped`: lenient, recording what unknown keys dropped; `None`:
     /// strict.
     fn parse(value: &Value, mut dropped: Option<&mut Vec<String>>) -> Result<Self, String> {
-        const KNOWN: [&str; 6] = [
+        const KNOWN: [&str; 7] = [
             "network",
             "webview",
             "statusBar",
             "clipboard",
             "process",
             "fs",
+            "host",
         ];
         let obj = object(value, "capabilities")?;
         for key in obj.keys().filter(|k| !KNOWN.contains(&k.as_str())) {
@@ -601,6 +612,30 @@ impl Capabilities {
                 }
             }
         }
+        if let Some(host) = obj.get("host") {
+            caps.host = match host {
+                Value::Bool(all) => {
+                    if *all {
+                        vec!["*".into()]
+                    } else {
+                        Vec::new()
+                    }
+                }
+                _ => {
+                    let names = strings(host, "capabilities.host")
+                        .map_err(|e| format!("{e} (or a boolean)"))?;
+                    if let Some(bad) = names.iter().find(|n| match n.strip_suffix('*') {
+                        Some(prefix) => !prefix.is_empty() && !valid_host_name(prefix),
+                        None => !valid_host_name(n),
+                    }) {
+                        return Err(format!(
+                            "capabilities.host: invalid function `{bad}` (a name of {HOST_NAME_CHARS}, or a prefix of them ending in `*`)"
+                        ));
+                    }
+                    names
+                }
+            };
+        }
         Ok(caps)
     }
 
@@ -662,6 +697,10 @@ impl Capabilities {
         }
         obj.insert("statusBar".into(), self.status_bar.into());
         obj.insert("clipboard".into(), self.clipboard.into());
+        // Only when granted: older runtimes read what newer signers write.
+        if !self.host.is_empty() {
+            obj.insert("host".into(), self.host.clone().into());
+        }
         Value::Object(obj)
     }
 
@@ -673,6 +712,13 @@ impl Capabilities {
             || self.network.http.iter().any(|g| g.allows(method, url))
     }
 
+    fn allows_host(&self, name: &str) -> bool {
+        self.host.iter().any(|g| match g.strip_suffix('*') {
+            Some(prefix) => name.starts_with(prefix),
+            None => g == name,
+        })
+    }
+
     fn allows_webview(&self, url: &UrlParts<'_>) -> bool {
         matches!(url.scheme.as_str(), "http" | "https")
             && self
@@ -682,6 +728,20 @@ impl Capabilities {
     }
 }
 
+/// What host function names (`nsh/host`) are made of, for messages.
+pub(crate) const HOST_NAME_CHARS: &str = "1 to 128 of A-Z a-z 0-9 _ . : -, without `..`";
+
+/// Whether `name` may name a function of the embedding host: 1 to 128 of
+/// `[A-Za-z0-9_.:-]`, without `..`. Hosts may put a name in a path or a URL,
+/// so no name escapes the prefix a grant gives it.
+pub(crate) fn valid_host_name(name: &str) -> bool {
+    (1..=128).contains(&name.len())
+        && name
+            .bytes()
+            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'-'))
+        && !name.contains("..")
+}
+
 fn valid_command(command: &str) -> bool {
     !command.is_empty()
         && !command.contains(['\0', '*'])
@@ -1136,6 +1196,11 @@ impl Access {
     pub fn allows_clipboard(&self) -> bool {
         self.each(|c| c.clipboard)
     }
+
+    /// Whether `nsh/host` may call the embedding app's function `name`.
+    pub fn allows_host(&self, name: &str) -> bool {
+        self.each(|c| c.allows_host(name))
+    }
 }
 
 /// `location` against `base` (absolute, scheme-relative, absolute-path or
@@ -1633,9 +1698,65 @@ mod tests {
         assert!(!access.allows_webview("https://example.com/"));
         assert!(!access.allows_status_bar());
         assert!(!access.allows_clipboard());
+        assert!(!access.allows_host("getUserInfo"));
         assert!(Access::all().is_unrestricted());
     }
 
+    #[test]
+    fn host_functions_need_an_explicit_grant() {
+        // Undeclared, the other capabilities are all granted; the host's
+        // functions are not.
+        let all = Access::all();
+        assert!(all.allows_clipboard());
+        assert!(!all.allows_host("getUserInfo"));
+        let access = |json: &str| Access {
+            declared: caps(json),
+            granted: None,
+        };
+        let every = access(r#"{ "host": true }"#);
+        assert!(every.allows_host("getUserInfo") && every.allows_host("bridge.call"));
+        assert!(!access(r#"{ "host": false }"#).allows_host("getUserInfo"));
+        let listed = access(r#"{ "host": ["getUserInfo", "bridge.*"] }"#);
+        assert!(listed.allows_host("getUserInfo"));
+        assert!(listed.allows_host("bridge.share"));
+        assert!(!listed.allows_host("getUserInfoX"));
+        assert!(!listed.allows_host("bridge"));
+        assert!(Capabilities::from_json(&serde_json::json!({ "host": ["a*b"] })).is_err());
+        assert!(Capabilities::from_json(&serde_json::json!({ "host": [""] })).is_err());
+        // The embedding app may grant less.
+        let narrowed = Access {
+            declared: caps(r#"{ "host": true }"#),
+            granted: Some(caps(r#"{ "host": ["bridge.*"] }"#)),
+        };
+        assert!(narrowed.allows_host("bridge.call") && !narrowed.allows_host("getUserInfo"));
+    }
+
+    #[test]
+    fn host_function_names_and_grants_keep_to_their_charset() {
+        for good in ["getUserInfo", "user.info", "a-b_c:d.E9", &"a".repeat(128)] {
+            assert!(valid_host_name(good), "{good}");
+        }
+        for bad in [
+            "",
+            "user.x/../../admin",
+            "a..b",
+            "user x",
+            "a\\b",
+            "caf\u{e9}",
+            "ok\u{0}",
+            "a*",
+            &"a".repeat(129),
+        ] {
+            assert!(!valid_host_name(bad), "{bad:?}");
+        }
+        let parses =
+            |grant: &str| Capabilities::from_json(&serde_json::json!({ "host": [grant] })).is_ok();
+        assert!(parses("*") && parses("user.*") && parses("user.info"));
+        for bad in ["user/*", "a..*", "../*", "a b", "**"] {
+            assert!(!parses(bad), "{bad}");
+        }
+    }
+
     #[test]
     fn hosts_and_finer_grants() {
         let c = caps(
diff --git a/third-party/nsh/crates/nsh-core/src/core/components.rs b/third-party/nsh/crates/nsh-core/src/core/components.rs
index fa302eeb3b..1256bfbb4f 100644
--- a/third-party/nsh/crates/nsh-core/src/core/components.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/components.rs
@@ -472,6 +472,10 @@ pub(super) struct Components {
     /// id: their answers.
     clipboard_reads: FxHashMap<u32, ClipboardRead>,
     next_clipboard_read: u32,
+    /// Calls of the embedding app's functions (`nsh/host`), by request id:
+    /// their answers.
+    host_calls: FxHashMap<u64, AnswerSlot<Result<crate::JsonValue, String>>>,
+    next_host_call: u64,
     /// HTTP requests of tasks (`ui::fetch`), by request id: what arrived
     /// so far and the answer.
     fetches: FxHashMap<u32, PendingFetch>,
@@ -555,6 +559,8 @@ impl Default for Components {
             next_image_info: 1,
             clipboard_reads: FxHashMap::default(),
             next_clipboard_read: 1,
+            host_calls: FxHashMap::default(),
+            next_host_call: 1,
             fetches: FxHashMap::default(),
             fetch_requests: Vec::new(),
             next_fetch: 1,
@@ -586,6 +592,12 @@ impl Default for Components {
 }
 
 impl Components {
+    /// Whether `list` is one of the components' own lists (bound in the
+    /// core).
+    pub(super) fn is_virtual_list(&self, list: NodeId) -> bool {
+        self.virtual_lists.contains_key(&list)
+    }
+
     pub(super) fn for_viewport(viewport: crate::Size<crate::Points>) -> Self {
         let mut components = Self::default();
         components.environment.window = viewport;
@@ -4408,6 +4420,28 @@ impl<M: TextEngine> Core<M> {
                     command,
                 });
             }
+            Action::HostCall { name, args, answer } => {
+                if !self.components.environment.host {
+                    answer
+                        .borrow_mut()
+                        .deliver(Err(crate::runtime::host::NO_HOST.into()));
+                    return;
+                }
+                let id = self.components.next_host_call;
+                self.components.next_host_call += 1;
+                self.components.host_calls.insert(id, answer);
+                let _ = self.apply_op(Op::Command {
+                    id: crate::ROOT_ID,
+                    command: Command::HostCall { id, name, args },
+                });
+            }
+            // A call timed out (`runtime/host.rs`), its answer dropped: its
+            // slot waits no more.
+            Action::ForgetHostCalls => {
+                self.components
+                    .host_calls
+                    .retain(|_, slot| std::rc::Rc::strong_count(slot) > 1);
+            }
         }
     }
 
@@ -4515,6 +4549,20 @@ impl<M: TextEngine> Core<M> {
         }
     }
 
+    /// The answer to host call `id`: the host's value, or its error. A
+    /// call that timed out (`runtime/host.rs`) takes none: its late answer
+    /// is dropped.
+    pub(super) fn answer_host(&mut self, id: u64, result: Result<crate::JsonValue, String>) {
+        match self.components.host_calls.remove(&id) {
+            Some(answer) if std::rc::Rc::strong_count(&answer) > 1 => {
+                answer.borrow_mut().deliver(result);
+            }
+            _ => {
+                log::debug!(target: "js", "nsh/host: dropped the answer to call {id}, which no longer waits")
+            }
+        }
+    }
+
     /// Events the components handle before anything else: dialog answers
     /// (theirs never reach JS), insets of screens and modals.
     fn platform_event(&mut self, id: NodeId, kind: EventKind, payload: &EventPayload) -> bool {
@@ -4614,6 +4662,42 @@ impl<M: TextEngine> Core<M> {
                 self.answer_clipboard(request as u32, answer);
                 true
             }
+            // The host's replies; its events go on to `nsh/host`'s
+            // listeners (`nsh-js`).
+            EventKind::Host if id == crate::ROOT_ID => {
+                let EventPayload::Json(reply) = payload else {
+                    return false;
+                };
+                if reply.get("type").and_then(crate::JsonValue::as_str) != Some("reply") {
+                    return false;
+                }
+                // JSON's numbers may come as floats (`1.0`); `1.5` is no
+                // call. Ids stay within JS' safe integers.
+                let call = reply.get("id").and_then(|id| {
+                    id.as_u64().or_else(|| {
+                        id.as_f64()
+                            .filter(|n| {
+                                n.fract() == 0.0 && (1.0..=9_007_199_254_740_991.0).contains(n)
+                            })
+                            .map(|n| n as u64)
+                    })
+                });
+                let Some(call) = call else {
+                    log::debug!(target: "js", "nsh/host: dropped a reply without a call's id: {reply}");
+                    return true;
+                };
+                let result = if reply.get("ok").and_then(crate::JsonValue::as_bool) == Some(true) {
+                    Ok(reply.get("value").cloned().unwrap_or_default())
+                } else {
+                    Err(reply
+                        .get("error")
+                        .and_then(crate::JsonValue::as_str)
+                        .unwrap_or("nsh/host: the call failed")
+                        .to_owned())
+                };
+                self.answer_host(call, result);
+                true
+            }
             EventKind::Insets => {
                 let insets = EdgeInsets {
                     top: Points(number("top").unwrap_or(0.0) as f32),
diff --git a/third-party/nsh/crates/nsh-core/src/core/list.rs b/third-party/nsh/crates/nsh-core/src/core/list.rs
index e0465ca1c3..5da76b23b9 100644
--- a/third-party/nsh/crates/nsh-core/src/core/list.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/list.rs
@@ -16,7 +16,18 @@
 //! `batch` and then sets its `cellIndex` to confirm, and only confirmed cells
 //! are measured and positioned. New cells are created with their first
 //! index. The engine runs at most 8 "bind → JS → layout" rounds per frame,
-//! merged into one batch. Row-local state travels with recycled cells, so
+//! merged into one batch.
+//!
+//! A slot remembers the item JS was last asked to show (`requested`), so
+//! what is still to bind is always derived from the current assignment:
+//! nothing stale is queued. Cells whose item moved (a splice) learn their
+//! new index in the same commit, and so does every cell in the viewport and
+//! just ahead of it in the scroll direction. A list with `paced` set (the
+//! runtime's) leaves the rest of the overscan pending: the engine binds it
+//! with `take_paced`, nearest the leading edge first, as its per-turn
+//! budget allows, and asks for a display frame while some is left.
+//!
+//! Row-local state travels with recycled cells, so
 //! apps keep it outside, keyed by id. Unbound cells keep their last content:
 //! the core moves them out of sight (never with a NaN frame; cells never
 //! placed are skipped). The header, footer and empty placeholder are cells
@@ -72,6 +83,9 @@ pub(super) struct Slot {
     pub(super) index: Option<u32>,
     /// Item the cell's content currently shows (confirmed by the runtime).
     pub(super) rendered: Option<u32>,
+    /// Item the runtime was last asked to show in this slot (its cell's
+    /// index); `None` before the first request or when that item is gone.
+    pub(super) requested: Option<u32>,
 }
 
 impl Slot {
@@ -310,6 +324,13 @@ pub(super) struct ListState {
     /// Cells whose item moved to another index (a splice): the runtime
     /// learns their new index with the next bindings.
     moved: Vec<(u32, u32)>,
+    /// Binds of overscan cells wait for the engine's budget
+    /// ([`Self::take_paced`]); the rest bind with the window.
+    pub(super) paced: bool,
+    /// The last scroll step the platform reported, in natural coordinates
+    /// (its sign is the scroll direction). The core's own moves (anchoring
+    /// a splice, measured rows, following a pin) are not steps.
+    lead: f32,
     /// Scroll that keeps the visible content in place after a splice.
     splice_anchor: f32,
     /// The target of the last scroll command, until it is reached with
@@ -376,6 +397,8 @@ impl ListState {
             issued: 0,
             pending: Vec::new(),
             moved: Vec::new(),
+            paced: false,
+            lead: 0.0,
             splice_anchor: 0.0,
             pin: None,
             source_geometry: SourceGeometry::default(),
@@ -519,24 +542,35 @@ impl ListState {
         };
         self.by_index.clear();
         for (slot, s) in self.slots.iter_mut().enumerate() {
-            let Some(old) = s.index else { continue };
-            match shift(old) {
-                Some(new) => {
-                    s.index = Some(new);
-                    if s.rendered == Some(old) {
-                        s.rendered = Some(new);
-                    }
-                    if new != old {
-                        self.moved.push((slot as u32, new));
+            let index = s.index.and_then(shift);
+            let Some(new) = index else {
+                if s.index.take().is_some() {
+                    self.free.push(slot as u32);
+                }
+                // The cell's content follows the new data at its old index:
+                // it is bound again before it shows.
+                s.rendered = None;
+                s.requested = None;
+                continue;
+            };
+            s.index = Some(new);
+            s.rendered = s.rendered.and_then(shift);
+            // The runtime's index for the cell follows its item at once, or
+            // its content shows the item now at the old index.
+            match s.requested.map(|r| (r, shift(r))) {
+                Some((r, Some(n))) => {
+                    s.requested = Some(n);
+                    if n != r {
+                        self.moved.push((slot as u32, n));
                     }
-                    self.by_index.insert(new, slot as u32);
                 }
-                None => {
-                    s.index = None;
+                Some((_, None)) => {
+                    s.requested = None;
                     s.rendered = None;
-                    self.free.push(slot as u32);
                 }
+                None => {}
             }
+            self.by_index.insert(new, slot as u32);
         }
         self.free.sort_unstable();
         self.free.dedup();
@@ -591,6 +625,8 @@ impl ListState {
     fn moved_to(&mut self, at: f32, settled: bool) {
         let moved = at != self.offset;
         if moved {
+            let step = at - self.offset;
+            self.lead = if self.inverted { -step } else { step };
             self.offset = at;
             self.dirty = true;
         }
@@ -1289,6 +1325,7 @@ impl ListState {
                     cell: u32::MAX,
                     index: None,
                     rendered: None,
+                    requested: None,
                 },
             );
         }
@@ -1307,6 +1344,7 @@ impl ListState {
             if slot.cell == cell {
                 slot.cell = u32::MAX;
                 slot.rendered = None;
+                slot.requested = None;
                 self.dirty = true;
             }
         }
@@ -1409,23 +1447,25 @@ impl ListState {
         StickyRange { cell, start, end }
     }
 
-    /// Recomputes the render window. New `(slot, index)` bindings are
-    /// pushed to `binds`. Returns true if `onLoadMore` should fire.
+    /// Recomputes the render window. `(slot, index)` bindings due now are
+    /// pushed to `binds`: cells whose item moved, and new ones in the
+    /// viewport or just ahead of it (all of them unless `paced`). Returns
+    /// true if `onLoadMore` should fire.
     pub(super) fn update_window(&mut self, binds: &mut Vec<(u32, u32)>) -> bool {
         self.dirty = false;
+        // Several splices can share one published source. Intermediate
+        // indices no longer describe that source or the slot's current item.
+        self.moved
+            .retain(|&(slot, index)| self.slots[slot as usize].index == Some(index));
+        // Cells whose item moved learn their final index before anything
+        // else binds, including when their viewport just became empty.
+        binds.append(&mut self.moved);
         if self.count == 0 || self.viewport <= 0.0 {
-            self.moved.clear();
             for slot in 0..self.slots.len() {
                 self.unbind(slot as u32);
             }
             return false;
         }
-        // Several splices can share one published source. Intermediate
-        // indices no longer describe that source or the slot's current item.
-        self.moved
-            .retain(|&(slot, index)| self.slots[slot as usize].index == Some(index));
-        // Cells whose item moved: the runtime learns their new index.
-        binds.append(&mut self.moved);
         let at = self.natural_offset();
         let overscan = self.viewport * OVERSCAN;
         let range = self.items_in(at - overscan, at + self.viewport + overscan);
@@ -1446,7 +1486,7 @@ impl ListState {
                 self.unbind(slot);
             }
         }
-        let mut bind = |this: &mut Self, i: u32| {
+        let bind = |this: &mut Self, i: u32| {
             if this.by_index.contains_key(&i) {
                 return;
             }
@@ -1457,13 +1497,13 @@ impl ListState {
                         cell: u32::MAX,
                         index: None,
                         rendered: None,
+                        requested: None,
                     });
                     this.slots.len() as u32 - 1
                 }
             };
             this.slots[slot as usize].index = Some(i);
             this.by_index.insert(i, slot);
-            binds.push((slot, i));
         };
         if let Some((first, last)) = range {
             for i in first..=last {
@@ -1473,6 +1513,24 @@ impl ListState {
         for s in [before, sticky].into_iter().flatten() {
             bind(self, s);
         }
+        let (start, end) = self.urgent_span(at);
+        let new = binds.len();
+        for slot in 0..self.slots.len() {
+            let s = self.slots[slot];
+            let Some(i) = s.index else { continue };
+            if s.requested == Some(i) {
+                continue;
+            }
+            if !self.paced
+                || sticky == Some(i)
+                || before == Some(i)
+                || self.item_overlaps(i, start, end)
+            {
+                self.slots[slot].requested = Some(i);
+                binds.push((slot as u32, i));
+            }
+        }
+        binds[new..].sort_unstable_by_key(|b| b.1);
 
         let total = self.blocks().total;
         let near_end = at + self.viewport >= total - self.viewport * self.end_threshold;
@@ -1483,6 +1541,82 @@ impl ListState {
         false
     }
 
+    /// The span (natural coordinates) whose cells bind with the window:
+    /// the viewport, and ahead of it in the scroll direction twice the last
+    /// step (what the next frames of a fling show). A step longer than the
+    /// viewport is a jump: only the viewport.
+    fn urgent_span(&self, at: f32) -> (f32, f32) {
+        let step = self.lead.abs();
+        let ahead = if step <= self.viewport {
+            (2.0 * step).min(self.viewport * OVERSCAN)
+        } else {
+            0.0
+        };
+        if self.lead > 0.0 {
+            (at, at + self.viewport + ahead)
+        } else {
+            (at - ahead, at + self.viewport)
+        }
+    }
+
+    /// Whether item `i`'s row overlaps `start..end` (natural coordinates).
+    fn item_overlaps(&self, i: u32, start: f32, end: f32) -> bool {
+        let top = self.item_at(i);
+        top < end && top + self.row_size(self.row_of(i)) > start
+    }
+
+    /// Whether cells wait for [`Self::take_paced`].
+    pub(super) fn has_paced(&self) -> bool {
+        self.slots
+            .iter()
+            .any(|s| s.index.is_some() && s.index != s.requested)
+    }
+
+    /// Up to `max` of the cells waiting to bind, nearest the viewport first
+    /// on the side the list scrolls towards, then the other side; pushed to
+    /// `binds`. Returns how many. The engine takes one at a time (it checks
+    /// its budget after each), so this picks the nearest by a scan of the
+    /// slots instead of sorting them.
+    pub(super) fn take_paced(&mut self, max: usize, binds: &mut Vec<(u32, u32)>) -> usize {
+        let mut taken = 0;
+        while taken < max {
+            let Some((slot, i)) = self.nearest_waiting() else {
+                break;
+            };
+            self.slots[slot as usize].requested = Some(i);
+            binds.push((slot, i));
+            taken += 1;
+        }
+        taken
+    }
+
+    /// The waiting cell [`Self::take_paced`] binds next: `(slot, index)`.
+    fn nearest_waiting(&self) -> Option<(u32, u32)> {
+        let at = self.natural_offset();
+        let (top, bottom) = (at, at + self.viewport);
+        let forward = self.lead >= 0.0;
+        let mut best: Option<((bool, f32), u32, u32)> = None;
+        for (slot, s) in self.slots.iter().enumerate() {
+            let Some(i) = s.index else { continue };
+            if s.requested == Some(i) {
+                continue;
+            }
+            let start = self.item_at(i);
+            let (distance, below) = if start >= bottom {
+                (start - bottom, true)
+            } else {
+                let end = start + self.row_size(self.row_of(i));
+                ((top - end).max(0.0), false)
+            };
+            // The side behind waits for the side ahead.
+            let key = (below != forward, distance);
+            if best.is_none_or(|(b, ..)| key.0.cmp(&b.0).then(key.1.total_cmp(&b.1)).is_lt()) {
+                best = Some((key, slot as u32, i));
+            }
+        }
+        best.map(|(_, slot, i)| (slot, i))
+    }
+
     fn unbind(&mut self, slot: u32) {
         if let Some(i) = self.slots[slot as usize].index.take() {
             self.by_index.remove(&i);
@@ -1889,6 +2023,117 @@ mod tests {
         );
     }
 
+    #[test]
+    fn paced_batched_splices_discard_bindings_for_replaced_sources() {
+        let mut l = list(100, 50.0, 400.0);
+        l.paced = true;
+        let mut binds = Vec::new();
+        l.update_window(&mut binds);
+        assert_eq!(binds.len(), 8);
+        assert!(l.has_paced());
+        binds.clear();
+
+        // Both changes publish together. Previously requested cells and
+        // overscan cells still waiting must read only the final snapshot.
+        l.splice(0, 0, 1);
+        l.splice(0, 101, 2);
+        l.update_window(&mut binds);
+        // The prepend moved the viewport to 50: final row 1 is visible,
+        // while row 0 is overscan above it and must wait for paced work.
+        assert_eq!(binds.iter().map(|b| b.1).collect::<Vec<_>>(), [1]);
+        assert_eq!(l.take_paced(100, &mut binds), 1);
+        assert_eq!(binds.iter().map(|b| b.1).collect::<Vec<_>>(), [1, 0]);
+        assert!(
+            binds
+                .iter()
+                .all(|&(slot, index)| l.slots[slot as usize].requested == Some(index))
+        );
+        assert!(!l.has_paced());
+        binds.clear();
+
+        // An empty final source must not receive even a moved-cell bind.
+        l.splice(0, 0, 2);
+        l.splice(0, 4, 0);
+        l.update_window(&mut binds);
+        l.take_paced(100, &mut binds);
+        assert!(binds.is_empty());
+        assert!(!l.has_paced());
+    }
+
+    #[test]
+    fn paced_lists_bind_the_viewport_now_and_the_overscan_ahead_first() {
+        let mut l = list(1000, 50.0, 400.0);
+        l.paced = true;
+        let mut binds = Vec::new();
+        l.update_window(&mut binds);
+        let now: Vec<u32> = binds.iter().map(|b| b.1).collect();
+        assert_eq!(now, (0..8).collect::<Vec<_>>(), "the viewport");
+        assert!(l.has_paced());
+        binds.clear();
+        assert_eq!(l.take_paced(3, &mut binds), 3);
+        assert_eq!(binds.iter().map(|b| b.1).collect::<Vec<_>>(), [8, 9, 10]);
+        while l.take_paced(100, &mut binds) > 0 {}
+        assert!(!l.has_paced());
+
+        // The platform reports the scroll: scrolling up by 100, the
+        // viewport and two steps above it bind now; the rest above comes
+        // before what is below.
+        l.reported(2000.0, 0);
+        l.update_window(&mut binds);
+        while l.take_paced(100, &mut binds) > 0 {}
+        l.reported(1900.0, 0);
+        binds.clear();
+        l.update_window(&mut binds);
+        // 1700..2300 was bound already; 1500..1600 is new and waits.
+        assert_eq!(binds, []);
+        // A jump binds its viewport only.
+        l.reported(5000.0, 0);
+        l.update_window(&mut binds);
+        let now: Vec<u32> = binds.iter().map(|b| b.1).collect();
+        assert_eq!(now, (100..108).collect::<Vec<_>>());
+        // A step up before the overscan bound: the viewport (4900..5300)
+        // and two steps ahead (4700..4900) bind now ...
+        l.reported(4900.0, 0);
+        binds.clear();
+        l.update_window(&mut binds);
+        let now: Vec<u32> = binds.iter().map(|b| b.1).collect();
+        assert_eq!(now, (94..100).collect::<Vec<_>>());
+        // ... the rest above before what is below.
+        binds.clear();
+        l.take_paced(3, &mut binds);
+        assert_eq!(binds.iter().map(|b| b.1).collect::<Vec<_>>(), [93, 92, 91]);
+    }
+
+    #[test]
+    fn splices_tell_bound_cells_their_index_and_forget_gone_items() {
+        let mut l = list(100, 50.0, 400.0);
+        l.paced = true;
+        let mut binds = Vec::new();
+        l.update_window(&mut binds);
+        for &(slot, index) in &binds {
+            l.add_slot_cell(CellSlot::Item(slot), 100 + slot);
+            l.rendered(100 + slot, index);
+        }
+        // Overscan cells wait; one row arrives at the top.
+        l.splice(0, 0, 1);
+        binds.clear();
+        l.update_window(&mut binds);
+        // Every bound cell learns its new index, at once (the anchoring
+        // scroll keeps them in place). The anchoring scroll is the core's,
+        // not a step of the user's: the cells waiting below it, and the new
+        // row above, still wait.
+        let now: Vec<u32> = binds.iter().map(|b| b.1).collect();
+        assert_eq!(now, (1..=8).collect::<Vec<_>>(), "{binds:?}");
+        assert!(l.has_paced());
+        // The data shrinks under the cells still waiting: none is asked
+        // for an item that is gone.
+        l.splice(3, 98, 0);
+        binds.clear();
+        l.update_window(&mut binds);
+        while l.take_paced(100, &mut binds) > 0 {}
+        assert!(binds.iter().all(|&(_, i)| i < 3), "{binds:?}");
+    }
+
     #[test]
     fn pins_follow_until_landed_measured_or_scrolled_away() {
         let mut l = list(100, 50.0, 400.0);
diff --git a/third-party/nsh/crates/nsh-core/src/core/mod.rs b/third-party/nsh/crates/nsh-core/src/core/mod.rs
index 9abd124399..9e200ea80d 100644
--- a/third-party/nsh/crates/nsh-core/src/core/mod.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/mod.rs
@@ -796,6 +796,11 @@ pub struct Core<M: TextEngine> {
     /// headers under the status bar).
     safe_area: EdgeInsets,
     list_binds: Vec<ListBind>,
+    /// The runtime's lists with overscan cells waiting to bind
+    /// ([`Core::take_paced_list_binds`]), in the order they asked.
+    paced_lists: Vec<NodeId>,
+    /// [`Core::take_paced_list_binds`]'s buffer, reused.
+    paced_scratch: Vec<(u32, u32)>,
     /// The events for JS, in order (the platform's and the core's events,
     /// Rust components' events, calls' results).
     events: Vec<crate::ui::JsEvent>,
@@ -927,6 +932,8 @@ impl<M: TextEngine> Core<M> {
             header_heights: (Points(44.0), Points(96.0)),
             safe_area: EdgeInsets::default(),
             list_binds: Vec::new(),
+            paced_lists: Vec::new(),
+            paced_scratch: Vec::new(),
             events: Vec::new(),
             cells_scratch: Vec::new(),
             root,
@@ -1181,11 +1188,58 @@ impl<M: TextEngine> Core<M> {
         Some((scroll.event_throttle_ms, content * z, frame, z))
     }
 
-    /// Moves list render requests produced by the last commit into `out`.
+    /// Moves list render requests produced by the last commit into `out`:
+    /// what must bind before the frame shows (cells whose item moved, cells
+    /// in the viewport and just ahead of it). The rest of the overscan
+    /// waits ([`Core::take_paced_list_binds`]).
     pub fn take_list_binds(&mut self, out: &mut Vec<ListBind>) {
         out.append(&mut self.list_binds);
     }
 
+    /// Whether lists have overscan cells waiting to bind.
+    pub fn has_paced_list_binds(&self) -> bool {
+        !self.paced_lists.is_empty()
+    }
+
+    /// Hands out up to `cells` of the overscan cells waiting to bind, one
+    /// per list in turn (a nested list is not starved by its parent), each
+    /// list's nearest its leading edge first. What a list assigns now is
+    /// what waits: cells it left or whose items went are never handed out.
+    pub fn take_paced_list_binds(&mut self, cells: usize, out: &mut Vec<ListBind>) {
+        let mut left = cells;
+        let mut lists = std::mem::take(&mut self.paced_lists);
+        let mut one = std::mem::take(&mut self.paced_scratch);
+        let start = out.len();
+        while left > 0 && !lists.is_empty() {
+            lists.retain(|&id| {
+                if left == 0 {
+                    return true;
+                }
+                let Some(list) = self
+                    .key(id)
+                    .ok()
+                    .and_then(|key| self.nodes[key as usize].list.as_mut())
+                else {
+                    return false;
+                };
+                one.clear();
+                left -= list.take_paced(1, &mut one);
+                if let Some((slot, index)) = one.pop() {
+                    match out[start..].iter_mut().find(|b| b.list == id) {
+                        Some(bind) => bind.binds.push((slot, index)),
+                        None => out.push(ListBind {
+                            list: id,
+                            binds: vec![(slot, index)],
+                        }),
+                    }
+                }
+                list.has_paced()
+            });
+        }
+        self.paced_lists = lists;
+        self.paced_scratch = one;
+    }
+
     /// Moves what the core hands JS (events it raised, such as
     /// `onLoadMore` and `onLayout`, Rust components' events, calls'
     /// results) into `out`, in order.
@@ -1629,6 +1683,27 @@ impl<M: TextEngine> Core<M> {
                     }
                     continue;
                 }
+                // Checked before the grants: a host may put the name in a
+                // path or a URL, where `user.x/../admin` leaves `user.*`.
+                Command::HostCall { id: call, name, .. }
+                    if !crate::access::valid_host_name(name) =>
+                {
+                    let message = format!(
+                        "nsh/host: invalid function name {name:?} ({})",
+                        crate::access::HOST_NAME_CHARS
+                    );
+                    self.answer_host(*call, Err(message.clone()));
+                    self.deny(message);
+                    continue;
+                }
+                Command::HostCall { id: call, name, .. } if !self.access.allows_host(name) => {
+                    let message = format!(
+                        "nsh/host: `{name}` is not granted: add it to capabilities.host in nsh.json"
+                    );
+                    self.answer_host(*call, Err(message.clone()));
+                    self.deny(message);
+                    continue;
+                }
                 Command::StatusBar(_) if !self.access.allows_status_bar() => {
                     self.deny("the status bar is not granted: add `\"statusBar\": true` to capabilities in nsh.json".into());
                     continue;
@@ -5757,7 +5832,11 @@ impl<M: TextEngine> Core<M> {
             self.commands.push((id, command));
         }
 
+        // The components' own lists bind in the core, at once; the
+        // runtime's pace their overscan.
+        let paced = !self.components.is_virtual_list(id);
         let list = self.nodes[key as usize].list.as_mut().expect("list");
+        list.paced = paced;
         let mut binds = Vec::new();
         if list.update_window(&mut binds) {
             self.events.push(crate::ui::JsEvent::Event(
@@ -5769,6 +5848,9 @@ impl<M: TextEngine> Core<M> {
         if !binds.is_empty() {
             self.list_binds.push(ListBind { list: id, binds });
         }
+        if list.paced && list.has_paced() && !self.paced_lists.contains(&id) {
+            self.paced_lists.push(id);
+        }
         if events.contains(EventKind::ViewableItemsChange) {
             let viewable = list.viewable_items();
             if viewable != list.viewable {
@@ -5810,10 +5892,11 @@ impl<M: TextEngine> Core<M> {
         if extras[1].cell != u32::MAX {
             cells.push(self.nodes[extras[1].cell as usize].id);
         }
-        // Cells left without an item still show their last one. Those
-        // in the render window (the data shrank, the list jumped) would
-        // show through: move them out of sight. Cells the window left
-        // behind while scrolling are out of sight already.
+        // Cells left without an item, or not showing theirs yet (a paced
+        // bind, or one the frame's rounds did not reach), still show their
+        // last one. Those in the render window (the data changed, the list
+        // jumped) would show through: move them out of sight. Cells the
+        // window left behind while scrolling are out of sight already.
         let (window_start, window_end) = list.render_window();
         // In its parent's viewport, the cells start inside the list's
         // border and padding.
@@ -5821,7 +5904,7 @@ impl<M: TextEngine> Core<M> {
         let hidden: Vec<Key> = list
             .slots
             .iter()
-            .filter(|s| s.index.is_none() && s.cell != u32::MAX)
+            .filter(|s| s.ready().is_none() && s.cell != u32::MAX)
             .map(|s| s.cell)
             .collect();
         for cell in hidden {
diff --git a/third-party/nsh/crates/nsh-core/src/core/tests.rs b/third-party/nsh/crates/nsh-core/src/core/tests.rs
index f53a58d785..be6d161392 100644
--- a/third-party/nsh/crates/nsh-core/src/core/tests.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/tests.rs
@@ -1534,7 +1534,8 @@ fn scroll_content_is_sent_once_per_batch() {
 }
 
 /// Plays JS: creates a cell per new slot, renders a fixed-height
-/// row for each bind and confirms the index.
+/// row for each bind and confirms the index; the overscan too, as an
+/// engine with time to spare does.
 fn render_binds(
     c: &mut Core<FakeEngine>,
     next_id: &mut u32,
@@ -1543,6 +1544,7 @@ fn render_binds(
 ) -> usize {
     let mut binds = Vec::new();
     c.take_list_binds(&mut binds);
+    c.take_paced_list_binds(usize::MAX, &mut binds);
     let mut count = 0;
     for bind in binds {
         for (slot, index) in bind.binds {
@@ -16699,6 +16701,8 @@ fn render_lists_binds(
 ) {
     let mut binds = Vec::new();
     c.take_list_binds(&mut binds);
+    // The overscan too, as an engine with time to spare binds it.
+    c.take_paced_list_binds(usize::MAX, &mut binds);
     for bind in binds {
         let list_cells = cells.entry(bind.list).or_default();
         for (slot, index) in bind.binds {
diff --git a/third-party/nsh/crates/nsh-core/src/js/host.rs b/third-party/nsh/crates/nsh-core/src/js/host.rs
index ef30531976..7058e57444 100644
--- a/third-party/nsh/crates/nsh-core/src/js/host.rs
+++ b/third-party/nsh/crates/nsh-core/src/js/host.rs
@@ -812,7 +812,7 @@ pub const EVENTS: &[(EventKind, u32, &str, &str)] = &[
 ];
 
 /// The events no element's props declare: the root's (`BackHandler`,
-/// `nsh/keymap`, dialogs, `Image.getSize`, clipboard reads, menus), the runtime's own (touches, insets, native
+/// `nsh/keymap`, dialogs, `Image.getSize`, clipboard reads, menus, `nsh/host`'s replies and events), the runtime's own (touches, insets, native
 /// components' events) and those whose payload a module makes
 /// (`List`'s viewable items, with the app's items).
 pub const INTERNAL_EVENTS: &[EventKind] = &[
@@ -823,6 +823,7 @@ pub const INTERNAL_EVENTS: &[EventKind] = &[
     EventKind::BackPress,
     EventKind::ImageInfo,
     EventKind::Clipboard,
+    EventKind::Host,
     EventKind::KeyCommand,
     EventKind::Menu,
     EventKind::ViewableItemsChange,
diff --git a/third-party/nsh/crates/nsh-core/src/js/mod.rs b/third-party/nsh/crates/nsh-core/src/js/mod.rs
index 34d0eb4f7f..5ed8086d33 100644
--- a/third-party/nsh/crates/nsh-core/src/js/mod.rs
+++ b/third-party/nsh/crates/nsh-core/src/js/mod.rs
@@ -31,6 +31,7 @@ pub const NATIVE_MODULES: &[&str] = &[
     "nsh/canvas",
     "nsh/list",
     "nsh/image",
+    "nsh/host",
     "nsh/keymap",
 ];
 
diff --git a/third-party/nsh/crates/nsh-core/src/keymap.rs b/third-party/nsh/crates/nsh-core/src/keymap.rs
index 7794af9a83..305f8f3fbf 100644
--- a/third-party/nsh/crates/nsh-core/src/keymap.rs
+++ b/third-party/nsh/crates/nsh-core/src/keymap.rs
@@ -151,8 +151,162 @@ impl Key {
             Key::F(_) => return None,
         })
     }
+
+    /// The key of USB HID keyboard usage `usage` (usage page 7, which
+    /// UIKit's `UIKey.keyCode` is): the key's place on the US layout,
+    /// whatever layout types on it; the keypad's digits and Enter too.
+    pub fn from_hid_usage(usage: u16) -> Option<Key> {
+        Some(match usage {
+            4..=29 => Key::Char(char::from(b'a' + (usage - 4) as u8)),
+            30..=38 => Key::Char(char::from(b'1' + (usage - 30) as u8)),
+            39 => Key::Char('0'),
+            40 | 88 => Key::Enter,
+            41 => Key::Escape,
+            42 => Key::Backspace,
+            43 => Key::Tab,
+            44 => Key::Char(' '),
+            58..=69 => Key::F((usage - 57) as u8),
+            73 => Key::Insert,
+            74 => Key::Home,
+            75 => Key::PageUp,
+            76 => Key::Delete,
+            77 => Key::End,
+            78 => Key::PageDown,
+            79 => Key::Right,
+            80 => Key::Left,
+            81 => Key::Down,
+            82 => Key::Up,
+            89..=97 => Key::Char(char::from(b'1' + (usage - 89) as u8)),
+            98 => Key::Char('0'),
+            104..=115 => Key::F((usage - 91) as u8),
+            _ => {
+                return HID_KEYS
+                    .iter()
+                    .find(|(u, _)| *u == usage)
+                    .map(|&(_, c)| Key::Char(c));
+            }
+        })
+    }
+
+    /// The USB HID keyboard usage of this key (the main keyboard's, not
+    /// the keypad's): what a hardware keyboard sends for it.
+    pub fn hid_usage(self) -> Option<u16> {
+        Some(match self {
+            Key::Char(c @ 'a'..='z') => 4 + u16::from(c as u8 - b'a'),
+            Key::Char(c @ '1'..='9') => 30 + u16::from(c as u8 - b'1'),
+            Key::Char('0') => 39,
+            Key::Enter => 40,
+            Key::Escape => 41,
+            Key::Backspace => 42,
+            Key::Tab => 43,
+            Key::Char(' ') => 44,
+            Key::F(n @ 1..=12) => 57 + u16::from(n),
+            Key::F(n @ 13..=24) => 91 + u16::from(n),
+            Key::Insert => 73,
+            Key::Home => 74,
+            Key::PageUp => 75,
+            Key::Delete => 76,
+            Key::End => 77,
+            Key::PageDown => 78,
+            Key::Right => 79,
+            Key::Left => 80,
+            Key::Down => 81,
+            Key::Up => 82,
+            Key::Char(c) => return HID_KEYS.iter().find(|(_, k)| *k == c).map(|&(u, _)| u),
+            Key::F(_) => return None,
+        })
+    }
+
+    /// The key of Android key code `code` (`KeyEvent.KEYCODE_*`); the
+    /// keypad's digits and Enter too.
+    pub fn from_android_keycode(code: i32) -> Option<Key> {
+        Some(match code {
+            7..=16 => Key::Char(char::from(b'0' + (code - 7) as u8)),
+            29..=54 => Key::Char(char::from(b'a' + (code - 29) as u8)),
+            19 => Key::Up,
+            20 => Key::Down,
+            21 => Key::Left,
+            22 => Key::Right,
+            61 => Key::Tab,
+            62 => Key::Char(' '),
+            66 | 160 => Key::Enter,
+            67 => Key::Backspace,
+            92 => Key::PageUp,
+            93 => Key::PageDown,
+            111 => Key::Escape,
+            112 => Key::Delete,
+            122 => Key::Home,
+            123 => Key::End,
+            124 => Key::Insert,
+            131..=142 => Key::F((code - 130) as u8),
+            144..=153 => Key::Char(char::from(b'0' + (code - 144) as u8)),
+            _ => {
+                return ANDROID_KEYS
+                    .iter()
+                    .find(|(k, _)| *k == code)
+                    .map(|&(_, c)| Key::Char(c));
+            }
+        })
+    }
+
+    /// The Android key code of this key (`KeyEvent.KEYCODE_*`, the main
+    /// keyboard's): what a key event for it carries.
+    pub fn android_keycode(self) -> Option<i32> {
+        Some(match self {
+            Key::Char(c @ '0'..='9') => 7 + i32::from(c as u8 - b'0'),
+            Key::Char(c @ 'a'..='z') => 29 + i32::from(c as u8 - b'a'),
+            Key::Up => 19,
+            Key::Down => 20,
+            Key::Left => 21,
+            Key::Right => 22,
+            Key::Tab => 61,
+            Key::Char(' ') => 62,
+            Key::Enter => 66,
+            Key::Backspace => 67,
+            Key::PageUp => 92,
+            Key::PageDown => 93,
+            Key::Escape => 111,
+            Key::Delete => 112,
+            Key::Home => 122,
+            Key::End => 123,
+            Key::Insert => 124,
+            Key::F(n @ 1..=12) => 130 + i32::from(n),
+            Key::Char(c) => return ANDROID_KEYS.iter().find(|(_, k)| *k == c).map(|&(k, _)| k),
+            Key::F(_) => return None,
+        })
+    }
 }
 
+/// The US layout's punctuation keys' USB HID usages.
+const HID_KEYS: &[(u16, char)] = &[
+    (45, '-'),
+    (46, '='),
+    (47, '['),
+    (48, ']'),
+    (49, '\\'),
+    (51, ';'),
+    (52, '\''),
+    (53, '`'),
+    (54, ','),
+    (55, '.'),
+    (56, '/'),
+];
+
+/// The US layout's punctuation keys' Android key codes.
+const ANDROID_KEYS: &[(i32, char)] = &[
+    (55, ','),
+    (56, '.'),
+    (68, '`'),
+    (69, '-'),
+    (70, '='),
+    (71, '['),
+    (72, ']'),
+    (73, '\\'),
+    (74, ';'),
+    (75, '\''),
+    (76, '/'),
+];
+
 /// One key with its modifiers.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 pub struct Keystroke {
@@ -657,6 +811,34 @@ mod tests {
         assert_eq!(Key::F(25).virtual_key(), None);
     }
 
+    #[test]
+    fn keys_have_hid_usages_and_android_key_codes_both_ways() {
+        assert_eq!(Key::from_hid_usage(4), Some(Key::Char('a')));
+        assert_eq!(Key::from_hid_usage(39), Some(Key::Char('0')));
+        assert_eq!(Key::from_hid_usage(81), Some(Key::Down));
+        assert_eq!(Key::from_hid_usage(56), Some(Key::Char('/')));
+        assert_eq!(Key::from_hid_usage(88), Some(Key::Enter));
+        assert_eq!(Key::from_hid_usage(104), Some(Key::F(13)));
+        assert_eq!(Key::from_hid_usage(225), None);
+        // The keypad's keys have the main keyboard's usages back.
+        for usage in (0..=0xFF).filter(|u| !(88..=98).contains(u)) {
+            if let Some(key) = Key::from_hid_usage(usage) {
+                assert_eq!(key.hid_usage(), Some(usage), "{key:?}");
+            }
+        }
+        assert_eq!(Key::from_android_keycode(29), Some(Key::Char('a')));
+        assert_eq!(Key::from_android_keycode(7), Some(Key::Char('0')));
+        assert_eq!(Key::from_android_keycode(20), Some(Key::Down));
+        assert_eq!(Key::from_android_keycode(76), Some(Key::Char('/')));
+        assert_eq!(Key::from_android_keycode(160), Some(Key::Enter));
+        assert_eq!(Key::from_android_keycode(59), None);
+        for code in (0..=300).filter(|c| *c != 160 && !(144..=153).contains(c)) {
+            if let Some(key) = Key::from_android_keycode(code) {
+                assert_eq!(key.android_keycode(), Some(code), "{key:?}");
+            }
+        }
+    }
+
     fn parse(text: &str) -> Result<Keystroke, KeystrokeError> {
         Keystroke::parse(text, Os::MacOs)
     }
diff --git a/third-party/nsh/crates/nsh-core/src/ops.rs b/third-party/nsh/crates/nsh-core/src/ops.rs
index 2a5fc49fcd..eb14f175c0 100644
--- a/third-party/nsh/crates/nsh-core/src/ops.rs
+++ b/third-party/nsh/crates/nsh-core/src/ops.rs
@@ -220,6 +220,8 @@ events! {
     Position = "onPosition": "The core placed an overlay (payload: `placement`, the side it went on, `x`, `y`, `width`, `height` in the root view, `availableWidth`, `availableHeight`: the room on that side).",
     MouseMove = "onMouseMove": "The mouse moved inside the view, including with a button down (desktops and web; local coordinates `x`, `y`; at most once per frame).",
     ScrollIntent = "onScrollIntent": "The user requested scrolling, before the offset changes, including at a boundary (payload: `intent`: `\"start\"`, `\"end\"` or `\"scroll\"`; once when a drag starts, before `onScrollDragStart`; programmatic scrolling does not emit it).",
+    Host = "onHost": "The embedding host answered a [`Command::HostCall`] or sent an event to `nsh/host`'s listeners (root only; payload: [`EventPayload::Json`], `{ type: \"reply\", id, ok, value?, error? }` or `{ type: \"event\", event, payload }`).",
+
 }
 
 /// What an input holds, for the system's autofill and suggestions
@@ -593,6 +595,8 @@ pub enum EventPayload {
         /// Its data.
         data: crate::JsonValue,
     },
+    /// A JSON value handed to JS as it is ([`EventKind::Host`]).
+    Json(crate::JsonValue),
 }
 
 impl TouchEvent {
@@ -1428,6 +1432,19 @@ pub enum Command {
     /// A desktop command (on the root; `nsh/desktop`): the window, its
     /// title bar, menus. Mobile platforms ignore it.
     Desktop(Box<crate::desktop::DesktopCommand>),
+    /// Call function `name` of the app embedding NSH (on the root;
+    /// `nsh/host`'s `host.call`): the platform answers with
+    /// [`EventKind::Host`] `{ type: "reply", id, .. }`. Sent only on
+    /// platforms whose [`crate::platform::Environment::host`] is set, for
+    /// functions the app's `capabilities.host` grants.
+    HostCall {
+        /// The call, echoed by the reply.
+        id: u64,
+        /// The host's function.
+        name: String,
+        /// Its argument (any JSON value; `null` without one).
+        args: crate::JsonValue,
+    },
     /// A call on a registered native component (`ref.command(name, args)`).
     Native {
         /// The command.
diff --git a/third-party/nsh/crates/nsh-core/src/platform.rs b/third-party/nsh/crates/nsh-core/src/platform.rs
index 20f831f9bc..a0738c7d01 100644
--- a/third-party/nsh/crates/nsh-core/src/platform.rs
+++ b/third-party/nsh/crates/nsh-core/src/platform.rs
@@ -192,6 +192,9 @@ pub struct Environment {
     pub terminal_keyboard: bool,
     /// The user's locale (number and date formats: `Intl`'s default).
     pub locale: Locale,
+    /// The app embedding NSH answers `nsh/host`'s calls
+    /// ([`crate::Command::HostCall`]); without it, they reject at once.
+    pub host: bool,
 }
 
 impl Environment {
@@ -235,6 +238,7 @@ impl Default for Environment {
             apple_keyboard: false,
             terminal_keyboard: false,
             locale: Locale::EN_US,
+            host: false,
         }
     }
 }
diff --git a/third-party/nsh/crates/nsh-core/src/runtime/host.rs b/third-party/nsh/crates/nsh-core/src/runtime/host.rs
new file mode 100644
index 0000000000..8db4521631
--- /dev/null
+++ b/third-party/nsh/crates/nsh-core/src/runtime/host.rs
@@ -0,0 +1,89 @@
+//! `nsh/host`: functions of the app embedding NSH, called by name with a
+//! JSON argument and answered by the platform ([`crate::Command::HostCall`],
+//! [`crate::EventKind::Host`] `{ type: "reply" }`). A call needs a valid
+//! name (`crate::access::valid_host_name`), granted by the `host`
+//! capability, also when the app declares no capabilities; platforms whose
+//! environment has no host ([`crate::platform::Environment::host`]) reject
+//! every call. A call the host does not answer in time rejects
+//! ([`DEFAULT_TIMEOUT`]); its answer, when it comes, is dropped. The host's
+//! events and `available()` are `nsh-js`'s (`platform_modules.rs`): its
+//! listeners are JS functions.
+
+use std::{future::Future, task::Poll, time::Duration};
+
+use crate::{
+    JsonValue,
+    ui::{JsObject, module},
+};
+
+/// What a call rejects with on a platform without a host.
+pub(crate) const NO_HOST: &str = "nsh/host: this platform has no host";
+
+/// How long a call waits for the host's answer unless its options say.
+const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// `host.call`'s options.
+#[derive(Debug, Clone, Default, JsObject)]
+pub(crate) struct HostCallOptions {
+    /// How long the call waits for the host's answer, in milliseconds,
+    /// before it rejects: 30000 unless set; 0, or one so large the clock
+    /// never gets there, waits as long as the host takes; a negative or
+    /// non-finite one rejects the call.
+    timeout: Option<f64>,
+}
+
+/// The app embedding NSH: `host.call(name, args)` calls one of its
+/// functions (granted by `capabilities.host` in nsh.json).
+#[module]
+pub(crate) struct Host;
+
+#[module(name = "host")]
+impl Host {
+    /// Calls the host's function `name` with `args`: what it returns, or a
+    /// rejection with its error, or once `options.timeout` passed.
+    pub(crate) async fn call(
+        name: String,
+        args: Option<JsonValue>,
+        options: Option<HostCallOptions>,
+    ) -> Result<JsonValue, String> {
+        // A timeout past what a `Duration` or the clock holds waits as 0.
+        let deadline = match options.and_then(|o| o.timeout) {
+            Some(ms) if !ms.is_finite() || ms < 0.0 => {
+                return Err(format!("nsh/host: invalid timeout {ms}"));
+            }
+            Some(0.0) => None,
+            Some(ms) => Duration::try_from_secs_f64(ms / 1000.0)
+                .ok()
+                .and_then(|t| Some((t, crate::time::now().checked_add(t)?))),
+            None => Some((DEFAULT_TIMEOUT, crate::time::now() + DEFAULT_TIMEOUT)),
+        };
+        let answer = crate::ui::ambient::host_call(name.clone(), args.unwrap_or_default());
+        let Some((timeout, at)) = deadline else {
+            return answer.await;
+        };
+        // On the app's clock, as its timers: a manual clock moves it.
+        let answered = {
+            let mut answer = std::pin::pin!(answer);
+            let mut delay = std::pin::pin!(crate::ui::ambient::timer_until(at));
+            std::future::poll_fn(|cx| {
+                if let Poll::Ready(result) = answer.as_mut().poll(cx) {
+                    return Poll::Ready(Some(result));
+                }
+                if delay.as_mut().poll(cx).is_ready() {
+                    return Poll::Ready(None);
+                }
+                Poll::Pending
+            })
+            .await
+        };
+        answered.unwrap_or_else(|| {
+            // The answer went with the block: the core drops its slot now,
+            // and a late reply finds none (`Core::answer_host`).
+            crate::ui::ambient::forget_host_calls();
+            Err(format!(
+                "nsh/host: {name} timed out after {} ms",
+                timeout.as_millis()
+            ))
+        })
+    }
+}
diff --git a/third-party/nsh/crates/nsh-core/src/runtime/mod.rs b/third-party/nsh/crates/nsh-core/src/runtime/mod.rs
index ad0708af02..6565297768 100644
--- a/third-party/nsh/crates/nsh-core/src/runtime/mod.rs
+++ b/third-party/nsh/crates/nsh-core/src/runtime/mod.rs
@@ -2,7 +2,8 @@
 //! `nsh/accessibility`'s commands, all of `nsh/image`, `nsh/clipboard` and
 //! `nsh/storage`,
 //! `nsh/desktop`'s `Window`, the surface-independent `os` metadata,
-//! `net`'s sockets, `websocket`'s connections and `fs/promises`' files. Their JS face is made
+//! `net`'s sockets, `websocket`'s connections, `fs/promises`' files and
+//! `nsh/host`'s calls. Their JS face is made
 //! from their specs like a UI set's (`nsh-js` `components.rs`), and so are
 //! their declarations ([`crate::ui::declarations`]); `nsh-js` adds what has
 //! to run in JS (the app's listeners, reactive accessors).
@@ -16,6 +17,7 @@ mod android;
 mod clipboard;
 mod desktop;
 mod fs;
+pub(crate) mod host;
 mod image;
 mod net;
 mod os;
@@ -64,6 +66,8 @@ pub fn registries() -> &'static [Registry] {
             websocket.module::<websocket::WebSocket>();
             websocket
         };
+        let mut host = Registry::runtime("nsh/host");
+        host.module::<host::Host>();
         vec![
             android,
             accessibility,
@@ -76,6 +80,7 @@ pub fn registries() -> &'static [Registry] {
             process,
             fs,
             websocket,
+            host,
         ]
     })
 }
diff --git a/third-party/nsh/crates/nsh-core/src/ui/ambient.rs b/third-party/nsh/crates/nsh-core/src/ui/ambient.rs
index d6749a0415..6b299a44ef 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/ambient.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/ambient.rs
@@ -439,6 +439,22 @@ pub fn clipboard_files() -> Answer<Vec<String>> {
     answer
 }
 
+/// The value function `name` of the embedding app returns for `args`
+/// (TS: `host.call`), to await in a task.
+pub(crate) fn host_call(
+    name: String,
+    args: crate::JsonValue,
+) -> Answer<Result<crate::JsonValue, String>> {
+    let (answer, action) = super::services::host_call(name, args);
+    act(action);
+    answer
+}
+
+/// Forgets the host calls whose answers were dropped (they timed out).
+pub(crate) fn forget_host_calls() {
+    act(Action::ForgetHostCalls);
+}
+
 /// Shows `message` at the bottom of the screen (Android's toasts, TS:
 /// `Toast.show`); nothing happens elsewhere.
 pub fn toast(message: impl Into<String>, duration: super::ToastDuration) {
diff --git a/third-party/nsh/crates/nsh-core/src/ui/env.rs b/third-party/nsh/crates/nsh-core/src/ui/env.rs
index 76bdab103e..808021fca9 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/env.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/env.rs
@@ -47,6 +47,9 @@ pub struct Environment {
     pub apple_keyboard: bool,
     /// The host receives terminal key sequences, even with graphical rendering.
     pub terminal_keyboard: bool,
+    /// The embedding app answers `nsh/host`'s calls
+    /// ([`crate::platform::Environment::host`]).
+    pub host: bool,
 }
 
 impl Default for Environment {
@@ -70,6 +73,7 @@ impl Default for Environment {
             locale: Locale::EN_US,
             apple_keyboard: false,
             terminal_keyboard: false,
+            host: false,
         }
     }
 }
diff --git a/third-party/nsh/crates/nsh-core/src/ui/services.rs b/third-party/nsh/crates/nsh-core/src/ui/services.rs
index e9366e2ec1..eaf01cb19a 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/services.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/services.rs
@@ -157,6 +157,22 @@ pub(crate) fn clipboard_files() -> (Answer<Vec<String>>, Action) {
     (answer, Action::ReadClipboard(ClipboardRead::Files(slot)))
 }
 
+/// Calls function `name` of the embedding app with `args` (`nsh/host`).
+pub(crate) fn host_call(
+    name: String,
+    args: crate::JsonValue,
+) -> (Answer<Result<crate::JsonValue, String>>, Action) {
+    let (answer, slot) = Answer::new();
+    (
+        answer,
+        Action::HostCall {
+            name,
+            args,
+            answer: slot,
+        },
+    )
+}
+
 /// The size of the image at `uri`, its bytes loaded.
 pub(crate) fn image_size(uri: String) -> (Answer<Result<ImageSize, String>>, Action) {
     let (answer, slot) = Answer::new();
diff --git a/third-party/nsh/crates/nsh-core/src/ui/task.rs b/third-party/nsh/crates/nsh-core/src/ui/task.rs
index 0890dfd0f5..3c64d04177 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/task.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/task.rs
@@ -79,6 +79,16 @@ pub(crate) enum Action {
     ),
     /// Reads the system clipboard; the answer resolves the slot.
     ReadClipboard(ClipboardRead),
+    /// Calls function `name` of the embedding app (`nsh/host`); its
+    /// value, or why it failed, resolves the slot.
+    HostCall {
+        name: String,
+        args: crate::JsonValue,
+        answer: AnswerSlot<Result<crate::JsonValue, String>>,
+    },
+    /// Drops the slots of the host calls whose answers are gone (they
+    /// timed out).
+    ForgetHostCalls,
     /// An operation on the native animation graph.
     Animation(crate::animation::AnimationOp),
     /// Starts an animation; its end resolves the slot.
diff --git a/third-party/nsh/crates/nsh-engine/src/lib.rs b/third-party/nsh/crates/nsh-engine/src/lib.rs
index 12d1849536..8ee344adff 100644
--- a/third-party/nsh/crates/nsh-engine/src/lib.rs
+++ b/third-party/nsh/crates/nsh-engine/src/lib.rs
@@ -564,9 +564,9 @@ pub trait MountSink: Send + 'static {
     }
 
     /// JS or a component's task asked for a display frame
-    /// (`requestAnimationFrame`, `ui::ambient::next_frame`): call
-    /// [`EngineControl::frame`] at the next vsync. Requests are not repeated
-    /// until that call.
+    /// (`requestAnimationFrame`, `ui::ambient::next_frame`), or lists have
+    /// overscan cells left to bind: call [`EngineControl::frame`] at the
+    /// next vsync. Requests are not repeated until that call.
     fn request_frame(&self) {}
 
     /// A development update ([`EngineControl::hot_update`]) could not be
@@ -885,6 +885,16 @@ pub struct HotUpdate {
 /// leftovers continue next frame.
 const MAX_LIST_ROUNDS: usize = 8;
 
+/// The overscan a turn binds: cells until their ops reach this many (about
+/// as many mount items, which the main thread applies in one go) or their
+/// JS takes [`PACED_BIND_TIME`], at least one. A fast scroll asks for a
+/// screen of cells at once and each bind rebuilds a cell's subtree; what
+/// shows (the viewport, the leading edge, cells whose item moved) binds
+/// regardless, the rest on the next display frames.
+const PACED_BIND_OPS: usize = 150;
+/// See [`PACED_BIND_OPS`].
+const PACED_BIND_TIME: std::time::Duration = std::time::Duration::from_millis(2);
+
 /// A cheap, cloneable handle for sending input to the engine from any thread.
 #[derive(Clone)]
 pub struct EngineControl {
@@ -1419,6 +1429,7 @@ impl<M: TextEngine + 'static> Driver<M> {
             touch_move: None,
             hover_moves: Vec::new(),
             hover_moved: None,
+            sets_seen: FxHashMap::default(),
             resized: None,
             pinned: config.pinned,
             net: Net {
@@ -1783,6 +1794,8 @@ struct State<M: TextEngine> {
     hover_moves: Vec<(NodeId, EventKind, EventPayload)>,
     /// When `hover_moves` were last delivered.
     hover_moved: Option<Instant>,
+    /// [`keep_last_sets`]'s buffer, reused.
+    sets_seen: FxHashMap<(u8, NodeId), usize>,
     /// The viewport size a resize set this turn ([`MountSink::resized`]).
     resized: Option<Size<Points>>,
 }
@@ -2075,7 +2088,9 @@ impl<M: TextEngine> State<M> {
     /// for one.
     fn request_frame(&mut self, script: Option<&Script>, sink: &dyn MountSink) {
         if !self.frame_requested
-            && (script.is_some_and(Script::wants_frame) || self.core.wants_frame())
+            && (script.is_some_and(Script::wants_frame)
+                || self.core.wants_frame()
+                || (script.is_some() && self.core.has_paced_list_binds()))
         {
             self.frame_requested = true;
             sink.request_frame();
@@ -2115,8 +2130,11 @@ impl<M: TextEngine> State<M> {
     fn commit(&mut self, script: Option<&Script>) -> Option<MountBatch> {
         let start = Instant::now();
         let mut merged: Option<MountBatch> = None;
+        let mut commits = 0;
         let mut op_count = 0;
         let mut js = std::time::Duration::ZERO;
+        // The overscan bound this turn ([`PACED_BIND_OPS`]).
+        let mut paced = PacedBinds::default();
         for _ in 0..MAX_LIST_ROUNDS {
             // Requests from the previous round (or frame) first.
             let js_start = Instant::now();
@@ -2127,6 +2145,35 @@ impl<M: TextEngine> State<M> {
                 for bind in binds {
                     script.bind_list(bind.list, &bind.binds);
                 }
+                script.take_ops(&mut self.ops);
+                // Taking ops publishes JS list sources. Their count/splice
+                // must reach the core before its queued indices read them.
+                let source_changed = |ops: &[Op]| {
+                    ops.iter().any(|op| {
+                        matches!(
+                            op,
+                            Op::SetProp {
+                                prop: nsh_core::Prop::Count(_) | nsh_core::Prop::Splice(..),
+                                ..
+                            } | Op::Release { .. }
+                        )
+                    })
+                };
+                let mut source_pending = source_changed(&self.ops);
+                let mut binds = Vec::new();
+                while !source_pending && paced.left() && self.core.has_paced_list_binds() {
+                    let start = Instant::now();
+                    let ops = self.ops.len();
+                    self.core.take_paced_list_binds(1, &mut binds);
+                    for bind in binds.drain(..) {
+                        paced.cells += bind.binds.len();
+                        script.bind_list(bind.list, &bind.binds);
+                    }
+                    script.take_ops(&mut self.ops);
+                    source_pending = source_changed(&self.ops[ops..]);
+                    paced.ops += self.ops.len() - ops;
+                    paced.time += start.elapsed();
+                }
             } else {
                 drop(binds);
             }
@@ -2183,6 +2230,7 @@ impl<M: TextEngine> State<M> {
                 }
             }
             if let Some(batch) = self.core.commit() {
+                commits += 1;
                 match &mut merged {
                     Some(m) => extend_frame(&mut m.items, batch.items),
                     None => merged = Some(batch),
@@ -2202,10 +2250,17 @@ impl<M: TextEngine> State<M> {
                 }
                 _ => false,
             };
-            if self.binds.is_empty() && self.events.is_empty() && !posted && !retheme {
+            let more_paced = script.is_some() && paced.left() && self.core.has_paced_list_binds();
+            if self.binds.is_empty() && self.events.is_empty() && !posted && !retheme && !more_paced
+            {
                 break;
             }
         }
+        if commits > 1
+            && let Some(batch) = &mut merged
+        {
+            keep_last_sets(&mut batch.items, &mut self.sets_seen);
+        }
         self.committed_since_gc |= op_count > 0;
         if self.perf.is_some() && (op_count > 0 || merged.is_some()) {
             let c = &mut self.perf_commit;
@@ -2252,6 +2307,7 @@ fn ui_environment(env: &Environment, window: Size<Points>) -> nsh_core::ui::Envi
         locale: env.locale,
         apple_keyboard: env.apple_keyboard,
         terminal_keyboard: env.terminal_keyboard,
+        host: env.host,
     }
 }
 
@@ -2484,6 +2540,22 @@ fn handle<M: TextEngine>(script: Option<&Script>, state: &mut State<M>, message:
     true
 }
 
+/// What a turn spent binding overscan cells.
+#[derive(Default)]
+struct PacedBinds {
+    cells: usize,
+    ops: usize,
+    time: std::time::Duration,
+}
+
+impl PacedBinds {
+    /// Whether the turn may bind another: at least one, then within the
+    /// budget.
+    fn left(&self) -> bool {
+        self.cells == 0 || (self.ops < PACED_BIND_OPS && self.time < PACED_BIND_TIME)
+    }
+}
+
 /// Appends a commit's items to the frame's. A scroll view's content size
 /// that the frame already sets is updated to the later size (unless the
 /// view scrolls in between): a row relaid over two commits must not send
@@ -2508,6 +2580,44 @@ fn extend_frame(frame: &mut Vec<nsh_core::MountItem>, items: Vec<nsh_core::Mount
     }
 }
 
+/// Of the items of a frame merged from several commits that set one part
+/// of a view whole (its frame, style, text, ...), keeps the last: the
+/// platform would apply each in one pass, and a list's rounds (binds, then
+/// the paced ones) relay and restyle the same cells. Once per turn, with
+/// `last` the reused buffer.
+fn keep_last_sets(frame: &mut Vec<nsh_core::MountItem>, last: &mut FxHashMap<(u8, NodeId), usize>) {
+    last.clear();
+    let mut repeated = false;
+    for (k, item) in frame.iter().enumerate() {
+        if let Some(key) = sets_whole(item) {
+            repeated |= last.insert(key, k).is_some();
+        }
+    }
+    if !repeated {
+        return;
+    }
+    let mut k = 0;
+    frame.retain(|item| {
+        k += 1;
+        sets_whole(item).is_none_or(|key| last[&key] == k - 1)
+    });
+}
+
+/// The part of a view an item sets whole, and the view: of two such items
+/// the later is all the view ends up with.
+fn sets_whole(item: &nsh_core::MountItem) -> Option<(u8, nsh_core::NodeId)> {
+    use nsh_core::MountItem;
+    Some(match *item {
+        MountItem::Frame { id, .. } => (0, id),
+        MountItem::Overflow { id, .. } => (1, id),
+        MountItem::ViewStyle { id, .. } => (2, id),
+        MountItem::Text { id, .. } => (3, id),
+        MountItem::Events { id, .. } => (4, id),
+        MountItem::Image { id, .. } => (5, id),
+        _ => return None,
+    })
+}
+
 #[cfg(test)]
 mod tests {
     #[test]
@@ -3072,4 +3182,35 @@ mount(() => <View>
         super::extend_frame(&mut frame, vec![content(Points(544.0))]);
         assert_eq!(heights(&frame), [313.0, 544.0]);
     }
+
+    #[test]
+    fn a_frame_sends_a_views_last_frame_once() {
+        use nsh_core::{Bounds, Point};
+        let at = |id, y| MountItem::Frame {
+            id,
+            frame: Bounds::new(
+                Point::new(Points(0.0), Points(y)),
+                Size::new(Points(10.0), Points(10.0)),
+            ),
+        };
+        let mut frame = vec![
+            MountItem::Create {
+                id: 4,
+                kind: nsh_core::ViewKind::View,
+            },
+            at(4, 0.0),
+            at(6, 0.0),
+        ];
+        super::extend_frame(&mut frame, vec![at(4, 50.0)]);
+        super::keep_last_sets(&mut frame, &mut Default::default());
+        assert!(matches!(frame[0], MountItem::Create { id: 4, .. }));
+        let frames: Vec<_> = frame
+            .iter()
+            .filter_map(|m| match m {
+                MountItem::Frame { id, frame } => Some((*id, frame.origin.y.0)),
+                _ => None,
+            })
+            .collect();
+        assert_eq!(frames, [(6, 0.0), (4, 50.0)]);
+    }
 }
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs b/third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs
index 37389e27c7..0cfa5ffde9 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs
@@ -219,6 +219,7 @@ impl App {
                 items.extend(batch.items);
             }
             self.core.take_list_binds(&mut self.binds);
+            self.core.take_paced_list_binds(usize::MAX, &mut self.binds);
             self.native += t.elapsed();
             if self.binds.is_empty() {
                 break;
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/components.rs b/third-party/nsh/crates/nsh-engine/tests/engine/components.rs
index f0e36ccd6c..1c8b5bc379 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/components.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/components.rs
@@ -26,7 +26,7 @@ fn start_in(app: &str, environment: Environment) -> (Engine, Receiver<MountBatch
 
 /// [`start_in`] with the capabilities the app declares (JSON, `nsh.json`'s
 /// `capabilities` form) and an `nsh.json`.
-fn start_with(
+pub(crate) fn start_with(
     app: &str,
     environment: Environment,
     capabilities: Option<&str>,
@@ -209,7 +209,10 @@ fn a_stateful_component_steps_while_held_on((engine, rx): (Engine, Receiver<Moun
 }
 
 /// The batches until `done` holds for one (or nothing came for 500ms).
-fn until(rx: &Receiver<MountBatch>, mut done: impl FnMut(&MountBatch) -> bool) -> Vec<MountBatch> {
+pub(crate) fn until(
+    rx: &Receiver<MountBatch>,
+    mut done: impl FnMut(&MountBatch) -> bool,
+) -> Vec<MountBatch> {
     let mut batches = Vec::new();
     while let Ok(batch) = rx.recv_timeout(Duration::from_millis(500)) {
         let stop = done(&batch);
@@ -221,7 +224,7 @@ fn until(rx: &Receiver<MountBatch>, mut done: impl FnMut(&MountBatch) -> bool) -
     batches
 }
 
-fn shows(batch: &MountBatch, wanted: &str) -> bool {
+pub(crate) fn shows(batch: &MountBatch, wanted: &str) -> bool {
     text_ids(batch, wanted).is_some()
 }
 
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/host.rs b/third-party/nsh/crates/nsh-engine/tests/engine/host.rs
new file mode 100644
index 0000000000..d00146218f
--- /dev/null
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/host.rs
@@ -0,0 +1,476 @@
+//! `nsh/host`: calls into the app embedding NSH and its events, answered
+//! by a stand-in for the platform (as `components.rs`' clipboard is).
+
+use crossbeam_channel::Receiver;
+use nsh_core::{Command, EventKind, EventPayload, MountBatch, MountItem, ROOT_ID};
+use nsh_engine::{Engine, Environment};
+use serde_json::json;
+
+use crate::components::{shows, start_with, until};
+
+/// What the tests' apps declare: every host function granted.
+const ALL: Option<&str> = Some(r#"{ "host": true }"#);
+
+/// A platform that embeds a host.
+fn with_host() -> Environment {
+    Environment {
+        host: true,
+        ..Environment::default()
+    }
+}
+
+/// Calls each of `names` with `{ a: 1 }` and shows `available()` and what
+/// each call settled with.
+fn calls(names: &[&str]) -> String {
+    let names = serde_json::to_string(names).unwrap();
+    format!(
+        r#"import {{ mount, signal, Text }} from "nsh";
+import {{ host }} from "nsh/host";
+
+const shown = signal("waiting");
+const settled = async (p) => {{
+  try {{ return JSON.stringify(await p); }} catch (e) {{ return `rejected ${{e.message}}`; }}
+}};
+setTimeout(async () => {{
+  const results = [];
+  for (const name of {names}) results.push(await settled(host.call(name, {{ a: 1 }})));
+  shown.set(`${{host.available()}} ${{results.join(" | ")}}`);
+}}, 0);
+
+mount(() => <Text>{{shown()}}</Text>);
+"#
+    )
+}
+
+/// A host on the platform: answers each call with its arguments, `fail`
+/// with an error. Returns the calls that reached it.
+fn serve_host(
+    engine: &Engine,
+    rx: &Receiver<MountBatch>,
+    mut done: impl FnMut(&str) -> bool,
+) -> Vec<Command> {
+    let control = engine.control();
+    let mut commands = Vec::new();
+    until(rx, |batch| {
+        for item in &batch.items {
+            let MountItem::Command {
+                id: ROOT_ID,
+                command: command @ Command::HostCall { id, name, args },
+            } = item
+            else {
+                continue;
+            };
+            let reply = if name == "fail" {
+                json!({ "type": "reply", "id": id, "ok": false, "error": "boom" })
+            } else {
+                json!({ "type": "reply", "id": id, "ok": true, "value": args })
+            };
+            control.dispatch_event(ROOT_ID, EventKind::Host, EventPayload::Json(reply));
+            commands.push(command.clone());
+        }
+        texts(batch).any(|t| done(&t))
+    });
+    commands
+}
+
+fn texts(batch: &MountBatch) -> impl Iterator<Item = String> + '_ {
+    batch.items.iter().filter_map(|i| match i {
+        MountItem::Text { text, .. } => Some(text.text.to_string()),
+        _ => None,
+    })
+}
+
+/// The names of the calls that reached the platform.
+fn names(commands: &[Command]) -> Vec<&str> {
+    commands
+        .iter()
+        .filter_map(|c| match c {
+            Command::HostCall { name, .. } => Some(name.as_str()),
+            _ => None,
+        })
+        .collect()
+}
+
+#[test]
+fn host_call_round_trips_through_the_platform() {
+    let echo = Some(r#"{ "host": ["echo"] }"#);
+    let (engine, rx) = start_with(&calls(&["echo"]), with_host(), echo, None);
+    let commands = serve_host(&engine, &rx, |t| t == r#"true {"a":1}"#);
+    assert_eq!(
+        commands,
+        [Command::HostCall {
+            id: 1,
+            name: "echo".into(),
+            args: json!({ "a": 1 }),
+        }]
+    );
+    // Without a host, nothing reaches the platform and calls reject.
+    let (engine, rx) = start_with(&calls(&["echo"]), Environment::default(), echo, None);
+    let commands = serve_host(&engine, &rx, |t| {
+        t == "false rejected nsh/host: this platform has no host"
+    });
+    assert!(commands.is_empty(), "{commands:?}");
+}
+
+#[test]
+fn a_failing_host_call_rejects_with_its_message() {
+    let (engine, rx) = start_with(&calls(&["fail"]), with_host(), ALL, None);
+    let mut shown = String::new();
+    serve_host(&engine, &rx, |t| {
+        let done = t.starts_with("true ");
+        if done {
+            shown = t.to_owned();
+        }
+        done
+    });
+    assert_eq!(shown, "true rejected boom");
+}
+
+#[test]
+fn host_calls_need_their_capability() {
+    // Declared without `host`, and not declared at all: denied alike (the
+    // other capabilities keep their grant-all default for apps without a
+    // declaration; the host's functions never do).
+    for capabilities in [Some("{}"), None] {
+        host_call_is_denied(capabilities);
+    }
+}
+
+fn host_call_is_denied(capabilities: Option<&str>) {
+    crate::logs::capture();
+    let (engine, rx) = start_with(&calls(&["echo"]), with_host(), capabilities, None);
+    let mut shown = String::new();
+    let commands = serve_host(&engine, &rx, |t| {
+        let done = t.starts_with("true ");
+        if done {
+            shown = t.to_owned();
+        }
+        done
+    });
+    assert!(commands.is_empty(), "{commands:?}");
+    assert!(
+        shown.starts_with("true rejected ") && shown.contains("capabilities.host"),
+        "{shown}"
+    );
+    // The refusal is logged, saying which key to add.
+    let logged = crate::logs::lines_containing("nsh/host: `echo` is not granted");
+    assert!(
+        logged.iter().any(|w| w.contains("capabilities.host")),
+        "{logged:?}"
+    );
+}
+
+#[test]
+fn host_allowlist_matches_prefixes() {
+    let (engine, rx) = start_with(
+        &calls(&["bridge.call", "other"]),
+        with_host(),
+        Some(r#"{ "host": ["bridge.*"] }"#),
+        None,
+    );
+    let mut shown = String::new();
+    let commands = serve_host(&engine, &rx, |t| {
+        let done = t.starts_with("true ");
+        if done {
+            shown = t.to_owned();
+        }
+        done
+    });
+    assert_eq!(names(&commands), ["bridge.call"]);
+    assert!(shown.starts_with(r#"true {"a":1} | rejected "#), "{shown}");
+}
+
+const EVENTS: &str = r#"import { mount, signal, Text } from "nsh";
+import { host } from "nsh/host";
+
+const shown = signal("waiting");
+let once = 0;
+let all = 0;
+const first = host.on("tick", () => {
+  once++;
+  first.remove();
+});
+host.on("tick", (payload) => {
+  all++;
+  shown.set(`${once} ${all} ${JSON.stringify(payload)}`);
+});
+host.on("other", () => shown.set("wrong event"));
+setTimeout(() => shown.set("ready"), 0);
+
+mount(() => <Text>{shown()}</Text>);
+"#;
+
+#[test]
+fn host_events_reach_listeners_until_removed() {
+    let (engine, rx) = start_with(EVENTS, with_host(), Some("{}"), None);
+    until(&rx, |b| shows(b, "ready"));
+    for n in [1, 2] {
+        engine.control().dispatch_event(
+            ROOT_ID,
+            EventKind::Host,
+            EventPayload::Json(json!({ "type": "event", "event": "tick", "payload": { "n": n } })),
+        );
+    }
+    let batches = until(&rx, |b| shows(b, r#"1 2 {"n":2}"#));
+    assert!(
+        batches.iter().any(|b| shows(b, r#"1 2 {"n":2}"#)),
+        "{:?}",
+        batches.iter().flat_map(texts).collect::<Vec<_>>()
+    );
+}
+
+/// Calls `slow` with `options` (JS) once the app runs, and shows how it
+/// settled.
+fn timed(options: &str) -> String {
+    format!(
+        r#"import {{ mount, signal, Text }} from "nsh";
+import {{ host }} from "nsh/host";
+
+const shown = signal("waiting");
+setTimeout(() => {{
+  host.call("slow", null, {options}).then(
+    (value) => shown.set(`ok ${{JSON.stringify(value)}}`),
+    (e) => shown.set(`rejected ${{e.message}}`),
+  );
+}}, 0);
+
+mount(() => <Text>{{shown()}}</Text>);
+"#
+    )
+}
+
+/// The ids of the host calls in `batches`.
+fn call_ids(batches: &[MountBatch]) -> Vec<u64> {
+    batches
+        .iter()
+        .flat_map(|b| &b.items)
+        .filter_map(|item| match item {
+            MountItem::Command {
+                command: Command::HostCall { id, .. },
+                ..
+            } => Some(*id),
+            _ => None,
+        })
+        .collect()
+}
+
+fn reply(engine: &Engine, id: u64, value: serde_json::Value) {
+    reply_with_id(engine, json!(id), value);
+}
+
+fn reply_with_id(engine: &Engine, id: serde_json::Value, value: serde_json::Value) {
+    engine.control().dispatch_event(
+        ROOT_ID,
+        EventKind::Host,
+        EventPayload::Json(json!({ "type": "reply", "id": id, "ok": true, "value": value })),
+    );
+}
+
+#[test]
+fn a_host_call_times_out_and_its_late_reply_is_ignored() {
+    let (engine, rx) = start_with(&timed("{ timeout: 200 }"), with_host(), ALL, None);
+    let batches = until(&rx, |b| {
+        shows(b, "rejected nsh/host: slow timed out after 200 ms")
+    });
+    assert!(
+        batches
+            .iter()
+            .any(|b| shows(b, "rejected nsh/host: slow timed out after 200 ms")),
+        "{:?}",
+        batches.iter().flat_map(texts).collect::<Vec<_>>()
+    );
+    let ids = call_ids(&batches);
+    assert_eq!(ids.len(), 1, "{ids:?}");
+    // The host answers late: nothing changes, nothing fails.
+    reply(&engine, ids[0], json!(1));
+    let after = until(&rx, |_| false);
+    assert!(
+        !after.iter().flat_map(texts).any(|t| t.starts_with("ok")),
+        "{:?}",
+        after.iter().flat_map(texts).collect::<Vec<_>>()
+    );
+}
+
+#[test]
+fn a_reply_before_the_timeout_wins() {
+    let (engine, rx) = start_with(&timed("{ timeout: 300 }"), with_host(), ALL, None);
+    let mut answered = false;
+    let batches = until(&rx, |b| {
+        if !answered && let Some(&id) = call_ids(std::slice::from_ref(b)).first() {
+            reply(&engine, id, json!(7));
+            answered = true;
+        }
+        shows(b, "ok 7")
+    });
+    assert!(batches.iter().any(|b| shows(b, "ok 7")));
+    // Past the timeout, the settled call stays settled.
+    std::thread::sleep(std::time::Duration::from_millis(400));
+    let after = until(&rx, |_| false);
+    assert!(
+        !after
+            .iter()
+            .flat_map(texts)
+            .any(|t| t.starts_with("rejected")),
+        "{:?}",
+        after.iter().flat_map(texts).collect::<Vec<_>>()
+    );
+}
+
+#[test]
+fn a_timeout_of_zero_waits_for_the_host() {
+    let (engine, rx) = start_with(&timed("{ timeout: 0 }"), with_host(), ALL, None);
+    let mut ids = Vec::new();
+    // No answer for a while: the call keeps waiting.
+    let start = std::time::Instant::now();
+    while start.elapsed() < std::time::Duration::from_millis(600) {
+        let batches = until(&rx, |_| false);
+        ids.extend(call_ids(&batches));
+        assert!(
+            !batches
+                .iter()
+                .flat_map(texts)
+                .any(|t| t.starts_with("rejected")),
+            "{:?}",
+            batches.iter().flat_map(texts).collect::<Vec<_>>()
+        );
+    }
+    assert_eq!(ids.len(), 1, "{ids:?}");
+    reply(&engine, ids[0], json!("late"));
+    let batches = until(&rx, |b| shows(b, r#"ok "late""#));
+    assert!(batches.iter().any(|b| shows(b, r#"ok "late""#)));
+}
+
+#[test]
+fn host_function_names_outside_their_charset_are_refused() {
+    // Granted everything, yet none of these reach the host: a host that
+    // puts the name in a path or a URL never sees `/` or `..`.
+    let long = "a".repeat(129);
+    let bad = [
+        "user.x/../../admin",
+        "",
+        "a..b",
+        "user x",
+        "ok\u{0}",
+        long.as_str(),
+    ];
+    let (engine, rx) = start_with(&calls(&bad), with_host(), ALL, None);
+    let mut shown = String::new();
+    let commands = serve_host(&engine, &rx, |t| {
+        let done = t.starts_with("true ");
+        if done {
+            shown = t.to_owned();
+        }
+        done
+    });
+    assert!(commands.is_empty(), "{commands:?}");
+    let results: Vec<&str> = shown.trim_start_matches("true ").split(" | ").collect();
+    assert_eq!(results.len(), bad.len(), "{shown}");
+    assert!(
+        results
+            .iter()
+            .all(|r| r.starts_with("rejected nsh/host: invalid function name")),
+        "{shown}"
+    );
+    // The longest name and every character of the set pass.
+    let max = "a".repeat(128);
+    let good = ["user.info", "a-b_c:d.E9", max.as_str()];
+    let (engine, rx) = start_with(&calls(&good), with_host(), ALL, None);
+    let commands = serve_host(&engine, &rx, |t| t.starts_with("true "));
+    assert_eq!(names(&commands), good);
+}
+
+#[test]
+fn timeouts_past_the_clocks_reach_wait_without_limit() {
+    // Too long for a `Duration` (1e30 ms) or for the clock's `Instant`
+    // (1.5e22 ms): the call waits for the host as with 0, and the engine
+    // goes on.
+    for timeout in ["1e30", "1.5e22"] {
+        let options = format!("{{ timeout: {timeout} }}");
+        let (engine, rx) = start_with(&timed(&options), with_host(), ALL, None);
+        let mut answered = false;
+        let batches = until(&rx, |b| {
+            if !answered && let Some(&id) = call_ids(std::slice::from_ref(b)).first() {
+                reply(&engine, id, json!(timeout));
+                answered = true;
+            }
+            shows(b, &format!(r#"ok "{timeout}""#))
+        });
+        assert!(
+            batches
+                .iter()
+                .any(|b| shows(b, &format!(r#"ok "{timeout}""#))),
+            "{timeout}: {:?}",
+            batches.iter().flat_map(texts).collect::<Vec<_>>()
+        );
+    }
+}
+
+#[test]
+fn negative_and_non_finite_timeouts_reject() {
+    for (timeout, shown) in [("-1", "-1"), ("NaN", "NaN"), ("Infinity", "inf")] {
+        let options = format!("{{ timeout: {timeout} }}");
+        let (_engine, rx) = start_with(&timed(&options), with_host(), ALL, None);
+        let wanted = format!("rejected nsh/host: invalid timeout {shown}");
+        let batches = until(&rx, |b| shows(b, &wanted));
+        assert!(
+            batches.iter().any(|b| shows(b, &wanted)),
+            "{timeout}: {:?}",
+            batches.iter().flat_map(texts).collect::<Vec<_>>()
+        );
+        assert!(call_ids(&batches).is_empty());
+    }
+}
+
+#[test]
+fn a_reply_whose_id_is_not_a_whole_number_answers_no_call() {
+    let (engine, rx) = start_with(&timed("{ timeout: 0 }"), with_host(), ALL, None);
+    let mut answered = false;
+    let batches = until(&rx, |b| {
+        if !answered && let Some(&id) = call_ids(std::slice::from_ref(b)).first() {
+            assert_eq!(id, 1);
+            // 1.5 is not call 1; 1.0 is (JSON numbers may come as floats).
+            reply_with_id(&engine, json!(1.5), json!("half"));
+            reply_with_id(&engine, json!(1.0), json!("whole"));
+            answered = true;
+        }
+        shows(b, r#"ok "whole""#) || shows(b, r#"ok "half""#)
+    });
+    assert!(
+        batches.iter().any(|b| shows(b, r#"ok "whole""#)),
+        "{:?}",
+        batches.iter().flat_map(texts).collect::<Vec<_>>()
+    );
+}
+
+const THROWING_LISTENER: &str = r#"import { mount, signal, Text } from "nsh";
+import { host } from "nsh/host";
+
+const shown = signal("waiting");
+host.on("tick", () => {
+  throw new Error("first listener");
+});
+host.on("tick", (payload) => shown.set(`second ${JSON.stringify(payload)}`));
+setTimeout(() => shown.set("ready"), 0);
+
+mount(() => <Text>{shown()}</Text>);
+"#;
+
+#[test]
+fn a_throwing_host_listener_leaves_the_others_called() {
+    crate::logs::capture();
+    let (engine, rx) = start_with(THROWING_LISTENER, with_host(), Some("{}"), None);
+    until(&rx, |b| shows(b, "ready"));
+    engine.control().dispatch_event(
+        ROOT_ID,
+        EventKind::Host,
+        EventPayload::Json(json!({ "type": "event", "event": "tick", "payload": 1 })),
+    );
+    let batches = until(&rx, |b| shows(b, "second 1"));
+    assert!(
+        batches.iter().any(|b| shows(b, "second 1")),
+        "{:?}",
+        batches.iter().flat_map(texts).collect::<Vec<_>>()
+    );
+    // The first listener's error is still reported.
+    assert!(!crate::logs::wait_for("first listener").is_empty());
+}
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/list.rs b/third-party/nsh/crates/nsh-engine/tests/engine/list.rs
index 861cf3aac6..bf260087b6 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/list.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/list.rs
@@ -6,12 +6,23 @@ use std::{sync::mpsc, time::Duration};
 use nsh_core::{ApproxTextEngine, MountBatch, MountItem, Size, ViewKind, geometry::px};
 use nsh_engine::{Bundle, Engine, EngineConfig, MountSink};
 
-struct Sink(mpsc::Sender<MountBatch>);
+/// Sends batches to the test, and answers a request for a display frame
+/// at once, as a display would at its next refresh.
+struct Sink(
+    mpsc::Sender<MountBatch>,
+    std::sync::Arc<std::sync::OnceLock<nsh_engine::EngineControl>>,
+);
 
 impl MountSink for Sink {
     fn submit(&self, batch: MountBatch) {
         let _ = self.0.send(batch);
     }
+
+    fn request_frame(&self) {
+        if let Some(control) = self.1.get() {
+            control.frame();
+        }
+    }
 }
 
 /// `List`, made in place by the compiler.
@@ -77,9 +88,9 @@ fn list_as_a_value_scrolls_with_recycled_cells() {
     scrolls_with_recycled_cells(&app);
 }
 
-/// Bundles `app` and starts it on a 320 x 500 viewport: the engine, its
-/// batches and the app's directory (the caller removes it).
-fn start(app: &str) -> (Engine, mpsc::Receiver<MountBatch>, std::path::PathBuf) {
+/// Bundles `app` into a config for a 320 x 500 viewport, and the app's
+/// directory (the caller removes it).
+fn config(app: &str) -> (EngineConfig, std::path::PathBuf) {
     static APPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
     let dir = std::env::temp_dir().join(format!(
         "nsh-list-{}-{}",
@@ -97,8 +108,6 @@ fn start(app: &str) -> (Engine, mpsc::Receiver<MountBatch>, std::path::PathBuf)
     })
     .unwrap();
     let bytecode = nsh_js::compile_bytecode(&bundle.code, false).unwrap();
-
-    let (tx, rx) = mpsc::channel();
     let config = EngineConfig {
         // SAFETY: compiled just now by this runtime.
         app: nsh_engine::App::Js(unsafe { Bundle::bytecode(bytecode) }),
@@ -120,17 +129,31 @@ fn start(app: &str) -> (Engine, mpsc::Receiver<MountBatch>, std::path::PathBuf)
         pinned: Default::default(),
         clipboard: None,
     };
-    let engine = Engine::start(config, || ApproxTextEngine, Sink(tx)).unwrap();
+    (config, dir)
+}
+
+/// Bundles `app` and starts it on a 320 x 500 viewport: the engine, its
+/// batches and the app's directory (the caller removes it).
+fn start(app: &str) -> (Engine, mpsc::Receiver<MountBatch>, std::path::PathBuf) {
+    let (config, dir) = config(app);
+    let (tx, rx) = mpsc::channel();
+    let control = std::sync::Arc::new(std::sync::OnceLock::new());
+    let engine = Engine::start(config, || ApproxTextEngine, Sink(tx, control.clone())).unwrap();
+    let _ = control.set(engine.control());
+    // A frame asked for before the control was there.
+    engine.control().frame();
     (engine, rx, dir)
 }
 
 fn scrolls_with_recycled_cells(app: &str) {
     let (engine, rx, dir) = start(app);
     let first = rx.recv_timeout(Duration::from_secs(5)).unwrap();
-    // 500 pt viewport + 500 pt overscan at 50 pt per row = 21 rows.
+    // The first frame shows the viewport's rows; the overscan may follow.
     let first_texts = texts(&first);
-    assert_eq!(first_texts.len(), 21, "{first_texts:?}");
     assert_eq!(first_texts[0], "row 0");
+    for i in 0..10 {
+        assert!(first_texts.contains(&format!("row {i}")), "{first_texts:?}");
+    }
     let list = first
         .items
         .iter()
@@ -142,6 +165,12 @@ fn scrolls_with_recycled_cells(app: &str) {
             _ => None,
         })
         .unwrap();
+    // 500 pt viewport + 500 pt overscan at 50 pt per row = 21 rows.
+    let mut window = first_texts;
+    while let Ok(batch) = rx.recv_timeout(Duration::from_millis(300)) {
+        window.extend(texts(&batch));
+    }
+    assert_eq!(window.len(), 21, "{window:?}");
 
     // Scroll to row 40: rows 30..=60 are needed; cells are recycled, so the
     // update re-renders text in existing views instead of creating them.
@@ -325,3 +354,313 @@ mount(App);
     assert!(shown.iter().any(|t| t == "size 320x483"), "{shown:?}");
     let _ = std::fs::remove_dir_all(&dir);
 }
+
+/// What a [`Paced`] engine's sink saw: its errors and whether a display
+/// frame was asked for.
+#[derive(Default)]
+struct Seen {
+    errors: std::sync::Mutex<Vec<String>>,
+    frame: std::sync::atomic::AtomicBool,
+}
+
+struct PacedSink(mpsc::Sender<MountBatch>, std::sync::Arc<Seen>);
+
+impl MountSink for PacedSink {
+    fn submit(&self, batch: MountBatch) {
+        let _ = self.0.send(batch);
+    }
+
+    fn report_error(&self, message: &str) {
+        self.1.errors.lock().unwrap().push(message.to_owned());
+    }
+
+    fn request_frame(&self) {
+        self.1
+            .frame
+            .store(true, std::sync::atomic::Ordering::SeqCst);
+    }
+}
+
+/// A list app on a [`nsh_engine::LocalEngine`], so a test sees every
+/// turn's batch: [`Paced::turn`] is one pump, [`Paced::frame`] the display
+/// frame the engine asked for. Keeps what each text view shows.
+struct Paced {
+    engine: nsh_engine::LocalEngine<ApproxTextEngine>,
+    rx: mpsc::Receiver<MountBatch>,
+    seen: std::sync::Arc<Seen>,
+    texts: std::collections::HashMap<nsh_core::NodeId, String>,
+    list: Option<nsh_core::NodeId>,
+    pressable: Option<nsh_core::NodeId>,
+    dir: std::path::PathBuf,
+}
+
+impl Paced {
+    fn start(app: &str) -> Self {
+        let (config, dir) = config(app);
+        let (tx, rx) = mpsc::channel();
+        let seen = std::sync::Arc::new(Seen::default());
+        let engine =
+            nsh_engine::LocalEngine::start(config, ApproxTextEngine, PacedSink(tx, seen.clone()))
+                .expect("the app starts");
+        let mut paced = Paced {
+            engine,
+            rx,
+            seen,
+            texts: Default::default(),
+            list: None,
+            pressable: None,
+            dir,
+        };
+        paced.receive();
+        paced
+    }
+
+    /// Applies the batches sent so far to the model; one `Vec` of the
+    /// texts each batch set, per batch.
+    fn receive(&mut self) -> Vec<Vec<String>> {
+        let mut batches = Vec::new();
+        while let Ok(batch) = self.rx.try_recv() {
+            let mut set = Vec::new();
+            for item in &batch.items {
+                match item {
+                    MountItem::Text { id, text, .. } => {
+                        self.texts.insert(*id, text.text.clone());
+                        set.push(text.text.clone());
+                    }
+                    MountItem::Create {
+                        id,
+                        kind: ViewKind::Scroll,
+                    } => self.list = self.list.or(Some(*id)),
+                    MountItem::Events { id, events }
+                        if events.contains(nsh_core::EventKind::Press) =>
+                    {
+                        self.pressable = Some(*id);
+                    }
+                    _ => {}
+                }
+            }
+            batches.push(set);
+        }
+        batches
+    }
+
+    /// One wake-up of the engine: what was sent is handled and committed.
+    fn turn(&mut self) -> Vec<Vec<String>> {
+        self.engine.pump();
+        self.receive()
+    }
+
+    /// The display frame the engine asked for, if it did.
+    fn frame(&mut self) -> Option<Vec<Vec<String>>> {
+        if !self
+            .seen
+            .frame
+            .swap(false, std::sync::atomic::Ordering::SeqCst)
+        {
+            return None;
+        }
+        self.engine.control().frame();
+        Some(self.turn())
+    }
+
+    /// Turns and frames until nothing changes, with `check` after each
+    /// batch.
+    fn settle(&mut self, mut check: impl FnMut(&Self, &[String])) {
+        for _ in 0..500 {
+            let mut batches = self.turn();
+            if let Some(more) = self.frame() {
+                batches.extend(more);
+            }
+            if batches.is_empty() && !self.seen.frame.load(std::sync::atomic::Ordering::SeqCst) {
+                return;
+            }
+            for batch in &batches {
+                check(self, batch);
+            }
+        }
+        panic!("the list never settled");
+    }
+
+    fn scroll_to(&self, y: f32) {
+        self.engine.control().scroll(
+            self.list.expect("a list"),
+            nsh_core::geometry::point(px(0.0), px(y)),
+            u32::MAX,
+        );
+    }
+
+    fn errors(&self) -> Vec<String> {
+        self.seen.errors.lock().unwrap().clone()
+    }
+
+    /// Rows `first..=last` are what text views show.
+    fn shows(&self, first: u32, last: u32) -> bool {
+        (first..=last).all(|i| self.texts.values().any(|t| *t == format!("row {i}")))
+    }
+}
+
+impl Drop for Paced {
+    fn drop(&mut self) {
+        let _ = std::fs::remove_dir_all(&self.dir);
+    }
+}
+
+/// Rows of objects (`row().label`), `count` of them, and a pressable view
+/// whose press runs `press` on the rows signal.
+fn rows_app(count: usize, mount_effect: &str, press: &str) -> String {
+    format!(
+        r#"
+import {{ List, mount, signal, Text, View }} from "nsh";
+const rows = signal(Array.from({{ length: {count} }}, (_, i) => ({{ label: `row ${{i}}` }})));
+{mount_effect}
+function App() {{
+  return (
+    <View style={{{{ flex: 1 }}}}>
+      <View onPress={{() => {{ {press} }}}} style={{{{ height: 0 }}}} />
+      <List each={{rows()}} estimatedItemSize={{50}} style={{{{ flex: 1 }}}}>
+        {{(row) => <View style={{{{ height: 50 }}}}><Text>{{row().label}}</Text></View>}}
+      </List>
+    </View>
+  );
+}}
+mount(App);
+"#
+    )
+}
+
+/// The data shrinking while cells of the first window wait to be bound:
+/// cells whose items are gone are never asked for (a cell made for an
+/// index past the end would read `undefined`).
+#[test]
+fn shrinking_rows_before_the_window_is_bound_binds_no_missing_item() {
+    let app = rows_app(
+        100,
+        "setTimeout(() => rows.set(rows().slice(0, 3)), 0);",
+        "",
+    );
+    let mut paced = Paced::start(&app);
+    paced.settle(|_, _| {});
+    assert_eq!(paced.errors(), Vec::<String>::new());
+    assert!(paced.shows(0, 2), "{:?}", paced.texts);
+}
+
+/// Publishing the source after a row binds must not let remaining paced
+/// requests read it before the corresponding splice reaches the core.
+#[test]
+fn source_changes_during_binding_are_applied_before_paced_reads() {
+    // The last visible row tests the fence before any paced work (the
+    // budget always allows a first cell). The first overscan row tests a
+    // source published between two paced binds while budget remains.
+    for change_at in [9, 10] {
+        let app = format!(
+            r#"
+import {{ List, mount, signal, Text, View }} from "nsh";
+const rows = signal(Array.from({{ length: 100 }}, (_, i) => ({{ label: `row ${{i}}` }})));
+function App() {{
+  return (
+    <View style={{{{ flex: 1 }}}}>
+      <View style={{{{ height: 0 }}}}><Text>{{`count ${{rows().length}}`}}</Text></View>
+      <List each={{rows()}} estimatedItemSize={{50}} style={{{{ flex: 1 }}}}>
+        {{(row, index) => {{
+          if (index() === {change_at} && rows().length > 3) rows.set(rows().slice(0, 3));
+          return <View style={{{{ height: 50 }}}}><Text>{{row().label}}</Text></View>;
+        }}}}
+      </List>
+    </View>
+  );
+}}
+mount(App);
+"#
+        );
+        let mut paced = Paced::start(&app);
+        paced.settle(|_, _| {});
+        assert!(
+            paced.texts.values().any(|text| text == "count 3"),
+            "row {change_at} did not shrink the source: {:?}",
+            paced.texts
+        );
+        assert_eq!(paced.errors(), Vec::<String>::new(), "row {change_at}");
+        assert!(paced.shows(0, 2), "row {change_at}: {:?}", paced.texts);
+    }
+}
+
+/// A row added above keeps every cell on its row in every batch: cells
+/// whose item moved learn their new index in the commit of the change.
+#[test]
+fn prepending_a_row_never_shows_a_cell_with_its_neighbours_row() {
+    let app = rows_app(100, "", "rows.update((r) => [{ label: \"new\" }, ...r]);");
+    let mut paced = Paced::start(&app);
+    paced.settle(|_, _| {});
+    assert!(paced.shows(0, 20), "{:?}", paced.texts);
+    let before = paced.texts.clone();
+    paced.engine.control().dispatch_event(
+        paced.pressable.expect("the pressable view"),
+        nsh_core::EventKind::Press,
+        nsh_core::EventPayload::None,
+    );
+    let mut wrong = Vec::new();
+    paced.settle(|paced, _| {
+        for (id, text) in &before {
+            if paced.texts.get(id) != Some(text) {
+                wrong.push((text.clone(), paced.texts.get(id).cloned()));
+            }
+        }
+    });
+    assert!(wrong.is_empty(), "cells showed another row: {wrong:?}");
+    assert!(paced.texts.values().any(|t| t == "new"));
+    assert_eq!(paced.errors(), Vec::<String>::new());
+}
+
+/// A fling that moves the window faster than cells bind: once it stops,
+/// only rows of the window it stopped at are bound (requests for windows
+/// already left are dropped, not bound late).
+#[test]
+fn a_fling_binds_no_row_of_a_window_left_behind() {
+    let app = rows_app(2000, "", "");
+    let mut paced = Paced::start(&app);
+    paced.settle(|_, _| {});
+    let mut binds = 0;
+    for step in 1..=20 {
+        paced.scroll_to(step as f32 * 1500.0);
+        binds += paced.turn().iter().map(Vec::len).sum::<usize>();
+        if let Some(batches) = paced.frame() {
+            binds += batches.iter().map(Vec::len).sum::<usize>();
+        }
+    }
+    // The last window: a viewport of overscan on either side of 30000.
+    let window = (30_000 - 500) / 50..=(30_000 + 500 + 500) / 50;
+    let mut stale = Vec::new();
+    paced.settle(|_, batch| {
+        binds += batch.len();
+        for text in batch {
+            let row: u32 = text.trim_start_matches("row ").parse().unwrap();
+            if !window.contains(&row) {
+                stale.push(row);
+            }
+        }
+    });
+    assert!(stale.is_empty(), "bound after the fling: rows {stale:?}");
+    assert!(paced.shows(600, 609), "{:?}", paced.texts);
+    eprintln!("fling: {binds} binds");
+    assert_eq!(paced.errors(), Vec::<String>::new());
+}
+
+/// A jump binds what it shows first: the batch of the jump holds every
+/// visible row; the overscan around it may follow.
+#[test]
+fn a_jump_binds_the_visible_rows_first() {
+    let app = rows_app(2000, "", "");
+    let mut paced = Paced::start(&app);
+    paced.settle(|_, _| {});
+    paced.scroll_to(5000.0);
+    let batches = paced.turn();
+    let first = batches.first().expect("a batch for the jump");
+    for i in 100..=109 {
+        assert!(
+            first.contains(&format!("row {i}")),
+            "row {i} missing from the jump's batch: {first:?}"
+        );
+    }
+    paced.settle(|_, _| {});
+    assert!(paced.shows(90, 120), "{:?}", paced.texts);
+}
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs b/third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs
index c8bb2291dc..a743ac1f6a 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs
@@ -50,6 +50,7 @@ fn bench_gallery_scroll() {
     });
     host.eval_bytecode(&bytecode).unwrap();
     let mut core = Core::new(ApproxTextEngine, Size::new(px(390.0), px(844.0)));
+    core.set_components(nsh_base::registry());
     let mut ops = Vec::new();
     let mut binds: Vec<ListBind> = Vec::new();
     let list = std::cell::Cell::new(None);
@@ -83,6 +84,10 @@ fn bench_gallery_scroll() {
                 }
             }
             core.take_list_binds(binds);
+            // The overscan the engine paces over frames, all in this one.
+            if binds.is_empty() {
+                core.take_paced_list_binds(usize::MAX, binds);
+            }
             if binds.is_empty() {
                 break;
             }
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/logs.rs b/third-party/nsh/crates/nsh-engine/tests/engine/logs.rs
index 6cfa57637d..4ede3817e9 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/logs.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/logs.rs
@@ -53,6 +53,17 @@ pub(crate) fn containing(word: &str) -> Vec<String> {
         .collect()
 }
 
+/// The lines of every level logged so far that contain `word`.
+pub(crate) fn lines_containing(word: &str) -> Vec<String> {
+    LINES
+        .lock()
+        .unwrap()
+        .iter()
+        .filter(|(_, l)| l.contains(word))
+        .map(|(_, l)| l.clone())
+        .collect()
+}
+
 /// The errors that contain `word`, once there is one (or after 5 s).
 pub(crate) fn wait_for(word: &str) -> Vec<String> {
     let deadline = Instant::now() + Duration::from_secs(5);
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/main.rs b/third-party/nsh/crates/nsh-engine/tests/engine/main.rs
index 049d4177f1..6d5436b3bb 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/main.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/main.rs
@@ -24,6 +24,7 @@ mod fetch;
 #[cfg(any(target_os = "linux", target_os = "macos"))]
 mod fs;
 mod handle_bench;
+mod host;
 mod host_modules;
 mod hot;
 mod hover;
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/theme.rs b/third-party/nsh/crates/nsh-engine/tests/engine/theme.rs
index 8ec9518f8a..9b5ea2da1b 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/theme.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/theme.rs
@@ -2,7 +2,7 @@
 //! frame, sent later it updates what read the theme, and it wins over the
 //! app's own theme until cleared.
 
-use std::time::Duration;
+use std::{collections::BTreeMap, time::Duration};
 
 use crossbeam_channel::Receiver;
 use nsh_base::{Theme, theme};
@@ -10,7 +10,7 @@ use nsh_core::{
     ApproxTextEngine, Color, MountBatch, MountItem, Size,
     ui::{Root, prelude::*},
 };
-use nsh_engine::{App, Engine, EngineConfig, Environment};
+use nsh_engine::{App, ColorScheme, Engine, EngineConfig, Environment};
 
 use crate::ChannelSink;
 
@@ -165,3 +165,107 @@ fn a_tsx_apps_token_classes_follow_the_host_theme() {
     assert_eq!(backgrounds(&rx), vec![default_primary()]);
     let _ = std::fs::remove_dir_all(&dir);
 }
+
+/// The backgrounds the views draw within a moment, by node id.
+fn backgrounds_by_id(rx: &Receiver<MountBatch>) -> BTreeMap<u32, Color> {
+    let mut seen = BTreeMap::new();
+    while let Ok(batch) = rx.recv_timeout(Duration::from_millis(300)) {
+        for item in &batch.items {
+            if let MountItem::ViewStyle { id, style } = item
+                && style.background_color.0 & 0xff != 0
+            {
+                seen.insert(*id, style.background_color);
+            }
+        }
+    }
+    seen
+}
+
+/// The SDKs' theme overrides (`nsh_base::theme_overrides`): what the host
+/// sets wins over the app's own theme (`nsh.json`), light and dark, and
+/// what it leaves out stays the app's.
+#[test]
+fn a_hosts_theme_overrides_win_over_the_apps_tokens_they_set_and_keep_the_rest() {
+    let dir = std::env::temp_dir().join(format!("nsh-theme-overrides-{}", std::process::id()));
+    std::fs::create_dir_all(&dir).unwrap();
+    std::fs::write(
+        dir.join("nsh.json"),
+        r##"{ "theme": { "cssVars": { "light": { "primary": "#0000ff", "background": "#ffff00" } } } }"##,
+    )
+    .unwrap();
+    let entry = dir.join("App.tsx");
+    std::fs::write(
+        &entry,
+        "import { mount, View } from \"nsh\";\n\
+         mount(() => (\n\
+           <View>\n\
+             <View class=\"h-10 w-10 bg-primary\" />\n\
+             <View class=\"h-10 w-10 bg-background\" />\n\
+           </View>\n\
+         ));\n",
+    )
+    .unwrap();
+    let bundle = nsh_bundler::bundle(&nsh_bundler::BundleOptions {
+        entry,
+        minify: false,
+        split: false,
+        ui_sets: Vec::new(),
+    })
+    .unwrap();
+    let bytecode = nsh_js::compile_bytecode(&bundle.code, false).unwrap();
+    let (tx, rx) = crossbeam_channel::unbounded();
+    let config = EngineConfig {
+        // SAFETY: compiled just now by this runtime.
+        app: App::Js(unsafe { nsh_engine::Bundle::bytecode(bytecode) }),
+        viewport: Size::new(px(390.0), px(844.0)),
+        trace: false,
+        network: None,
+        websocket: None,
+        filesystem: None,
+        assets: None,
+        environment: Environment::default(),
+        header_heights: (nsh_core::Points(44.0), nsh_core::Points(96.0)),
+        limits: nsh_engine::Limits::default(),
+        ui_sets: vec![nsh_base::registry()],
+        runtime: None,
+        data_dir: None,
+        storage: None,
+        clock: Default::default(),
+        context: Default::default(),
+        pinned: Default::default(),
+        clipboard: None,
+    };
+    // A brand's primary, light and dark, set before the app runs.
+    let overrides = nsh_base::theme_overrides(
+        r##"{ "cssVars": { "light": { "primary": "#e11d48" }, "dark": { "primary": "#fb7185" } } }"##,
+    )
+    .unwrap();
+    let engine = Engine::start_with(
+        config,
+        || ApproxTextEngine,
+        ChannelSink(tx),
+        |control| control.set_tokens(Some(overrides)),
+    )
+    .unwrap();
+    let colors = |seen: BTreeMap<u32, Color>| seen.into_values().collect::<Vec<_>>();
+    // The host's primary; the app's own background stays.
+    assert_eq!(
+        colors(backgrounds_by_id(&rx)),
+        vec![Color(0xe11d48ff), Color(0xffff00ff)]
+    );
+    // Dark: the override's dark value.
+    engine.control().set_environment(Environment {
+        color_scheme: ColorScheme::Dark,
+        ..Environment::default()
+    });
+    assert!(colors(backgrounds_by_id(&rx)).contains(&Color(0xfb7185ff)));
+    // Changed live, then cleared: the app's own again.
+    engine.control().set_tokens(Some(
+        nsh_base::theme_overrides(r##"{ "cssVars": { "theme": { "primary": "#00ff00" } } }"##)
+            .unwrap(),
+    ));
+    assert_eq!(colors(backgrounds_by_id(&rx)), vec![Color(0x00ff00ff)]);
+    engine.control().set_tokens(None);
+    assert!(colors(backgrounds_by_id(&rx)).contains(&Color(0x0000ffff)));
+    let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/third-party/nsh/crates/nsh-js/src/host.rs b/third-party/nsh/crates/nsh-js/src/host.rs
index 996a39423c..cf8776c789 100644
--- a/third-party/nsh/crates/nsh-js/src/host.rs
+++ b/third-party/nsh/crates/nsh-js/src/host.rs
@@ -1188,6 +1188,7 @@ fn payload_object<'js>(cx: &Ctx<'js>, payload: &EventPayload) -> rquickjs::Resul
             obj.set("name", &**name)?;
             obj.set("data", json_value(cx, data)?)?;
         }
+        EventPayload::Json(value) => return json_value(cx, value),
         EventPayload::TextLayout(lines) => {
             let list = rquickjs::Array::new(cx.clone())?;
             for (i, l) in lines.iter().enumerate() {
diff --git a/third-party/nsh/crates/nsh-js/src/keymap.rs b/third-party/nsh/crates/nsh-js/src/keymap.rs
index 1b9d75d8b2..9b1437d5b8 100644
--- a/third-party/nsh/crates/nsh-js/src/keymap.rs
+++ b/third-party/nsh/crates/nsh-js/src/keymap.rs
@@ -341,8 +341,8 @@ pub(crate) const TYPES: &str = r#"export interface KeyBindingOptions {
   within?: YNode;
 }
 
-/** The app's keyboard shortcuts (desktop and terminal backends).
- * A keystroke is modifiers and a key joined by
+/** The app's keyboard shortcuts: on desktop and terminal backends, the web,
+ * and iOS and Android with a hardware keyboard. A keystroke is modifiers and a key joined by
  * `+`, in any case (lowercase is the recommended style; a capital letter is
  * the same key, not Shift): `"mod+k"`, `"ctrl+shift+p"`, `"alt+enter"`, `"escape"`,
  * `"f5"`, `"mod+plus"`. Modifiers: `mod` (`cmdorctrl`): Command on Apple
diff --git a/third-party/nsh/crates/nsh-js/src/modules.rs b/third-party/nsh/crates/nsh-js/src/modules.rs
index 716d681081..628b893869 100644
--- a/third-party/nsh/crates/nsh-js/src/modules.rs
+++ b/third-party/nsh/crates/nsh-js/src/modules.rs
@@ -453,6 +453,13 @@ pub(crate) const MODULES: &[NativeModule] = &[
         moved_to: Some("nsh"),
         types: "",
     },
+    NativeModule {
+        name: "nsh/host",
+        exports: platform_modules::HOST_EXPORTS,
+        also: &[],
+        moved_to: None,
+        types: decl!(platform_modules::HOST_TYPES),
+    },
     NativeModule {
         name: "nsh/keymap",
         exports: keymap::EXPORTS,
diff --git a/third-party/nsh/crates/nsh-js/src/platform_modules.rs b/third-party/nsh/crates/nsh-js/src/platform_modules.rs
index bf28f7ea9f..3511c2d8e2 100644
--- a/third-party/nsh/crates/nsh-js/src/platform_modules.rs
+++ b/third-party/nsh/crates/nsh-js/src/platform_modules.rs
@@ -2,10 +2,11 @@
 //! (`isAvailable`, `BackHandler.onBackPress` over the registry's module),
 //! `nsh/ios` (`isAvailable`, `InputAccessoryView`), `nsh/webview` and
 //! `nsh/video` (their host elements as components, which the compiler
-//! makes in place for their tags), and `nsh/native` (`nativeComponent`: the
+//! makes in place for their tags), `nsh/native` (`nativeComponent`: the
 //! JS face of components the embedding app registers natively;
 //! `nativeModule`: the exports of a UI set it registers, reached by name at
-//! run time).
+//! run time) and `nsh/host` (`available()` and the host's events over the
+//! registry's `call`).
 
 use std::{
     cell::RefCell,
@@ -33,6 +34,8 @@ pub(crate) struct PlatformModules {
     /// `BackHandler.onBackPress` listeners, in order (id, owned
     /// function).
     back: RefCell<Vec<(u32, JsValue)>>,
+    /// `host.on` listeners, in order (id, event, owned function).
+    host_listeners: RefCell<Vec<(u32, String, JsValue)>>,
     next: std::cell::Cell<u32>,
 }
 
@@ -43,6 +46,10 @@ impl PlatformModules {
             // SAFETY: owned values; no JS runs from here on.
             unsafe { qjs::JS_FreeValue(raw, f) };
         }
+        for (_, _, f) in self.host_listeners.borrow_mut().drain(..) {
+            // SAFETY: as above.
+            unsafe { qjs::JS_FreeValue(raw, f) };
+        }
     }
 }
 
@@ -259,6 +266,208 @@ export interface BackListeners {
 }
 "#;
 
+// --- nsh/host ---------------------------------------------------------------------------
+
+/// `host`: the registry's module (`call`) with `available()` and the
+/// listeners of the host's events.
+fn host_module(ctx: *mut JsContext) -> JsValue {
+    let host = crate::components::runtime_module(ctx, "nsh/host", "host")
+        // SAFETY: a new object when the runtime has none.
+        .unwrap_or_else(|| unsafe { qjs::JS_NewObject(ctx) });
+    let natives: [(&CStr, reactive::Native, c_int); 2] = [
+        (c"available", host_available, 0),
+        (c"on", add_host_listener, 2),
+    ];
+    for (name, f, length) in natives {
+        let func = reactive::new_function(ctx, name, f, length);
+        // SAFETY: the object takes the function.
+        unsafe { qjs::JS_SetPropertyStr(ctx, host, name.as_ptr(), func) };
+    }
+    host
+}
+
+/// `host.available()`: whether the platform embeds a host that answers
+/// calls (read when called: the environment may change).
+unsafe extern "C" fn host_available(
+    ctx: *mut JsContext,
+    _this: JsValue,
+    _argc: c_int,
+    _argv: *mut JsValue,
+) -> JsValue {
+    boolean(crate::env::current(ctx).host)
+}
+
+/// `host.on(event, listener)`: `{ remove() }`.
+unsafe extern "C" fn add_host_listener(
+    ctx: *mut JsContext,
+    _this: JsValue,
+    argc: c_int,
+    argv: *mut JsValue,
+) -> JsValue {
+    let Some(event) = dom::string_arg(ctx, arg(argc, argv, 0)) else {
+        // SAFETY: throws on a live context.
+        return unsafe { qjs::JS_ThrowTypeError(ctx, c"host.on: an event name".as_ptr()) };
+    };
+    let f = arg(argc, argv, 1);
+    // SAFETY: an inline check.
+    if !unsafe { qjs::JS_IsFunction(ctx, f) } {
+        return unsafe { qjs::JS_ThrowTypeError(ctx, c"host.on: a function".as_ptr()) };
+    }
+    let m = modules(ctx);
+    let id = m.next.get() + 1;
+    m.next.set(id);
+    // SAFETY: a dup the listeners own.
+    let first = {
+        let mut host = m.host_listeners.borrow_mut();
+        host.push((id, event, unsafe { qjs::JS_DupValue(ctx, f) }));
+        host.len() == 1
+    };
+    if first {
+        let on = reactive::new_function(ctx, c"onHost", on_host_event, 1);
+        let r = dom::set_handler(ctx, 1, EventKind::Host, on);
+        // SAFETY: ours.
+        unsafe { qjs::JS_FreeValue(ctx, on) };
+        if let Err(e) = r {
+            return e;
+        }
+    }
+    let remove = native(
+        ctx,
+        remove_host_listener,
+        0,
+        &mut [qjs::JS_NewFloat64(id as f64)],
+    );
+    // SAFETY: a new object taking the function.
+    unsafe {
+        let o = qjs::JS_NewObject(ctx);
+        qjs::JS_SetPropertyStr(ctx, o, c"remove".as_ptr(), remove);
+        o
+    }
+}
+
+unsafe extern "C" fn remove_host_listener(
+    ctx: *mut JsContext,
+    _this: JsValue,
+    _argc: c_int,
+    _argv: *mut JsValue,
+    _magic: c_int,
+    data: *mut JsValue,
+) -> JsValue {
+    let mut id = 0f64;
+    // SAFETY: one data value, the listener's id.
+    unsafe { qjs::JS_ToFloat64(ctx, &mut id, *data) };
+    let (gone, empty) = {
+        let mut host = modules(ctx).host_listeners.borrow_mut();
+        let i = host.iter().position(|(i, ..)| *i == id as u32);
+        (i.map(|i| host.remove(i).2), host.is_empty())
+    };
+    if let Some(f) = gone {
+        // SAFETY: owned.
+        unsafe { qjs::JS_FreeValue(ctx, f) };
+        if empty {
+            let _ = dom::set_handler(ctx, 1, EventKind::Host, UNDEFINED);
+        }
+    }
+    UNDEFINED
+}
+
+/// The root's `Host` events of `type: "event"` (`{ event, payload }`; the
+/// core takes the replies): the listeners of `event`, in the order they
+/// were added, each with `payload`. A listener that throws leaves the next
+/// ones called; the first error is thrown once they all ran.
+unsafe extern "C" fn on_host_event(
+    ctx: *mut JsContext,
+    _this: JsValue,
+    argc: c_int,
+    argv: *mut JsValue,
+) -> JsValue {
+    let e = arg(argc, argv, 0);
+    let string = |key: &std::ffi::CStr| {
+        // SAFETY: a read of the handler's argument; the result is freed.
+        unsafe {
+            let v = qjs::JS_GetPropertyStr(ctx, e, key.as_ptr());
+            let s = qjs::JS_IsString(v)
+                .then(|| dom::string_arg(ctx, v))
+                .flatten();
+            qjs::JS_FreeValue(ctx, v);
+            s
+        }
+    };
+    if string(c"type").as_deref() != Some("event") {
+        return UNDEFINED;
+    }
+    let event = string(c"event");
+    // SAFETY: an owned result, freed below.
+    let payload = unsafe { qjs::JS_GetPropertyStr(ctx, e, c"payload".as_ptr()) };
+    let listeners: Vec<JsValue> = match &event {
+        Some(event) => modules(ctx)
+            .host_listeners
+            .borrow()
+            .iter()
+            .filter(|(_, e, _)| e == event)
+            // SAFETY: dups for the calls.
+            .map(|&(_, _, f)| unsafe { qjs::JS_DupValue(ctx, f) })
+            .collect(),
+        None => Vec::new(),
+    };
+    let mut first = None;
+    let mut fatal = false;
+    for f in listeners {
+        if !fatal {
+            let mut args = [payload];
+            // SAFETY: a call of an owned function with a borrowed argument,
+            // then its result.
+            unsafe {
+                let r = qjs::JS_Call(ctx, f, UNDEFINED, 1, args.as_mut_ptr());
+                if qjs::JS_IsException(r) {
+                    let error = crate::web::js::take_exception(ctx);
+                    // An interrupt stops them all.
+                    if qjs::JS_IsUncatchableError(error.raw()) {
+                        first = Some(error);
+                        fatal = true;
+                    } else {
+                        first.get_or_insert(error);
+                    }
+                } else {
+                    qjs::JS_FreeValue(ctx, r);
+                }
+            }
+        }
+        // SAFETY: ours.
+        unsafe { qjs::JS_FreeValue(ctx, f) };
+    }
+    // SAFETY: ours.
+    unsafe { qjs::JS_FreeValue(ctx, payload) };
+    match first {
+        Some(error) => {
+            let _ = crate::web::js::throw(ctx, error);
+            qjs::JS_EXCEPTION
+        }
+        None => UNDEFINED,
+    }
+}
+
+/// The hand-written exports of `nsh/host` (with the registry's).
+pub(crate) const HOST_EXPORTS: &[Item] = &[(
+    "host",
+    Export::Value(host_module),
+    Ts::value(
+        "The app embedding NSH: `host.call(name, args)` calls one of its functions\n(granted by `capabilities.host` in nsh.json), `host.on(event, f)` hears its\nevents, `host.available()` says whether the platform embeds one.",
+        "hostModule & HostEvents",
+    ),
+)];
+
+/// `nsh/host`'s types (with the runtime registry's module).
+pub(crate) const HOST_TYPES: &str = r#"/** The embedding host's events and whether there is one. */
+export interface HostEvents {
+  /** Whether the platform embeds a host that answers calls; without one,
+   * `call` rejects at once. */
+  available(): boolean;
+  /** Calls `listener` with each `event` the host sends, until `remove()`. */
+  on(event: string, listener: (payload: unknown) => void): { remove(): void };
+}
+"#;
+
 /// `nsh/ios`.
 pub(crate) const IOS_EXPORTS: &[Item] = &[
     (
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java
index 9f71fddc75..a76bf86e55 100644
--- a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java
@@ -72,6 +72,7 @@ final class Native {
     static final int POSITION = 61;
     static final int MOUSE_MOVE = 62;
     static final int SCROLL_INTENT = 63;
+    static final int HOST = 64;
 
     /**
      * Draws the SVG {@code bytes} for a view {@code maxPx} pixels on its longer side, with its shorter side that long
@@ -99,6 +100,12 @@ final class Native {
     /** The directory the storage of the apps loaded next is kept in (null: each app's own in the data directory). */
     static native void setStorage(long handle, String dir);
 
+    /**
+     * The embedding app's theme overrides (a shadcn/ui theme file, null: the
+     * app's own theme). Returns why the file does not parse, or null.
+     */
+    static native String setThemeOverrides(long handle, String json);
+
     /** Whether the running app's web views may navigate to {@code url}. */
     static native boolean allowsWebview(long handle, String url);
 
@@ -161,6 +168,16 @@ final class Native {
 
     static native void event(long handle, int id, int kind, String text, int count, float x, float y);
 
+    /**
+     * A hardware key went down: whether the app's shortcuts ({@code nsh/keymap}) take it.
+     * {@code unicode}: what it types without modifiers ({@code KeyEvent.getUnicodeChar(0)});
+     * {@code repeat}: an auto-repeat; {@code editing}: a text input or hosted view has the focus.
+     */
+    static native boolean keyDown(long handle, int keyCode, int unicode, int meta, boolean repeat, boolean editing);
+
+    /** A hardware key went up: whether its press was taken (then so is its release). */
+    static native boolean keyUp(long handle, int keyCode);
+
     /**
      * {@code onHoverMove} of view {@code id}: the pointer at ({@code x}, {@code y}) in it and at
      * ({@code pageX}, {@code pageY}) in its root view (dp); {@code modifiers}: 1 Ctrl, 2 Alt,
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java
index ef58cb4c64..ca19f1a64c 100644
--- a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java
@@ -62,6 +62,45 @@ public class NshView extends FrameLayout {
         // no view takes it.
         return super.dispatchTouchEvent(e) || tracking;
     }
+
+    /**
+     * A hardware keyboard's keys go to the app's shortcuts ({@code nsh/keymap}) before the focused view
+     * gets them: a key a binding takes is consumed, with its release (the input never sees it).
+     */
+    @Override
+    public boolean dispatchKeyEvent(android.view.KeyEvent e) {
+        if (handle != 0) {
+            int action = e.getAction();
+            if (action == android.view.KeyEvent.ACTION_DOWN && keymapKey(e)) {
+                return true;
+            } else if (action == android.view.KeyEvent.ACTION_UP && Native.keyUp(handle, e.getKeyCode())) {
+                return true;
+            }
+        }
+        return super.dispatchKeyEvent(e);
+    }
+
+    /**
+     * Keys with Control come as shortcuts first (the window's input stage offers them before the key
+     * event, and a text field selects all, copies or pastes there): the app's shortcuts take them first.
+     */
+    @Override
+    public boolean dispatchKeyShortcutEvent(android.view.KeyEvent e) {
+        if (handle != 0 && e.getAction() == android.view.KeyEvent.ACTION_DOWN && keymapKey(e)) {
+            return true;
+        }
+        return super.dispatchKeyShortcutEvent(e);
+    }
+
+    /** Whether the app's shortcuts take key press {@code e}. */
+    private boolean keymapKey(android.view.KeyEvent e) {
+        // An input or hosted view (a web view, a native component) has the focus: in text.
+        View focus = findFocus();
+        boolean editing = focus != null && focus != this;
+        return Native.keyDown(handle, e.getKeyCode(), e.getUnicodeChar(0), e.getMetaState(),
+                e.getRepeatCount() > 0, editing);
+    }
+
     private final Handler main = new Handler(Looper.getMainLooper());
     private ErrorListener errorListener;
     /** Where `asset:/…` files come from: an http base, a directory, or APK assets. */
@@ -174,6 +213,28 @@ public class NshView extends FrameLayout {
         Native.setStorage(handle, dir == null ? null : dir.getPath());
     }
 
+    /**
+     * Overrides the app's theme with the host's: a theme file of shadcn/ui's
+     * ({@code { "cssVars": { "theme": {…}, "light": {…}, "dark": {…} } }},
+     * values as CSS writes them, the format of {@code nsh.json}'s
+     * {@code "theme"}). Its tokens win over the app's of the same names, in
+     * light and dark mode; the app's others stay, so a branded host sets only
+     * what its brand changes ({@code primary}, {@code radius}, ...). Call it
+     * before or after loading: it applies at once (classes such as
+     * {@code bg-primary} are resolved again) and to every app loaded later,
+     * from its first frame. Main thread.
+     *
+     * @param json the theme file, or null to give the app its own theme back
+     * @throws IllegalArgumentException when the file does not parse; the
+     *     overrides then stay as they were
+     */
+    public void setThemeOverrides(String json) {
+        String error = Native.setThemeOverrides(handle, json);
+        if (error != null) {
+            throw new IllegalArgumentException(error);
+        }
+    }
+
     /** The contents of {@code file}, or null when it does not exist. */
     private static String readText(File file) throws java.io.IOException {
         return file.exists() ? new String(Net.readAll(new FileInputStream(file)), StandardCharsets.UTF_8) : null;
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java
index 84e24372d2..b0f7973d19 100644
--- a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java
@@ -187,6 +187,25 @@ final class TestAgent {
         });
     }
 
+    /**
+     * Presses the key `code` (`KeyEvent.KEYCODE_*`) with `meta` (`KeyEvent.META_*`) on a hardware
+     * keyboard: the window's view hierarchy gets it from its root, as the window's input stage
+     * (ViewRootImpl) hands it over after the input method: with Control as a shortcut first.
+     */
+    static void pressKeys(NshView host, int code, int meta) {
+        onMain(() -> {
+            View window = target(host);
+            long now = SystemClock.uptimeMillis();
+            KeyEvent down = new KeyEvent(now, now, KeyEvent.ACTION_DOWN, code, 0, meta,
+                    KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, android.view.InputDevice.SOURCE_KEYBOARD);
+            if ((meta & KeyEvent.META_CTRL_ON) == 0 || !window.dispatchKeyShortcutEvent(down)) {
+                window.dispatchKeyEvent(down);
+            }
+            window.dispatchKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP));
+            return null;
+        });
+    }
+
     /** The text input `id` holds; null when it is no input. */
     static String inputText(NshView host, int id) {
         return onMain(() -> {
diff --git a/third-party/nsh/crates/nsh-platform-android/src/agent.rs b/third-party/nsh/crates/nsh-platform-android/src/agent.rs
index 425e7dc768..e9619093fe 100644
--- a/third-party/nsh/crates/nsh-platform-android/src/agent.rs
+++ b/third-party/nsh/crates/nsh-platform-android/src/agent.rs
@@ -279,6 +279,40 @@ impl Native for AndroidNative {
         });
     }
 
+    fn press_keys(&self, keys: &str) -> Result<(), String> {
+        use nsh_core::keymap::Keystroke;
+        let stroke =
+            Keystroke::parse(keys, nsh_core::platform::Os::Android).map_err(|e| e.to_string())?;
+        let code = stroke
+            .key
+            .android_keycode()
+            .ok_or_else(|| format!("an Android keyboard has no key {keys:?}"))?;
+        // `KeyEvent.META_*_ON`.
+        let m = stroke.modifiers;
+        let meta = [
+            (m.shift, 0x1),
+            (m.alt, 0x2),
+            (m.ctrl, META_CTRL_ON),
+            (m.cmd, 0x10000),
+        ]
+        .into_iter()
+        .filter(|(on, _)| *on)
+        .fold(0, |meta, (_, bit)| meta | bit);
+        if self.host.upgrade().is_none() {
+            return Err("the app is gone".into());
+        }
+        self.call(|env, class, view| {
+            env.call_static_method(
+                class,
+                "pressKeys",
+                "(Lcom/longbridge/nsh/NshView;II)V",
+                &[JValue::Object(view), JValue::Int(code), JValue::Int(meta)],
+            )
+            .map(drop)
+        });
+        Ok(())
+    }
+
     fn input_text(&self, id: NodeId) -> Option<String> {
         self.call(|env, class, view| {
             let s = env
diff --git a/third-party/nsh/crates/nsh-platform-android/src/encode.rs b/third-party/nsh/crates/nsh-platform-android/src/encode.rs
index e13c3f11cc..04c71796d1 100644
--- a/third-party/nsh/crates/nsh-platform-android/src/encode.rs
+++ b/third-party/nsh/crates/nsh-platform-android/src/encode.rs
@@ -80,6 +80,9 @@ pub(crate) struct Encoded {
     pub(crate) objects: Vec<Obj>,
     /// Items for the UI-thread animator, in order.
     pub(crate) animation: Vec<MountItem>,
+    /// The app's keyboard shortcuts, when the batch changed them: matched
+    /// natively (`crate::keymap`), never sent to Java.
+    pub(crate) keymap: Option<std::sync::Arc<nsh_core::keymap::Keymap>>,
 }
 
 impl Encoded {
@@ -306,8 +309,7 @@ pub(crate) fn encode(batch: MountBatch) -> Encoded {
                 e.int(LAYOUT_DIRECTION);
                 e.bool(rtl);
             }
-            // Keyboard shortcuts are not matched on Android yet.
-            MountItem::Keymap(_) => {}
+            MountItem::Keymap(keymap) => e.keymap = Some(keymap),
             MountItem::Appearance { appearance } => {
                 e.int(APPEARANCE);
                 e.int(appearance as i32);
@@ -604,7 +606,10 @@ pub(crate) fn encode(batch: MountBatch) -> Encoded {
                     | Command::Measure { .. }
                     | Command::ScrollToEnd { .. }
                     // Desktops only.
-                    | Command::Desktop(_) => e.int(-1),
+                    | Command::Desktop(_)
+                    // No host answers `nsh/host` here (`Environment::host`):
+                    // the core rejects the calls first.
+                    | Command::HostCall { .. } => e.int(-1),
                 }
             }
         }
diff --git a/third-party/nsh/crates/nsh-platform-android/src/host.rs b/third-party/nsh/crates/nsh-platform-android/src/host.rs
index 0d8821f422..38287e38b1 100644
--- a/third-party/nsh/crates/nsh-platform-android/src/host.rs
+++ b/third-party/nsh/crates/nsh-platform-android/src/host.rs
@@ -65,9 +65,13 @@ pub(crate) struct Host {
     storage: Mutex<Option<std::path::PathBuf>>,
     /// What the running app may reach (web view navigations check it).
     access: Mutex<Arc<nsh_engine::Access>>,
+    /// The embedding app's theme overrides (`NshView.setThemeOverrides`).
+    host_tokens: Mutex<Option<nsh_engine::Tokens>>,
     /// The bytes of the in-memory images (`data:` URIs, blobs) the app
     /// shows, by URI, for `Images.java` (`Native.imageBytes`).
     inline_images: Mutex<nsh_core::image::InlineImages>,
+    /// The app's keyboard shortcuts, matched on the UI thread.
+    pub(crate) keymap: Mutex<crate::keymap::KeymapState>,
     #[cfg(feature = "dev")]
     pub(crate) dev: Mutex<Option<crate::dev::DevSession>>,
     /// A test session's model of the app (`nsh test --target android`):
@@ -93,11 +97,28 @@ impl Host {
         self.engine.lock().ok()?.as_ref().map(Engine::control)
     }
 
+    /// Hands event `kind` of node `id` to the running app.
+    pub(crate) fn dispatch(&self, id: NodeId, kind: EventKind, payload: EventPayload) {
+        if let Some(control) = self.control() {
+            control.dispatch_event(id, kind, payload);
+        }
+    }
+
     /// The VM limits of the apps loaded from now on.
     pub(crate) fn set_limits(&self, limits: nsh_engine::Limits) {
         *self.limits.lock().expect("limits") = limits;
     }
 
+    /// The embedding app's theme overrides (`None`: the app's own theme):
+    /// sent to the running app at once, and to every app loaded later
+    /// before its first frame.
+    fn set_host_tokens(&self, tokens: Option<nsh_engine::Tokens>) {
+        *self.host_tokens.lock().expect("tokens") = tokens.clone();
+        if let Some(control) = self.control() {
+            control.set_tokens(tokens);
+        }
+    }
+
     /// Runs `app`, replacing the running app.
     pub(crate) fn load(self: &Arc<Self>, app: impl Into<App>) {
         let mut app = app.into();
@@ -127,6 +148,10 @@ impl Host {
             self.app_stopped();
         }
         *self.animation_queue.lock().expect("queue") = VecDeque::new();
+        self.keymap
+            .lock()
+            .unwrap_or_else(|e| e.into_inner())
+            .clear();
         *self.ui.lock().expect("ui") = UiAnimation::default();
         #[allow(unused_mut)]
         let mut config = EngineConfig {
@@ -159,7 +184,14 @@ impl Host {
         if let Some(model) = &*self.test_model.lock().unwrap_or_else(|e| e.into_inner()) {
             model.set_up_engine(&mut config);
         }
-        match Engine::start(config, move || AndroidTextEngine { density }, sink) {
+        // The embedding app's theme overrides are in the first frame.
+        let tokens = self.host_tokens.lock().expect("tokens").clone();
+        let setup = |control: &EngineControl| {
+            if tokens.is_some() {
+                control.set_tokens(tokens);
+            }
+        };
+        match Engine::start_with(config, move || AndroidTextEngine { density }, sink, setup) {
             Ok(engine) => {
                 // The test moves its clock through the engine.
                 #[cfg(feature = "dev")]
@@ -335,7 +367,13 @@ impl MountSink for Sink {
         }
         answer_clipboard_reads(host, &batch);
         register_inline_images(host, &batch);
-        let encoded = encode::encode(batch);
+        let mut encoded = encode::encode(batch);
+        if let Some(keymap) = encoded.keymap.take() {
+            host.keymap
+                .lock()
+                .unwrap_or_else(|e| e.into_inner())
+                .set(keymap);
+        }
         let seq = host.seq.fetch_add(1, Ordering::Relaxed) + 1;
         if !encoded.animation.is_empty() {
             host.animation_queue
@@ -485,7 +523,9 @@ extern "system" fn Java_com_longbridge_nsh_Native_create(
         data_dir,
         storage: Mutex::new(None),
         access: Mutex::new(Arc::default()),
+        host_tokens: Mutex::new(None),
         inline_images: Mutex::default(),
+        keymap: Mutex::default(),
         #[cfg(feature = "dev")]
         dev: Mutex::new(None),
         #[cfg(feature = "dev")]
@@ -587,6 +627,41 @@ extern "system" fn Java_com_longbridge_nsh_Native_setStorage(
     *host.storage.lock().expect("storage") = dir;
 }
 
+/// The embedding app's theme overrides: a theme file of shadcn/ui's
+/// (`cssVars`, as `nsh.json`'s `"theme"`), whose tokens win over the app's
+/// of the same names (null: the app's own theme again). Applies at once and
+/// to every app loaded later. Returns why the file does not parse (nothing
+/// changes), or null.
+#[unsafe(no_mangle)]
+extern "system" fn Java_com_longbridge_nsh_Native_setThemeOverrides<'local>(
+    mut env: JNIEnv<'local>,
+    _: JClass<'local>,
+    handle: jlong,
+    json: JString<'local>,
+) -> JString<'local> {
+    // SAFETY: Java passes its live handle.
+    let host = unsafe { host(handle) };
+    let tokens = if json.is_null() {
+        Ok(None)
+    } else {
+        match env.get_string(&json) {
+            Ok(json) => nsh_base::theme_overrides(&String::from(json))
+                .map(Some)
+                .map_err(|err| format!("invalid theme: {err}")),
+            Err(err) => Err(format!("invalid theme: {err}")),
+        }
+    };
+    match tokens {
+        Ok(tokens) => {
+            host.set_host_tokens(tokens);
+            JString::from(JObject::null())
+        }
+        Err(err) => env
+            .new_string(err)
+            .unwrap_or_else(|_| JString::from(JObject::null())),
+    }
+}
+
 /// Whether the running app's web views may navigate to `url`.
 #[unsafe(no_mangle)]
 extern "system" fn Java_com_longbridge_nsh_Native_allowsWebview(
diff --git a/third-party/nsh/crates/nsh-platform-android/src/keymap.rs b/third-party/nsh/crates/nsh-platform-android/src/keymap.rs
new file mode 100644
index 0000000000..1b06dd0ffc
--- /dev/null
+++ b/third-party/nsh/crates/nsh-platform-android/src/keymap.rs
@@ -0,0 +1,161 @@
+//! The app's keyboard shortcuts (`nsh/keymap`) on a hardware keyboard (a
+//! tablet's, a Chromebook's, an emulator's): the table the engine mounts
+//! ([`MountItem::Keymap`](nsh_core::MountItem::Keymap)), kept here rather
+//! than sent to Java, and matched against every key on the spot.
+//!
+//! `NshView.dispatchKeyEvent` asks before its focused descendant gets a
+//! key (`Native.keyDown` / `Native.keyUp`, on the UI thread): a key that
+//! fires a binding is consumed (the focused input never sees it: no caret
+//! move, no line break, no typed character) and reported to JS with the
+//! root's `KeyCommand` event; JS runs the handler afterwards. Its release
+//! is consumed too. The input method sees keys before the view hierarchy
+//! does, so a composition keeps its keys. `NshView` holds the focus when
+//! no input does, so keys reach it wherever the focus is in it; a text
+//! input or hosted view (a web view, a native component) having the focus
+//! is "in text". Android repeats a held key's events itself (their repeat
+//! count): a binding fires on them only when it asks to (`repeat`).
+//!
+//! Keys are named by their key code for the keys that type nothing, and
+//! by the character they type without modifiers on the device's layout
+//! otherwise, the US layout's of the same key when that is no ASCII, as on
+//! the desktops. `mod` is Control.
+
+use std::sync::Arc;
+
+use jni::{
+    JNIEnv,
+    objects::JClass,
+    sys::{JNI_FALSE, JNI_TRUE, jboolean, jint, jlong},
+};
+use nsh_core::{
+    EventKind, EventPayload, PayloadValue, ROOT_ID,
+    keymap::{Key, KeyMatch, Keymap, Keystroke, Modifiers},
+};
+
+/// `KeyEvent.META_*_ON`.
+const META_SHIFT_ON: i32 = 0x1;
+const META_ALT_ON: i32 = 0x2;
+const META_CTRL_ON: i32 = 0x1000;
+const META_META_ON: i32 = 0x10000;
+
+/// A view's table, and the keys it consumed that are still down.
+#[derive(Default)]
+pub(crate) struct KeymapState {
+    table: Option<Arc<Keymap>>,
+    /// Key codes whose press the table consumed.
+    held: Vec<i32>,
+}
+
+impl KeymapState {
+    /// `MountItem::Keymap`: replaces the table.
+    pub(crate) fn set(&mut self, keymap: Arc<Keymap>) {
+        self.table = (!keymap.is_empty()).then_some(keymap);
+    }
+
+    /// A new app: no bindings yet.
+    pub(crate) fn clear(&mut self) {
+        *self = KeymapState::default();
+    }
+}
+
+/// The keystroke of a key event: key code `code`, `unicode` the character
+/// it types without modifiers (`KeyEvent.getUnicodeChar(0)`, 0 for none),
+/// `meta` its meta state.
+pub(crate) fn keystroke(code: i32, unicode: i32, meta: i32) -> Option<Keystroke> {
+    let modifiers = Modifiers {
+        ctrl: meta & META_CTRL_ON != 0,
+        alt: meta & META_ALT_ON != 0,
+        shift: meta & META_SHIFT_ON != 0,
+        cmd: meta & META_META_ON != 0,
+    };
+    let key = match Key::from_android_keycode(code)? {
+        us @ Key::Char(c) if c != ' ' => match char::from_u32(unicode as u32) {
+            Some(c) if c.is_ascii_graphic() => Key::Char(c.to_ascii_lowercase()),
+            _ => us,
+        },
+        named => named,
+    };
+    Some(Keystroke { modifiers, key })
+}
+
+/// A key went down (`repeat`: an auto-repeat of a held key): whether a
+/// binding takes it, firing it.
+#[unsafe(no_mangle)]
+extern "system" fn Java_com_longbridge_nsh_Native_keyDown(
+    _: JNIEnv<'_>,
+    _: JClass<'_>,
+    handle: jlong,
+    code: jint,
+    unicode: jint,
+    meta: jint,
+    repeat: jboolean,
+    editing: jboolean,
+) -> jboolean {
+    // SAFETY: Java passes its live handle.
+    let host = unsafe { crate::host::host(handle) };
+    let Some(stroke) = keystroke(code, unicode, meta) else {
+        return JNI_FALSE;
+    };
+    let found = {
+        let mut state = host.keymap.lock().unwrap_or_else(|e| e.into_inner());
+        let Some(table) = state.table.clone() else {
+            return JNI_FALSE;
+        };
+        let found = table.find(stroke, editing != 0, repeat != 0);
+        if found.consumed() && !state.held.contains(&code) {
+            state.held.push(code);
+        }
+        found
+    };
+    if let KeyMatch::Fire(id) = found {
+        host.dispatch(
+            ROOT_ID,
+            EventKind::KeyCommand,
+            EventPayload::Value(PayloadValue::Number(f64::from(id))),
+        );
+    }
+    if found.consumed() {
+        JNI_TRUE
+    } else {
+        JNI_FALSE
+    }
+}
+
+/// A key went up: whether its press was consumed (then so is this).
+#[unsafe(no_mangle)]
+extern "system" fn Java_com_longbridge_nsh_Native_keyUp(
+    _: JNIEnv<'_>,
+    _: JClass<'_>,
+    handle: jlong,
+    code: jint,
+) -> jboolean {
+    // SAFETY: Java passes its live handle.
+    let host = unsafe { crate::host::host(handle) };
+    let mut state = host.keymap.lock().unwrap_or_else(|e| e.into_inner());
+    match state.held.iter().position(|held| *held == code) {
+        Some(i) => {
+            state.held.remove(i);
+            JNI_TRUE
+        }
+        None => JNI_FALSE,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn names_keys_by_what_they_type() {
+        let parse = |t| Keystroke::parse(t, nsh_core::platform::Os::Android).ok();
+        assert_eq!(keystroke(20, 0, 0), parse("down"));
+        assert_eq!(keystroke(66, 0, 0), parse("enter"));
+        assert_eq!(keystroke(39, 'k' as i32, META_CTRL_ON), parse("mod+k"));
+        assert_eq!(keystroke(76, '/' as i32, META_SHIFT_ON), parse("shift+/"));
+        // A layout typing no ASCII there: the US layout's key.
+        assert_eq!(keystroke(39, 'л' as i32, 0), parse("k"));
+        // A layout typing another letter there (AZERTY's A on Q's key).
+        assert_eq!(keystroke(45, 'a' as i32, 0), parse("a"));
+        assert_eq!(keystroke(59, 0, META_SHIFT_ON), None);
+    }
+}
diff --git a/third-party/nsh/crates/nsh-platform-android/src/lib.rs b/third-party/nsh/crates/nsh-platform-android/src/lib.rs
index 3af9c8332f..ac91352232 100644
--- a/third-party/nsh/crates/nsh-platform-android/src/lib.rs
+++ b/third-party/nsh/crates/nsh-platform-android/src/lib.rs
@@ -25,6 +25,7 @@ mod dev;
 mod encode;
 mod host;
 mod java;
+mod keymap;
 mod logger;
 mod net;
 #[cfg(feature = "js")]
diff --git a/third-party/nsh/crates/nsh-platform-ios/include/nsh.h b/third-party/nsh/crates/nsh-platform-ios/include/nsh.h
index c50f288419..05481a96dc 100644
--- a/third-party/nsh/crates/nsh-platform-ios/include/nsh.h
+++ b/third-party/nsh/crates/nsh-platform-ios/include/nsh.h
@@ -68,6 +68,14 @@ char *_Nullable nsh_app_set_locale(NshApp *app, const char *_Nullable tag);
 /// for Dynamic Type's; 0 follows the system's again.
 void nsh_app_set_font_scale(NshApp *app, float scale);
 
+/// The embedding app's theme overrides: a theme file of shadcn/ui's
+/// (`{ "cssVars": { "theme": {...}, "light": {...}, "dark": {...} } }`, as
+/// `nsh.json`'s `"theme"`), whose tokens win over the app's of the same
+/// names, light and dark; NULL gives the app its own theme back. Applies at
+/// once and to every app loaded later. Returns why the file does not parse,
+/// or NULL.
+char *_Nullable nsh_app_set_theme_overrides(NshApp *app, const char *_Nullable json);
+
 /// Development: loads from a `nsh dev` server (`ws://host:port/ws`) and
 /// reloads on every change. Returns an error without development support,
 /// or NULL.
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/agent.rs b/third-party/nsh/crates/nsh-platform-ios/src/agent.rs
index 3a9e3fbaf2..8960baa4e9 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/agent.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/agent.rs
@@ -200,6 +200,45 @@ impl Native for IosNative {
         });
     }
 
+    fn press_keys(&self, keys: &str) -> Result<(), String> {
+        use nsh_core::keymap::Keystroke;
+        let stroke =
+            Keystroke::parse(keys, nsh_core::platform::Os::Ios).map_err(|e| e.to_string())?;
+        let usage = stroke
+            .key
+            .hid_usage()
+            .ok_or_else(|| format!("a hardware keyboard has no key {keys:?}"))?;
+        let m = stroke.modifiers;
+        let modifiers: Vec<u16> = [
+            (m.ctrl, synth::CONTROL),
+            (m.alt, synth::OPTION),
+            (m.shift, synth::SHIFT),
+            (m.cmd, synth::COMMAND),
+        ]
+        .into_iter()
+        .filter_map(|(on, usage)| on.then_some(usage))
+        .collect();
+        // As a hand presses a shortcut: the modifiers down first, the key
+        // pressed and released, then the modifiers released.
+        let held = modifiers.clone();
+        self.on_main(move |_, window| {
+            for usage in held {
+                synth::key(window, usage, true);
+            }
+            synth::key(window, usage, true)
+        })
+        .filter(|sent| *sent)
+        .ok_or_else(|| "the simulator takes no keyboard events".to_owned())?;
+        std::thread::sleep(TAP);
+        self.on_main(move |_, window| {
+            synth::key(window, usage, false);
+            for usage in modifiers.into_iter().rev() {
+                synth::key(window, usage, false);
+            }
+        });
+        Ok(())
+    }
+
     fn input_text(&self, id: NodeId) -> Option<String> {
         self.on_main(move |view, _| view.with_mounter(|m| m.input_text(id)).flatten())
             .flatten()
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/embed.rs b/third-party/nsh/crates/nsh-platform-ios/src/embed.rs
index 0b5e333b2c..968aad53e9 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/embed.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/embed.rs
@@ -295,6 +295,38 @@ pub unsafe extern "C" fn nsh_app_set_font_scale(app: *mut NshApp, scale: f32) {
         .set_font_scale((scale.is_finite() && scale > 0.0).then_some(scale));
 }
 
+/// The embedding app's theme overrides: a theme file of shadcn/ui's
+/// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`, as
+/// `nsh.json`'s `"theme"`), whose tokens win over the app's of the same
+/// names, light and dark; `NULL` gives the app its own theme back. Applies
+/// at once (classes naming the tokens are resolved again) and to every app
+/// loaded later, from its first frame. Returns why the file does not parse
+/// (nothing changes), or `NULL`.
+///
+/// # Safety
+///
+/// `app` must come from [`nsh_app_create`]; `json` must be `NULL` or a
+/// NUL-terminated string.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn nsh_app_set_theme_overrides(
+    app: *mut NshApp,
+    json: *const c_char,
+) -> *mut c_char {
+    // SAFETY: guaranteed by the caller.
+    let app = unsafe { &*app };
+    let json = if json.is_null() {
+        Ok(None)
+    } else {
+        // SAFETY: guaranteed by the caller.
+        unsafe { utf8(json, "the theme") }.map(Some)
+    };
+    status(json.and_then(|json| {
+        app.view
+            .set_theme_overrides(json)
+            .map_err(|err| format!("invalid theme: {err}"))
+    }))
+}
+
 /// Development: loads from a `nsh dev` server (`ws://host:port/ws`) and
 /// reloads on every change, showing errors over the app. Returns an error
 /// when the library was built without development support, or `NULL`.
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/input.rs b/third-party/nsh/crates/nsh-platform-ios/src/input.rs
index bf3b803c2a..b06e70d112 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/input.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/input.rs
@@ -102,6 +102,32 @@ define_class!(
                 backspace_on_empty(self.as_ref());
             }
         }
+
+        /// A hardware keyboard's keys go to the app's shortcuts first
+        /// (`nsh/keymap`): a key a binding takes never reaches the input.
+        #[unsafe(method(pressesBegan:withEvent:))]
+        fn presses_began(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+            if !crate::keymap::input_began(self.as_ref(), presses) {
+                // SAFETY: calling the superclass implementation.
+                let _: () = unsafe { msg_send![super(self), pressesBegan: presses, withEvent: event] };
+            }
+        }
+
+        #[unsafe(method(pressesEnded:withEvent:))]
+        fn presses_ended(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+            if !crate::keymap::input_ended(self.as_ref(), presses) {
+                // SAFETY: calling the superclass implementation.
+                let _: () = unsafe { msg_send![super(self), pressesEnded: presses, withEvent: event] };
+            }
+        }
+
+        #[unsafe(method(pressesCancelled:withEvent:))]
+        fn presses_cancelled(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+            if !crate::keymap::input_ended(self.as_ref(), presses) {
+                // SAFETY: calling the superclass implementation.
+                let _: () = unsafe { msg_send![super(self), pressesCancelled: presses, withEvent: event] };
+            }
+        }
     }
 );
 
@@ -201,6 +227,32 @@ define_class!(
             let _: () = unsafe { msg_send![self, insertText: &*NSString::from_str("\n")] };
             self.ivars().line_break.set(false);
         }
+
+        /// A hardware keyboard's keys go to the app's shortcuts first
+        /// (`nsh/keymap`): a key a binding takes never reaches the input.
+        #[unsafe(method(pressesBegan:withEvent:))]
+        fn presses_began(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+            if !crate::keymap::input_began(self.as_ref(), presses) {
+                // SAFETY: calling the superclass implementation.
+                let _: () = unsafe { msg_send![super(self), pressesBegan: presses, withEvent: event] };
+            }
+        }
+
+        #[unsafe(method(pressesEnded:withEvent:))]
+        fn presses_ended(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+            if !crate::keymap::input_ended(self.as_ref(), presses) {
+                // SAFETY: calling the superclass implementation.
+                let _: () = unsafe { msg_send![super(self), pressesEnded: presses, withEvent: event] };
+            }
+        }
+
+        #[unsafe(method(pressesCancelled:withEvent:))]
+        fn presses_cancelled(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+            if !crate::keymap::input_ended(self.as_ref(), presses) {
+                // SAFETY: calling the superclass implementation.
+                let _: () = unsafe { msg_send![super(self), pressesCancelled: presses, withEvent: event] };
+            }
+        }
     }
 );
 
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/keymap.rs b/third-party/nsh/crates/nsh-platform-ios/src/keymap.rs
new file mode 100644
index 0000000000..b4a02272bb
--- /dev/null
+++ b/third-party/nsh/crates/nsh-platform-ios/src/keymap.rs
@@ -0,0 +1,288 @@
+//! The app's keyboard shortcuts (`nsh/keymap`) on a hardware keyboard
+//! (an iPad's, a Mac's under Catalyst, the simulator's): the table the
+//! engine mounts ([`MountItem::Keymap`](nsh_core::MountItem::Keymap)),
+//! matched against every key press on the spot.
+//!
+//! UIKit hands a hardware key to the first responder as a press
+//! (`pressesBegan:withEvent:`), which passes what it does not handle up the
+//! responder chain. The text inputs (`input::Field`, `input::Area`) look at
+//! the table before UIKit's text system does, so a binding that fires in
+//! text (`when: "always"`, or a keystroke that edits no text) takes the key
+//! there and the input never sees it: no caret move, no line break, no
+//! typed character. Presses nothing handled reach the surface's root view
+//! (`surface::RootView`), which matches them as the desktops do when the
+//! focus is on none of the app's inputs: a press from a hosted view (a web
+//! view, a native component) counts as in text.
+//!
+//! A key that fires a binding is consumed until it is released (its
+//! release is consumed too) and reported to JS with the root's
+//! `KeyCommand` event; JS runs the handler afterwards. Keys are named by
+//! their USB HID usage (their place on the US layout) for the keys that
+//! type nothing, and by the character they type without modifiers
+//! otherwise, the US layout's of the same key when that is no ASCII, as on
+//! the desktops. UIKit sends a held key's press once: a binding that
+//! repeats (`repeat: true`) fires again on a timer of its own while the key
+//! is held, at the system's usual pace.
+
+use std::{
+    cell::{Cell, RefCell},
+    sync::Arc,
+    time::Duration,
+};
+
+use nsh_core::{
+    EventKind, EventPayload, PayloadValue, ROOT_ID,
+    keymap::{Key, KeyMatch, Keymap, Keystroke, Modifiers},
+};
+use objc2::{MainThreadMarker, msg_send, rc::Retained, runtime::AnyObject};
+use objc2_foundation::{NSArray, NSString};
+use objc2_ui_kit::UIView;
+
+use crate::target::EventTarget;
+
+/// How long a key is held before it repeats, and the time between repeats:
+/// iOS's defaults for a hardware keyboard.
+const REPEAT_DELAY: Duration = Duration::from_millis(400);
+const REPEAT_INTERVAL: Duration = Duration::from_millis(100);
+
+/// `UIKeyModifierFlags`.
+const SHIFT: isize = 1 << 17;
+const CONTROL: isize = 1 << 18;
+const ALTERNATE: isize = 1 << 19;
+const COMMAND: isize = 1 << 20;
+
+/// A surface's table, and the keys it consumed that are still down.
+#[derive(Default)]
+pub(crate) struct KeymapState {
+    table: RefCell<Option<Arc<Keymap>>>,
+    /// Keys whose press the table consumed, by HID usage, with the binding
+    /// that repeats while they are held, if any.
+    held: RefCell<Vec<(isize, Option<u32>)>>,
+    /// Bumped by every press and release: a repeat timer of an older
+    /// generation stops.
+    generation: Cell<u64>,
+}
+
+impl KeymapState {
+    /// `MountItem::Keymap`: replaces the table.
+    pub(crate) fn set(&self, keymap: Arc<Keymap>) {
+        *self.table.borrow_mut() = (!keymap.is_empty()).then_some(keymap);
+    }
+
+    /// Whether the table has bindings: presses need no look otherwise.
+    fn active(&self) -> bool {
+        self.table.borrow().is_some() || !self.held.borrow().is_empty()
+    }
+}
+
+/// The presses of a `UIPress` set, with their keys (`UIKey`); presses of
+/// no key (a remote's, a game controller's) are left out.
+fn keys(presses: &AnyObject) -> Vec<Retained<AnyObject>> {
+    // SAFETY: `presses` is the `NSSet<UIPress *>` UIKit passes; `key` is
+    // UIPress's (iOS 13.4, below the deployment target).
+    unsafe {
+        let all: Retained<NSArray<AnyObject>> = msg_send![presses, allObjects];
+        all.iter()
+            .filter_map(|press| msg_send![&*press, key])
+            .collect()
+    }
+}
+
+/// `UIKey.keyCode`: the key's USB HID usage.
+fn code(key: &AnyObject) -> isize {
+    // SAFETY: UIKey's property.
+    unsafe { msg_send![key, keyCode] }
+}
+
+/// The keystroke of `key` (a `UIKey`), by its key on the US layout.
+fn keystroke(key: &AnyObject) -> Option<Keystroke> {
+    // SAFETY: UIKey's properties.
+    let (flags, typed): (isize, Option<Retained<NSString>>) = unsafe {
+        (
+            msg_send![key, modifierFlags],
+            msg_send![key, charactersIgnoringModifiers],
+        )
+    };
+    let modifiers = Modifiers {
+        ctrl: flags & CONTROL != 0,
+        alt: flags & ALTERNATE != 0,
+        shift: flags & SHIFT != 0,
+        cmd: flags & COMMAND != 0,
+    };
+    let usage = u16::try_from(code(key)).ok()?;
+    let key = match Key::from_hid_usage(usage)? {
+        // A key that types a character: the one it types without
+        // modifiers. UIKit may leave Shift applied (`?` for Shift+/): then
+        // the key's own, which the US layout names.
+        us @ Key::Char(c) if c != ' ' => match typed.and_then(|s| s.to_string().chars().next()) {
+            Some(c) if c.is_ascii_alphabetic() => Key::Char(c.to_ascii_lowercase()),
+            Some(c) if c.is_ascii_graphic() && !modifiers.shift => Key::Char(c),
+            _ => us,
+        },
+        named => named,
+    };
+    Some(Keystroke { modifiers, key })
+}
+
+/// `pressesBegan:withEvent:` of a responder of the surface: whether the
+/// table consumed `presses` (then the responder neither handles them nor
+/// passes them on). `editing`: a text input or hosted view has the focus.
+pub(crate) fn began(target: &EventTarget, presses: &AnyObject, editing: bool) -> bool {
+    let state = target.keymap();
+    if !state.active() {
+        return false;
+    }
+    let mut consumed = false;
+    for key in keys(presses) {
+        let code = code(&key);
+        // UIKit sends a key's press once: one already held lost its
+        // release to a responder elsewhere (the focus moved), so it is
+        // pressed anew.
+        state.held.borrow_mut().retain(|(held, _)| *held != code);
+        let Some(stroke) = keystroke(&key) else {
+            continue;
+        };
+        let Some(table) = state.table.borrow().clone() else {
+            continue;
+        };
+        // The dev host's Cmd+R reloads, whatever the app binds.
+        if cfg!(feature = "dev") && stroke.reloads(nsh_core::platform::Os::Ios) {
+            continue;
+        }
+        // Presses come once: no auto-repeat to match (`KeyMatch::Held`).
+        let KeyMatch::Fire(id) = table.find(stroke, editing, false) else {
+            continue;
+        };
+        fire(target, id);
+        let repeats = table.bindings().iter().any(|b| b.id == id && b.repeat);
+        state.held.borrow_mut().push((code, repeats.then_some(id)));
+        if repeats {
+            let generation = state.generation.get() + 1;
+            state.generation.set(generation);
+            schedule(target, code, generation, REPEAT_DELAY);
+        }
+        consumed = true;
+    }
+    consumed
+}
+
+/// `pressesEnded:` / `pressesCancelled:`: whether `presses` are releases
+/// of keys the table consumed (then they are consumed too).
+pub(crate) fn ended(target: &EventTarget, presses: &AnyObject) -> bool {
+    let state = target.keymap();
+    if state.held.borrow().is_empty() {
+        return false;
+    }
+    let mut consumed = false;
+    for key in keys(presses) {
+        let code = code(&key);
+        let mut held = state.held.borrow_mut();
+        if let Some(i) = held.iter().position(|(h, _)| *h == code) {
+            held.remove(i);
+            state.generation.set(state.generation.get() + 1);
+            consumed = true;
+        }
+    }
+    consumed
+}
+
+/// Reports binding `id` to JS.
+fn fire(target: &EventTarget, id: u32) {
+    target.dispatch(
+        ROOT_ID,
+        EventKind::KeyCommand,
+        EventPayload::Value(PayloadValue::Number(f64::from(id))),
+    );
+}
+
+/// Fires the repeating binding of held key `code` again after `delay`,
+/// and on, while it is held and nothing else was pressed.
+fn schedule(target: &EventTarget, code: isize, generation: u64, delay: Duration) {
+    let mtm = MainThreadMarker::from(target);
+    let weak = dispatch2::MainThreadBound::new(objc2::rc::Weak::from(target), mtm);
+    let when = dispatch2::DispatchTime::NOW.time(delay.as_nanos() as i64);
+    dispatch2::DispatchQueue::main()
+        .after(when, move || {
+            let mtm = MainThreadMarker::new().expect("main queue");
+            let Some(target) = weak.into_inner(mtm).load() else {
+                return;
+            };
+            let state = target.keymap();
+            if state.generation.get() != generation {
+                return;
+            }
+            let binding = state
+                .held
+                .borrow()
+                .iter()
+                .find(|(held, _)| *held == code)
+                .and_then(|(_, binding)| *binding);
+            // The binding may have gone with its component.
+            let bound = state
+                .table
+                .borrow()
+                .as_ref()
+                .is_some_and(|t| t.bindings().iter().any(|b| Some(b.id) == binding));
+            if let (Some(id), true) = (binding, bound) {
+                fire(&target, id);
+                schedule(&target, code, generation, REPEAT_INTERVAL);
+            }
+        })
+        .ok();
+}
+
+/// The event target of text input `view` (its delegate).
+fn input_target(view: &UIView) -> Option<Retained<EventTarget>> {
+    // SAFETY: both input classes have a `delegate`, set to the target.
+    let delegate: Option<Retained<AnyObject>> = unsafe { msg_send![view, delegate] };
+    delegate?.downcast::<EventTarget>().ok()
+}
+
+/// `pressesBegan:withEvent:` of text input `view`: whether the table
+/// consumed `presses`. While an input method composes, its keys are its
+/// own (choosing a candidate, committing).
+pub(crate) fn input_began(view: &UIView, presses: &AnyObject) -> bool {
+    if crate::input::marked_text(view).is_some() {
+        return false;
+    }
+    input_target(view).is_some_and(|target| began(&target, presses, true))
+}
+
+/// `pressesEnded:` / `pressesCancelled:` of text input `view`.
+pub(crate) fn input_ended(view: &UIView, presses: &AnyObject) -> bool {
+    input_target(view).is_some_and(|target| ended(&target, presses))
+}
+
+/// `pressesBegan:withEvent:` of the surface's root view, which gets the
+/// presses no responder inside it handled: whether the table consumed
+/// them. The app's text inputs matched theirs already; a press from
+/// another view taking text (a web view's, a native component's) is in
+/// text.
+pub(crate) fn root_began(target: &EventTarget, presses: &AnyObject) -> bool {
+    use objc2::ProtocolType;
+    // SAFETY: as in `keys`; `responder` is UIPress's.
+    let responder: Option<Retained<AnyObject>> = unsafe {
+        let all: Retained<NSArray<AnyObject>> = msg_send![presses, allObjects];
+        all.iter()
+            .next()
+            .and_then(|press| msg_send![&*press, responder])
+    };
+    let editing = match responder.as_deref() {
+        Some(r)
+            if r.downcast_ref::<crate::input::Field>().is_some()
+                || r.downcast_ref::<crate::input::Area>().is_some() =>
+        {
+            return false;
+        }
+        Some(r) => {
+            let text = <dyn objc2_ui_kit::UIKeyInput>::protocol()
+                .is_some_and(|p| r.class().conforms_to(p));
+            // The app's own texts take keys only to select (`TextView`).
+            text && r.downcast_ref::<crate::native_view::NshView>().is_none()
+                && r.downcast_ref::<crate::selection_area::AreaResponder>()
+                    .is_none()
+        }
+        None => false,
+    };
+    began(target, presses, editing)
+}
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/lib.rs b/third-party/nsh/crates/nsh-platform-ios/src/lib.rs
index 89312300e4..704756079c 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/lib.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/lib.rs
@@ -30,6 +30,7 @@ pub mod embed;
 mod image;
 mod input;
 mod interaction;
+mod keymap;
 mod mount;
 mod native_view;
 mod navigation;
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/mount.rs b/third-party/nsh/crates/nsh-platform-ios/src/mount.rs
index b0668b251e..281ca3d727 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/mount.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/mount.rs
@@ -475,6 +475,11 @@ impl Mounter {
         }
     }
 
+    /// The surface's event target.
+    pub(crate) fn target(&self) -> &Retained<EventTarget> {
+        &self.target
+    }
+
     /// Node `id`'s view (`nsh test --target ios`).
     #[cfg(feature = "dev")]
     pub(crate) fn ui_view(&self, id: NodeId) -> Option<Retained<UIView>> {
@@ -1084,7 +1089,7 @@ impl Mounter {
             }
             MountItem::Command { id, command } => self.command(id, command),
             // Hardware keyboards' shortcuts are not matched yet.
-            MountItem::Keymap(_) => {}
+            MountItem::Keymap(keymap) => self.target.keymap().set(keymap),
         }
         false
     }
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/surface.rs b/third-party/nsh/crates/nsh-platform-ios/src/surface.rs
index 8a44f0267a..9e083f1c51 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/surface.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/surface.rs
@@ -14,7 +14,7 @@ use nsh_engine::{
 use objc2::{
     DefinedClass, MainThreadMarker, MainThreadOnly, Message, define_class, msg_send,
     rc::{Retained, Weak},
-    runtime::ProtocolObject,
+    runtime::{AnyObject, ProtocolObject},
 };
 use objc2_core_foundation::{CGRect, CGSize};
 use objc2_foundation::{
@@ -125,6 +125,8 @@ pub(crate) struct RootViewIvars {
     /// The embedding app's locale and text size over the system's
     /// ([`Surface::set_host_environment`]).
     host: Cell<nsh_engine::HostEnvironment>,
+    /// The embedding app's theme overrides ([`Surface::set_host_tokens`]).
+    host_tokens: RefCell<Option<nsh_engine::Tokens>>,
     /// Keyboard notification registrations.
     observers: RefCell<Vec<Retained<ProtocolObject<dyn NSObjectProtocol>>>>,
     #[cfg(feature = "dev")]
@@ -213,6 +215,42 @@ define_class!(
                 }
             });
         }
+
+        /// Hardware keys no responder inside handled: the app's shortcuts
+        /// (`nsh/keymap`) take those they bind.
+        #[unsafe(method(pressesBegan:withEvent:))]
+        fn presses_began(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+            let consumed = self
+                .target()
+                .is_some_and(|target| crate::keymap::root_began(&target, presses));
+            if !consumed {
+                // SAFETY: calling the superclass implementation.
+                let _: () = unsafe { msg_send![super(self), pressesBegan: presses, withEvent: event] };
+            }
+        }
+
+        #[unsafe(method(pressesEnded:withEvent:))]
+        fn presses_ended(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+            let consumed = self
+                .target()
+                .is_some_and(|target| crate::keymap::ended(&target, presses));
+            if !consumed {
+                // SAFETY: calling the superclass implementation.
+                let _: () = unsafe { msg_send![super(self), pressesEnded: presses, withEvent: event] };
+            }
+        }
+
+        #[unsafe(method(pressesCancelled:withEvent:))]
+        fn presses_cancelled(&self, presses: &AnyObject, event: Option<&AnyObject>) {
+            let consumed = self
+                .target()
+                .is_some_and(|target| crate::keymap::ended(&target, presses));
+            if !consumed {
+                // SAFETY: calling the superclass implementation.
+                let _: () =
+                    unsafe { msg_send![super(self), pressesCancelled: presses, withEvent: event] };
+            }
+        }
     }
 );
 
@@ -233,6 +271,7 @@ impl RootView {
             keyboard: Cell::new((0.0, 0.25)),
             environment: Cell::new(None),
             host: Cell::default(),
+            host_tokens: RefCell::new(None),
             observers: RefCell::new(Vec::new()),
             #[cfg(feature = "dev")]
             test_model: TestModel::default(),
@@ -241,6 +280,18 @@ impl RootView {
         unsafe { msg_send![super(this), initWithFrame: frame] }
     }
 
+    /// The running app's event target.
+    fn target(&self) -> Option<Retained<crate::target::EventTarget>> {
+        let running = self.ivars().running.borrow();
+        let mounter = running
+            .as_ref()?
+            .mounter
+            .get(self.mtm())
+            .try_borrow()
+            .ok()?;
+        mounter.as_ref().map(|m| m.target().clone())
+    }
+
     /// What JS sees through `windowSize()`, `safeArea()`, `keyboard()`...
     fn environment(&self) -> Environment {
         let size = self.bounds().size;
@@ -292,6 +343,9 @@ impl RootView {
             // Only read for the web: iOS keys are always Apple's.
             apple_keyboard: true,
             terminal_keyboard: false,
+            // No host answers `nsh/host` here: an embedding app talks to its
+            // apps through its own UI sets (`nsh/native`).
+            host: false,
         };
         // The embedding app's settings over the system's.
         self.ivars().host.get().apply(&mut env);
@@ -492,7 +546,13 @@ impl RootView {
             #[cfg(feature = "dev")]
             test_model: self.ivars().test_model.clone(),
         };
-        let engine = match Engine::start(config, CoreTextEngine::default, sink) {
+        // The embedding app's theme overrides are in the first frame.
+        let tokens = self.ivars().host_tokens.borrow().clone();
+        let engine = match Engine::start_with(config, CoreTextEngine::default, sink, |control| {
+            if tokens.is_some() {
+                control.set_tokens(tokens);
+            }
+        }) {
             Ok(engine) => engine,
             Err(err) => return log::error!("failed to start the engine: {err}"),
         };
@@ -689,6 +749,17 @@ impl Surface {
         self.view.publish_environment();
     }
 
+    /// The embedding app's theme overrides (`nsh_base::theme_overrides`),
+    /// which win over the app's tokens of the same names (`None`: the
+    /// app's own again): sent to the running app at once, and to every app
+    /// started later before its first frame.
+    pub fn set_host_tokens(&self, tokens: Option<nsh_engine::Tokens>) {
+        *self.view.ivars().host_tokens.borrow_mut() = tokens.clone();
+        if let Some(running) = &*self.view.ivars().running.borrow() {
+            running.engine.control().set_tokens(tokens);
+        }
+    }
+
     /// Replaces the running app with `app` (state is reset).
     pub fn reload(&self, app: impl Into<App>) {
         let app = app.into();
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/synth.rs b/third-party/nsh/crates/nsh-platform-ios/src/synth.rs
index 6e63dae3f0..4093d3d9a4 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/synth.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/synth.rs
@@ -68,6 +68,7 @@ struct Hid {
         u8,
         u32,
     ) -> Event,
+    keyboard: unsafe extern "C" fn(Allocator, u64, u32, u32, u8, u32) -> Event,
     append: unsafe extern "C" fn(Event, Event, u32),
     set_integer: unsafe extern "C" fn(Event, u32, i64),
     set_float: unsafe extern "C" fn(Event, u32, f64),
@@ -113,6 +114,7 @@ fn load() -> Option<Hid> {
         Some(Hid {
             digitizer: symbol(iokit, "IOHIDEventCreateDigitizerEvent")?,
             finger: symbol(iokit, "IOHIDEventCreateDigitizerFingerEvent")?,
+            keyboard: symbol(iokit, "IOHIDEventCreateKeyboardEvent")?,
             append: symbol(iokit, "IOHIDEventAppendEvent")?,
             set_integer: symbol(iokit, "IOHIDEventSetIntegerValue")?,
             set_float: symbol(iokit, "IOHIDEventSetFloatValue")?,
@@ -236,6 +238,39 @@ pub(crate) fn touch(window: &UIWindow, x: f64, y: f64, phase: Phase) -> bool {
     true
 }
 
+/// `kHIDPage_KeyboardOrKeypad`.
+const KEYBOARD_PAGE: u32 = 7;
+
+/// The USB HID usages of the left Control, Shift, Option and Command keys.
+pub(crate) const CONTROL: u16 = 0xE0;
+pub(crate) const SHIFT: u16 = 0xE1;
+pub(crate) const OPTION: u16 = 0xE2;
+pub(crate) const COMMAND: u16 = 0xE3;
+
+/// A hardware keyboard's key `usage` (a USB HID keyboard usage) going down
+/// or up, to `window`, as UIKit takes a real keyboard's: it becomes the
+/// presses, key commands and typing of the key.
+pub(crate) fn key(window: &UIWindow, usage: u16, down: bool) -> bool {
+    let Some(hid) = hid() else { return false };
+    // SAFETY: IOKit's constructor with its documented arguments; the event
+    // is handed over (and released) by `enqueue`.
+    let event = unsafe {
+        (hid.keyboard)(
+            std::ptr::null(),
+            now(),
+            KEYBOARD_PAGE,
+            u32::from(usage),
+            u8::from(down),
+            0,
+        )
+    };
+    if event.is_null() {
+        return false;
+    }
+    enqueue(hid, event, window, window.mtm());
+    true
+}
+
 /// The window's first responder (the focused input).
 fn first_responder(window: &UIWindow) -> Option<Retained<AnyObject>> {
     // SAFETY: UIWindow's `firstResponder` (private: the responder chain's).
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/target.rs b/third-party/nsh/crates/nsh-platform-ios/src/target.rs
index aeeecc0f28..d4f0807e00 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/target.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/target.rs
@@ -31,6 +31,8 @@ pub(crate) struct TargetIvars {
     /// Scrolls the core asked for that each scroll view applied, reported
     /// with its offsets (`EngineControl::scroll`).
     scrolls_applied: std::cell::RefCell<rustc_hash::FxHashMap<NodeId, u32>>,
+    /// The app's keyboard shortcuts, matched as keys are pressed.
+    keymap: crate::keymap::KeymapState,
 }
 
 define_class!(
@@ -455,6 +457,7 @@ impl EventTarget {
             control,
             mounter,
             scrolls_applied: Default::default(),
+            keymap: Default::default(),
         });
         // SAFETY: NSObject's designated initializer.
         unsafe { msg_send![super(this), init] }
@@ -476,7 +479,12 @@ impl EventTarget {
         self.ivars().scrolls_applied.borrow_mut().remove(&id);
     }
 
-    fn dispatch(&self, id: NodeId, event: EventKind, payload: EventPayload) {
+    /// The app's keyboard shortcuts.
+    pub(crate) fn keymap(&self) -> &crate::keymap::KeymapState {
+        &self.ivars().keymap
+    }
+
+    pub(crate) fn dispatch(&self, id: NodeId, event: EventKind, payload: EventPayload) {
         self.ivars().control.dispatch_event(id, event, payload);
     }
 
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/view.rs b/third-party/nsh/crates/nsh-platform-ios/src/view.rs
index a2a2e9932f..3963d9a25d 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/view.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/view.rs
@@ -37,6 +37,8 @@ struct State {
     options: SurfaceOptions,
     /// The embedding app's locale and text size.
     host: nsh_engine::HostEnvironment,
+    /// The embedding app's theme overrides.
+    host_tokens: Option<nsh_engine::Tokens>,
     on_error: Option<ErrorHandler>,
     #[cfg(feature = "dev")]
     dev: Option<crate::dev::DevSession>,
@@ -59,6 +61,7 @@ impl AppView {
                     surface: None,
                     options,
                     host: nsh_engine::HostEnvironment::default(),
+                    host_tokens: None,
                     on_error: None,
                     #[cfg(feature = "dev")]
                     dev: None,
@@ -156,6 +159,24 @@ impl AppView {
         self.set_host(|host| host.font_scale = scale);
     }
 
+    /// The embedding app's theme overrides: a theme file of shadcn/ui's
+    /// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`,
+    /// values as CSS writes them, as `nsh.json`'s `"theme"`), whose
+    /// tokens win over the app's of the same names, light and dark, while
+    /// the app's others stay (a brand's `primary` and `radius`). Classes
+    /// naming them (`bg-primary`) are resolved again at once, and every
+    /// app loaded later starts with them. `None` gives the app its own
+    /// theme back. Fails, changing nothing, on a file that does not parse.
+    pub fn set_theme_overrides(&self, json: Option<&str>) -> Result<(), nsh_base::ThemeError> {
+        let tokens = json.map(nsh_base::theme_overrides).transpose()?;
+        let mut state = self.inner.state.borrow_mut();
+        state.host_tokens = tokens;
+        if let Some(surface) = &state.surface {
+            surface.set_host_tokens(state.host_tokens.clone());
+        }
+        Ok(())
+    }
+
     fn set_host(&self, change: impl FnOnce(&mut nsh_engine::HostEnvironment)) {
         let mut state = self.inner.state.borrow_mut();
         change(&mut state.host);
@@ -224,6 +245,7 @@ impl Inner {
             UIViewAutoresizing::FlexibleWidth | UIViewAutoresizing::FlexibleHeight,
         );
         surface.set_host_environment(state.host);
+        surface.set_host_tokens(state.host_tokens.clone());
         #[cfg(feature = "dev")]
         surface.set_test_model(state.test_model.clone());
         let weak: Weak<Inner> = Rc::downgrade(this);
diff --git a/third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift b/third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift
index ada2d584bc..3c09573d8b 100644
--- a/third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift
+++ b/third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift
@@ -172,6 +172,20 @@ public final class NshAppView: UIView {
         }
     }
 
+    /// Overrides the app's theme with the host's: a theme file of shadcn/ui's
+    /// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`,
+    /// values as CSS writes them, the format of `nsh.json`'s `"theme"`). Its
+    /// tokens win over the app's of the same names, in light and dark mode;
+    /// the app's others stay, so a branded host sets only what its brand
+    /// changes (`primary`, `radius`, ...). Call it before or after loading:
+    /// it applies at once (classes such as `bg-primary` are resolved again)
+    /// and to every app loaded later, from its first frame. `nil` gives the
+    /// app its own theme back. Throws why the file does not parse; the
+    /// overrides then stay as they were.
+    public func setThemeOverrides(_ json: String?) throws {
+        try check(nsh_app_set_theme_overrides(app, json))
+    }
+
     /// Grants the apps loaded from now on at most these capabilities: JSON in
     /// the form of `nsh.json`'s `capabilities` (`nil`: what each app
     /// declares). A request must be allowed by both.
diff --git a/third-party/nsh/crates/nsh-platform-linux/src/app.rs b/third-party/nsh/crates/nsh-platform-linux/src/app.rs
index 642e96025c..b9692c2ec2 100644
--- a/third-party/nsh/crates/nsh-platform-linux/src/app.rs
+++ b/third-party/nsh/crates/nsh-platform-linux/src/app.rs
@@ -289,6 +289,21 @@ impl AppView {
         crate::system::host_environment_changed(&self.surface);
     }
 
+    /// The embedding app's theme overrides: a theme file of shadcn/ui's
+    /// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`,
+    /// values as CSS writes them, as `nsh.json`'s `"theme"`), whose tokens
+    /// win over the app's of the same names, light and dark, while the
+    /// app's others stay (a brand's `primary` and `radius`). Classes naming
+    /// them (`bg-primary`) are resolved again at once, and every app loaded
+    /// later starts with them. `None` gives the app its own theme back.
+    /// Fails, changing nothing, on a file that does not parse.
+    pub fn set_theme_overrides(&self, json: Option<&str>) -> Result<(), nsh_base::ThemeError> {
+        let tokens = json.map(nsh_base::theme_overrides).transpose()?;
+        *self.surface.host_tokens.borrow_mut() = tokens.clone();
+        self.surface.control(|c| c.set_tokens(tokens));
+        Ok(())
+    }
+
     /// Stops the app.
     pub fn stop(&self) {
         self.surface.stop();
diff --git a/third-party/nsh/crates/nsh-platform-linux/src/surface.rs b/third-party/nsh/crates/nsh-platform-linux/src/surface.rs
index 1b2c5313bf..db25ba28b9 100644
--- a/third-party/nsh/crates/nsh-platform-linux/src/surface.rs
+++ b/third-party/nsh/crates/nsh-platform-linux/src/surface.rs
@@ -110,6 +110,10 @@ pub(crate) struct Surface {
     /// The embedding app's locale and text size, standing in for the
     /// system's ([`crate::AppView::set_locale`], `set_font_scale`).
     pub(crate) host_environment: Cell<nsh_core::platform::HostEnvironment>,
+    /// The embedding app's theme overrides
+    /// ([`crate::AppView::set_theme_overrides`]): sent to every engine the
+    /// surface starts, before its first frame.
+    pub(crate) host_tokens: RefCell<Option<nsh_engine::Tokens>>,
     /// JS asked for a display frame.
     js_frame: Cell<bool>,
     /// The tick callback runs.
@@ -156,6 +160,7 @@ impl Surface {
             environment: Cell::new(environment),
             appearance: Cell::default(),
             host_environment: Cell::default(),
+            host_tokens: RefCell::new(None),
             js_frame: Cell::new(false),
             ticking: Cell::new(false),
             on_error: RefCell::new(None),
@@ -237,7 +242,14 @@ impl Surface {
         *self.rx.borrow_mut() = Some(rx);
         *self.pending.borrow_mut() = pending;
         let family = crate::text::ui_family();
-        match Engine::start(config, move || PangoTextEngine::new(family), sink) {
+        // The embedding app's theme overrides are in the first frame.
+        let tokens = self.host_tokens.borrow().clone();
+        let setup = |control: &EngineControl| {
+            if tokens.is_some() {
+                control.set_tokens(tokens);
+            }
+        };
+        match Engine::start_with(config, move || PangoTextEngine::new(family), sink, setup) {
             Ok(engine) => {
                 // The test moves its clock through the engine.
                 #[cfg(feature = "dev")]
diff --git a/third-party/nsh/crates/nsh-platform-macos/src/app.rs b/third-party/nsh/crates/nsh-platform-macos/src/app.rs
index 6a6041505a..c049c0471f 100644
--- a/third-party/nsh/crates/nsh-platform-macos/src/app.rs
+++ b/third-party/nsh/crates/nsh-platform-macos/src/app.rs
@@ -337,6 +337,21 @@ impl AppView {
         crate::system::host_environment_changed(&self.surface);
     }
 
+    /// The embedding app's theme overrides: a theme file of shadcn/ui's
+    /// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`,
+    /// values as CSS writes them, as `nsh.json`'s `"theme"`), whose tokens
+    /// win over the app's of the same names, light and dark, while the
+    /// app's others stay (a brand's `primary` and `radius`). Classes naming
+    /// them (`bg-primary`) are resolved again at once, and every app loaded
+    /// later starts with them. `None` gives the app its own theme back.
+    /// Fails, changing nothing, on a file that does not parse.
+    pub fn set_theme_overrides(&self, json: Option<&str>) -> Result<(), nsh_base::ThemeError> {
+        let tokens = json.map(nsh_base::theme_overrides).transpose()?;
+        *self.surface.host_tokens.borrow_mut() = tokens.clone();
+        self.surface.control(|c| c.set_tokens(tokens));
+        Ok(())
+    }
+
     /// Stops the app.
     pub fn stop(&self) {
         self.surface.stop();
diff --git a/third-party/nsh/crates/nsh-platform-macos/src/surface.rs b/third-party/nsh/crates/nsh-platform-macos/src/surface.rs
index 0b3428e078..8844ef5036 100644
--- a/third-party/nsh/crates/nsh-platform-macos/src/surface.rs
+++ b/third-party/nsh/crates/nsh-platform-macos/src/surface.rs
@@ -5,7 +5,13 @@
 //! wakes the main thread once (`DispatchQueue::main`); the main thread
 //! drains the queue and applies every batch in order ([`Surface::drain`]).
 //! Native animations and JS frame requests run on the root view's display
-//! link while there is something to do.
+//! link while there is something to do. While native animations or the
+//! platform's own (scroll glides) run, batches wait for the start of the
+//! next frame and each frame applies them for at most
+//! [`FRAME_BATCHES`] ([`Surface::apply_queued`]): a scroll glide's frames
+//! are not delayed by batches landing between them, and several batches
+//! (list binds while scrolling) spread over frames instead of one frame
+//! taking them all. An idle surface applies a batch as soon as it lands.
 //!
 //! The one exception to "the main thread never waits for JS": when the
 //! window resizes the root ([`Surface::resized`]), the main thread waits
@@ -21,6 +27,7 @@
 
 use std::{
     cell::{Cell, RefCell},
+    collections::VecDeque,
     path::PathBuf,
     rc::{Rc, Weak},
     sync::{
@@ -64,6 +71,11 @@ enum Message {
 /// (a frame at 60 Hz): see the module doc.
 const RESIZE_WAIT: std::time::Duration = std::time::Duration::from_millis(16);
 
+/// The main-thread time one frame spends applying queued batches
+/// ([`Surface::apply_queued`]): about half a refresh at 120 Hz, which
+/// leaves the rest to input, animations and Core Animation's commit.
+const FRAME_BATCHES: std::time::Duration = std::time::Duration::from_millis(4);
+
 thread_local! {
     /// The surfaces of this (main) thread, by key: the sink wakes one by
     /// its key, as dispatched closures must be `Send`.
@@ -188,8 +200,16 @@ pub(crate) struct Surface {
     /// The embedding app's locale and text size, standing in for the
     /// system's ([`crate::AppView::set_locale`], `set_font_scale`).
     pub(crate) host_environment: Cell<nsh_core::platform::HostEnvironment>,
+    /// The embedding app's theme overrides
+    /// ([`crate::AppView::set_theme_overrides`]): sent to every engine the
+    /// surface starts, before its first frame.
+    pub(crate) host_tokens: RefCell<Option<nsh_engine::Tokens>>,
     /// JS asked for a display frame.
     js_frame: Cell<bool>,
+    /// Batches that arrived while animations run, and what came after
+    /// them, applied at the start of the next frames
+    /// ([`Surface::apply_queued`]).
+    queued: RefCell<VecDeque<Message>>,
     /// Frame callbacks of the platform's own animations (scroll glides,
     /// transitions): called on every frame while they return true.
     frame_callbacks: RefCell<Vec<FrameCallback>>,
@@ -250,7 +270,9 @@ impl Surface {
             appearance: Cell::default(),
             chunks: RefCell::new(None),
             host_environment: Cell::default(),
+            host_tokens: RefCell::new(None),
             js_frame: Cell::new(false),
+            queued: RefCell::default(),
             frame_callbacks: RefCell::default(),
             #[cfg(feature = "dev")]
             test_model: RefCell::default(),
@@ -312,7 +334,14 @@ impl Surface {
         };
         *self.rx.borrow_mut() = Some(rx);
         *self.pending.borrow_mut() = pending;
-        match Engine::start(config, CoreTextEngine::default, sink) {
+        // The embedding app's theme overrides are in the first frame.
+        let tokens = self.host_tokens.borrow().clone();
+        let setup = |control: &EngineControl| {
+            if tokens.is_some() {
+                control.set_tokens(tokens);
+            }
+        };
+        match Engine::start_with(config, CoreTextEngine::default, sink, setup) {
             Ok(engine) => {
                 // The test moves its clock through the engine.
                 #[cfg(feature = "dev")]
@@ -343,6 +372,7 @@ impl Surface {
         crate::input::reset(self);
         crate::keymap::clear(self);
         self.js_frame.set(false);
+        self.queued.borrow_mut().clear();
         self.resize_late.set(false);
         self.frame_callbacks.borrow_mut().clear();
     }
@@ -425,6 +455,11 @@ impl Surface {
         // Measured as main-thread work: the wait is part of the frame.
         self.measure(true, || {
             quiet(|| {
+                // What waited for a frame comes before the new layout.
+                let queued = std::mem::take(&mut *self.queued.borrow_mut());
+                for message in queued {
+                    applied |= self.handle(message, monitor.as_deref());
+                }
                 loop {
                     let Ok(message) = rx.recv_deadline(deadline) else {
                         self.resize_late.set(true);
@@ -470,7 +505,8 @@ impl Surface {
         r
     }
 
-    /// Applies what the engine sent.
+    /// Handles what the engine sent. While animations run (see the module
+    /// doc), batches wait for the next frame ([`Surface::apply_queued`]).
     fn drain(self: &Rc<Self>) {
         let monitor = self.perf.borrow().clone();
         let start = monitor.as_ref().map(|_| Instant::now());
@@ -482,6 +518,18 @@ impl Surface {
         let mut applied = false;
         quiet(|| {
             for message in messages {
+                // A batch waits while the platform's own animations run;
+                // whatever follows a waiting batch waits behind it, which
+                // keeps the order (a frame request only sets a flag).
+                let wait = match message {
+                    Message::Batch(..) => self.defers_batches(),
+                    Message::Frame => false,
+                    _ => !self.queued.borrow().is_empty(),
+                };
+                if wait {
+                    self.queued.borrow_mut().push_back(message);
+                    continue;
+                }
                 applied |= self.handle(message, monitor.as_deref());
             }
             if applied {
@@ -602,10 +650,12 @@ impl Surface {
     }
 
     /// Whether a native animation runs: the graph's, or the platform's own
-    /// (scroll glides, zooms).
+    /// (scroll glides, zooms); or batches wait for the next frame.
     #[cfg(feature = "dev")]
     pub(crate) fn animating(&self) -> bool {
-        self.anim.borrow().animating() || !self.frame_callbacks.borrow().is_empty()
+        self.anim.borrow().animating()
+            || !self.frame_callbacks.borrow().is_empty()
+            || !self.queued.borrow().is_empty()
     }
 
     /// Keeps the model `nsh test --target` steps see of the app
@@ -616,11 +666,23 @@ impl Surface {
         *self.test_model.borrow_mut() = model;
     }
 
+    /// Whether batches wait for the start of the next frame: while native
+    /// animations or the platform's own (scroll glides, zooms) run, so
+    /// their frames are not delayed by batches landing between them, and
+    /// behind batches already waiting. A frame JS asked for alone does not
+    /// make them wait: what it draws shows in the frame it was asked for.
+    fn defers_batches(&self) -> bool {
+        !self.queued.borrow().is_empty()
+            || self.anim.borrow().animating()
+            || !self.frame_callbacks.borrow().is_empty()
+    }
+
     /// Whether the display link should run (animations, frame callbacks,
     /// a JS frame request): while it does, a bounds commit mid-scroll
     /// lands on a live frame.
     pub(crate) fn wants_frames(&self) -> bool {
         self.js_frame.get()
+            || !self.queued.borrow().is_empty()
             || self.anim.borrow().animating()
             || !self.frame_callbacks.borrow().is_empty()
     }
@@ -738,6 +800,7 @@ impl Surface {
     }
 
     fn tick(self: &Rc<Self>, now: f64) {
+        self.apply_queued(FRAME_BATCHES);
         if self.anim.borrow().animating() {
             self.anim.borrow_mut().tick(now);
             self.write_animations();
@@ -758,6 +821,28 @@ impl Surface {
         self.revisit();
     }
 
+    /// Applies the batches that waited for this frame, in order, until
+    /// `budget` is spent (at least one): the rest wait for the next frame,
+    /// so one frame's main-thread work stays bounded when the engine sent
+    /// several. A batch is the state of one commit and is applied whole.
+    fn apply_queued(self: &Rc<Self>, budget: std::time::Duration) {
+        if self.queued.borrow().is_empty() {
+            return;
+        }
+        let monitor = self.perf.borrow().clone();
+        let start = Instant::now();
+        let mut applied = false;
+        while !applied || start.elapsed() < budget {
+            let Some(message) = self.queued.borrow_mut().pop_front() else {
+                break;
+            };
+            applied |= self.handle(message, monitor.as_deref());
+        }
+        if applied {
+            self.write_animations();
+        }
+    }
+
     /// Draws views whose drawing changed out of sight and that show now
     /// ([`Mounter::draw_when_shown`]); after anything that moves views.
     pub(crate) fn revisit(&self) {
diff --git a/third-party/nsh/crates/nsh-platform-tui/src/surface.rs b/third-party/nsh/crates/nsh-platform-tui/src/surface.rs
index 48672af468..75cecd8c89 100644
--- a/third-party/nsh/crates/nsh-platform-tui/src/surface.rs
+++ b/third-party/nsh/crates/nsh-platform-tui/src/surface.rs
@@ -2117,6 +2117,7 @@ mod tests {
                 }
                 let mut binds = Vec::new();
                 core.take_list_binds(&mut binds);
+                core.take_paced_list_binds(usize::MAX, &mut binds);
                 if binds.is_empty() {
                     return;
                 }
diff --git a/third-party/nsh/crates/nsh-platform-web/src/encode.rs b/third-party/nsh/crates/nsh-platform-web/src/encode.rs
index 7880e95617..7c7ecf7af9 100644
--- a/third-party/nsh/crates/nsh-platform-web/src/encode.rs
+++ b/third-party/nsh/crates/nsh-platform-web/src/encode.rs
@@ -1407,6 +1407,21 @@ fn encode_command(out: &mut Out, id: u32, command: &Command) {
                 .num(*request)
                 .end();
         }
+        // The id is written whole (`num` rounds); the page answers with
+        // `onHost` `{ type: "reply" }` (`nsh.js` `hostCall`).
+        Command::HostCall {
+            id: request,
+            name,
+            args,
+        } => {
+            out.item("cmd")
+                .num(id)
+                .str("hostCall")
+                .raw(&request.to_string())
+                .str(name)
+                .raw(&args.to_string())
+                .end();
+        }
         Command::ImageInfo { id: request, uri } => {
             out.item("cmd")
                 .num(id)
diff --git a/third-party/nsh/crates/nsh-platform-web/src/lib.rs b/third-party/nsh/crates/nsh-platform-web/src/lib.rs
index b1deae3044..114627dfc2 100644
--- a/third-party/nsh/crates/nsh-platform-web/src/lib.rs
+++ b/third-party/nsh/crates/nsh-platform-web/src/lib.rs
@@ -335,9 +335,10 @@ fn page() -> Environment {
 /// (`"phone"`, `"pad"`, `"desktop"`), `locale` (a BCP 47 tag), `rtl`,
 /// `reduceMotion`, `fontScale`, `pixelRatio`, `safeArea` (`[top, right,
 /// bottom, left]`), `keyboard` (the height the on-screen keyboard
-/// covers) and `appleKeyboard` (the browser runs on an Apple platform: its
-/// shortcuts are Apple's); missing keys keep their value. Called before [`nsh_start`]
-/// and on every change; the idiom is the one at start, as on every
+/// covers), `appleKeyboard` (the browser runs on an Apple platform: its
+/// shortcuts are Apple's) and `host` (the page answers `nsh/host` calls:
+/// `createPlayer`'s `host`); missing keys keep their value. Called before [`nsh_start`]
+/// and on every change; the idiom and `host` are the ones at start, as on every
 /// platform. Returns the pump delay.
 ///
 /// # Safety
@@ -391,6 +392,7 @@ pub unsafe extern "C" fn nsh_environment(ptr: *const u8, len: usize) -> f64 {
         .map(|&v| nsh_core::Points(v));
     }
     env.keyboard_height = Points(num("keyboard").unwrap_or(env.keyboard_height.0));
+    env.host = flag("host").unwrap_or(env.host);
     PAGE.with(|p| p.set(Some(env)));
     send(|app| {
         let e = &mut app.environment;
@@ -1159,6 +1161,7 @@ pub unsafe extern "C" fn nsh_event(
             text: text("text"),
             count: num("count") as u32,
         },
+        EventKind::Host => EventPayload::Json(value),
         _ => match &value {
             serde_json::Value::Object(map) => EventPayload::Map(
                 map.iter()
diff --git a/third-party/nsh/crates/nsh-platform-web/web/nsh.js b/third-party/nsh/crates/nsh-platform-web/web/nsh.js
index c4741b6f7d..e216230f73 100644
--- a/third-party/nsh/crates/nsh-platform-web/web/nsh.js
+++ b/third-party/nsh/crates/nsh-platform-web/web/nsh.js
@@ -20,7 +20,13 @@
 // runs, such as fonts for `readAsset`; awaited with the chunk's download),
 // `dark`, `idiom` ("phone", "pad" or "desktop" instead of the one the
 // pointer and the viewport suggest), `overlay` (false: no error box),
-// `onError`, `onLog`.
+// `onError`, `onLog`, `host` (the functions `nsh/host`'s
+// `host.call(name, args)` reaches: an object whose `call(name, args)`
+// returns the result or a promise of it; a throw or a rejection rejects
+// the app's call; without it `host.available()` is false). The page sends
+// `host.on` listeners events with `player.hostEvent(event, payload)`
+// (JSON; it throws when the payload is not); events sent before the app
+// runs are delivered, in order, once it starts.
 //
 // A split bundle (`nsh build --split`) loads its chunks on demand: from
 // `chunks[id]` when given (`app.<digest>.js` on a CDN), else `id` relative
@@ -535,6 +541,8 @@ class Player {
     // default only when the app is the page (in an iframe each push would
     // be an entry of the embedding page's history too).
     this.history = options.history ?? window.top === window;
+    // Host events sent before the app runs (`begin`).
+    this.hostEvents = [];
     if (window.top === window) document.documentElement.classList.add("nsh-page");
   }
 
@@ -1000,13 +1008,25 @@ class Player {
     this.idiom = this.options.idiom ?? this.measureIdiom(width);
     const environment = (this.reported = JSON.stringify(this.environment()));
     this.updateRootClasses();
+    // Host events wait for this app's top-level `host.on` listeners, not
+    // the replaced app's; those queued after a failed start are dropped.
+    this.started = false;
+    this.hostEvents ??= [];
     // Fonts registered while it starts hold its first frame back (`fontLoading`).
     this.starting = true;
     try {
       this.call(() => start(environment));
+    } catch (err) {
+      // Only a trap (a panic) throws: no app runs to take events.
+      this.hostEvents = null;
+      throw err;
     } finally {
       this.starting = false;
     }
+    this.started = true;
+    const events = this.hostEvents;
+    this.hostEvents = [];
+    for (const json of events) this.eventJson(ROOT_ID, "onHost", json);
   }
 
   /** Fetches chunk `id` of the running bundle and hands it to the engine. */
@@ -1068,6 +1088,9 @@ class Player {
   }
 
   stop() {
+    // Host events wait for the next app (`begin`).
+    this.started = false;
+    this.hostEvents ??= [];
     this.fileOperations.clear();
     for (const id of [...this.requests.keys()]) this.fetchCancel(id);
     for (const id of [...this.websockets.keys()]) this.websocketCancel(id);
@@ -1219,6 +1242,7 @@ class Player {
       safeArea: this.safeArea(),
       keyboard: this.keyboardHeight(),
       appleKeyboard: APPLE,
+      host: Boolean(this.options.host),
     };
   }
 
@@ -1239,7 +1263,12 @@ class Player {
   }
 
   event(id, name, payload = null) {
-    this.call(() => this.withStrings([name, JSON.stringify(payload)], (np, nl, pp, pl) =>
+    this.eventJson(id, name, JSON.stringify(payload));
+  }
+
+  /** {@link event} with its payload already serialized. */
+  eventJson(id, name, json) {
+    this.call(() => this.withStrings([name, json], (np, nl, pp, pl) =>
       this.wasm.nsh_event(id, np, nl, pp, pl)));
   }
 
@@ -3479,6 +3508,7 @@ class Player {
       case "readClipboardImage": this.readClipboardImage(args[0], args[1]); break;
       // Pages see no local paths: never any files.
       case "readClipboardFiles": this.event(ROOT_ID, "onClipboard", { id: args[0], has: false, files: "" }); break;
+      case "hostCall": this.hostCall(args[0], args[1], args[2]); break;
       case "imageInfo": this.imageInfo(args[0], args[1]); break;
       case "back": this.defaultBack(); break;
       case "exitApp": {
@@ -3586,6 +3616,39 @@ class Player {
     img.src = uri;
   }
 
+  /**
+   * `nsh/host`'s `host.call`: `options.host.call(name, args)`, answered
+   * with `onHost` `{ type: "reply" }` whatever happens (the app's call waits for the
+   * reply): a missing host, a throw, a rejection or a result that is not
+   * JSON answer `ok: false` with the error's message.
+   */
+  async hostCall(request, name, args) {
+    // Answers for an app that was replaced meanwhile are dropped.
+    const app = this.app;
+    let reply;
+    try {
+      const host = this.options.host;
+      if (typeof host?.call !== "function") throw new Error("this page has no host (createPlayer's `host`)");
+      const value = (await host.call(name, args)) ?? null;
+      reply = JSON.stringify({ type: "reply", id: request, ok: true, value });
+    } catch (err) {
+      reply = JSON.stringify({ type: "reply", id: request, ok: false, error: String(err?.message ?? err) });
+    }
+    if (app === this.app) this.eventJson(ROOT_ID, "onHost", reply);
+  }
+
+  /**
+   * Sends `event` with `payload` to the app's `host.on(event)` listeners.
+   * The payload is serialized here, so one that is not JSON throws to the
+   * caller rather than inside a deferred engine call. Events sent before
+   * the app runs wait for it (`begin`).
+   */
+  hostEvent(event, payload = null) {
+    const json = JSON.stringify({ type: "event", event: String(event), payload: payload ?? null });
+    if (this.started) this.eventJson(ROOT_ID, "onHost", json);
+    else this.hostEvents?.push(json);
+  }
+
   /** `Image.getSize` / `prefetch`: loads the image (the browser keeps it cached for the views that show it). */
   imageInfo(request, uri) {
     const img = new Image();
diff --git a/third-party/nsh/crates/nsh-platform-web/web/nsh.test.mjs b/third-party/nsh/crates/nsh-platform-web/web/nsh.test.mjs
new file mode 100644
index 0000000000..62270330d0
--- /dev/null
+++ b/third-party/nsh/crates/nsh-platform-web/web/nsh.test.mjs
@@ -0,0 +1,167 @@
+// nsh.js's host channel without a browser or nsh.wasm:
+// `node --test crates/nsh-platform-web/web/nsh.test.mjs`. A fake engine
+// records what reaches it through the real `call` / `withStrings` path.
+
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+const source = await readFile(new URL("nsh.js", import.meta.url), "utf8");
+// `Player` is not exported; the page only gets it from `createPlayer`.
+const { Player } = await import(
+  `data:text/javascript,${encodeURIComponent(`${source}\nexport { Player };`)}`
+);
+
+/** A player on a fake engine; `sent` holds `[name, payload]` of the events it got. */
+function fakePlayer(options = {}) {
+  const sent = [];
+  const memory = { buffer: new ArrayBuffer(1 << 16) };
+  let next = 8;
+  const text = (ptr, len) => new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, len));
+  const player = Object.create(Player.prototype);
+  Object.assign(player, {
+    // What the constructor sets that these paths read.
+    options: { idiom: "phone", ...options },
+    hostEvents: [],
+    fileOperations: new Map(),
+    requests: new Map(),
+    websockets: new Map(),
+    environment: () => ({}),
+    updateRootClasses() {},
+    wasm: {
+      memory,
+      nsh_alloc(len) {
+        const ptr = next;
+        next += len;
+        return ptr;
+      },
+      nsh_dealloc() {},
+      nsh_stop() {},
+      nsh_event(_id, np, nl, pp, pl) {
+        sent.push([text(np, nl), JSON.parse(text(pp, pl))]);
+        return -1;
+      },
+    },
+  });
+  return { player, sent };
+}
+
+const started = () => -1;
+const failing = () => {
+  throw new Error("unreachable");
+};
+const events = (sent) =>
+  sent.filter(([name, p]) => name === "onHost" && p.type === "event").map(([, p]) => p.event);
+
+test("events sent before the app runs reach it in order once it starts, later ones at once", () => {
+  const { player, sent } = fakePlayer();
+  player.hostEvent("a", 1);
+  player.hostEvent("b", 2);
+  assert.deepEqual(sent, []);
+  player.begin(390, started);
+  assert.deepEqual(sent, [
+    ["onHost", { type: "event", event: "a", payload: 1 }],
+    ["onHost", { type: "event", event: "b", payload: 2 }],
+  ]);
+  player.hostEvent("c");
+  assert.deepEqual(events(sent), ["a", "b", "c"]);
+});
+
+test("events for an app that failed to start reach neither it nor the next app", () => {
+  const { player, sent } = fakePlayer();
+  player.hostEvent("before");
+  assert.throws(() => player.begin(390, failing));
+  player.hostEvent("after");
+  player.begin(390, started);
+  player.hostEvent("next");
+  assert.deepEqual(events(sent), ["next"]);
+});
+
+test("a replacing app that fails to start gets no events", () => {
+  const { player, sent } = fakePlayer();
+  player.begin(390, started);
+  assert.throws(() => player.begin(390, failing));
+  player.hostEvent("lost");
+  assert.deepEqual(events(sent), []);
+});
+
+test("events sent while a replacing app starts reach it once it has started", () => {
+  const { player, sent } = fakePlayer();
+  player.begin(390, started);
+  let during;
+  player.begin(390, () => {
+    player.hostEvent("early");
+    during = events(sent).length;
+    return -1;
+  });
+  assert.equal(during, 0);
+  assert.deepEqual(events(sent), ["early"]);
+});
+
+test("events sent after the app stopped wait for the next app", () => {
+  // `stop` cancels the frame it asked for; node has no frames.
+  globalThis.cancelAnimationFrame ??= () => {};
+  const { player, sent } = fakePlayer();
+  player.begin(390, started);
+  player.hostEvent("running");
+  player.stop();
+  player.hostEvent("stopped");
+  assert.deepEqual(events(sent), ["running"]);
+  player.begin(390, started);
+  assert.deepEqual(events(sent), ["running", "stopped"]);
+});
+
+async function reply(host) {
+  const { player, sent } = fakePlayer(host === undefined ? {} : { host });
+  player.app = 1;
+  await player.hostCall(7, "echo", ["x"]);
+  return sent;
+}
+
+test("a host call that returns answers ok with its value", async () => {
+  assert.deepEqual(await reply({ call: async (name, args) => ({ name, args }) }), [
+    ["onHost", { type: "reply", id: 7, ok: true, value: { name: "echo", args: ["x"] } }],
+  ]);
+});
+
+test("a host call that throws answers not ok with its message", async () => {
+  const host = {
+    call() {
+      throw new Error("boom");
+    },
+  };
+  assert.deepEqual(await reply(host), [["onHost", { type: "reply", id: 7, ok: false, error: "boom" }]]);
+});
+
+test("a host call that rejects answers not ok with its message", async () => {
+  const host = { call: () => Promise.reject(new Error("denied")) };
+  assert.deepEqual(await reply(host), [["onHost", { type: "reply", id: 7, ok: false, error: "denied" }]]);
+});
+
+test("a host call that rejects with a non-Error answers its string", async () => {
+  const host = { call: () => Promise.reject("nope") };
+  assert.deepEqual(await reply(host), [["onHost", { type: "reply", id: 7, ok: false, error: "nope" }]]);
+});
+
+test("a host call whose result is not JSON answers not ok", async () => {
+  const [[name, payload]] = await reply({ call: () => 1n });
+  assert.equal(name, "onHost");
+  assert.equal(payload.type, "reply");
+  assert.equal(payload.ok, false);
+  assert.match(payload.error, /BigInt/);
+});
+
+test("a page without a host answers not ok", async () => {
+  assert.deepEqual(await reply(undefined), [
+    ["onHost", { type: "reply", id: 7, ok: false, error: "this page has no host (createPlayer's `host`)" }],
+  ]);
+});
+
+test("a host reply for an app that was replaced is dropped", async () => {
+  const { player, sent } = fakePlayer({ host: { call: () => Promise.reject(new Error("late")) } });
+  player.app = 1;
+  const pending = player.hostCall(7, "echo", []);
+  player.app = 2;
+  await pending;
+  assert.deepEqual(sent, []);
+});
diff --git a/third-party/nsh/crates/nsh-platform-windows/src/app.rs b/third-party/nsh/crates/nsh-platform-windows/src/app.rs
index be22d98750..19abade7ec 100644
--- a/third-party/nsh/crates/nsh-platform-windows/src/app.rs
+++ b/third-party/nsh/crates/nsh-platform-windows/src/app.rs
@@ -287,6 +287,21 @@ impl AppView {
         crate::system::host_environment_changed(&self.surface);
     }
 
+    /// The embedding app's theme overrides: a theme file of shadcn/ui's
+    /// (`{ "cssVars": { "theme": { … }, "light": { … }, "dark": { … } } }`,
+    /// values as CSS writes them, as `nsh.json`'s `"theme"`), whose tokens
+    /// win over the app's of the same names, light and dark, while the
+    /// app's others stay (a brand's `primary` and `radius`). Classes naming
+    /// them (`bg-primary`) are resolved again at once, and every app loaded
+    /// later starts with them. `None` gives the app its own theme back.
+    /// Fails, changing nothing, on a file that does not parse.
+    pub fn set_theme_overrides(&self, json: Option<&str>) -> Result<(), nsh_base::ThemeError> {
+        let tokens = json.map(nsh_base::theme_overrides).transpose()?;
+        *self.surface.host_tokens.borrow_mut() = tokens.clone();
+        self.surface.control(|c| c.set_tokens(tokens));
+        Ok(())
+    }
+
     /// Stops the app.
     pub fn stop(&self) {
         self.surface.stop();
diff --git a/third-party/nsh/crates/nsh-platform-windows/src/surface.rs b/third-party/nsh/crates/nsh-platform-windows/src/surface.rs
index eba953bbe1..d2e20d7a24 100644
--- a/third-party/nsh/crates/nsh-platform-windows/src/surface.rs
+++ b/third-party/nsh/crates/nsh-platform-windows/src/surface.rs
@@ -139,6 +139,10 @@ pub(crate) struct Surface {
     /// The embedding app's locale and text size, standing in for the
     /// system's ([`crate::AppView::set_locale`], `set_font_scale`).
     pub(crate) host_environment: Cell<nsh_core::platform::HostEnvironment>,
+    /// The embedding app's theme overrides
+    /// ([`crate::AppView::set_theme_overrides`]): sent to every engine the
+    /// surface starts, before its first frame.
+    pub(crate) host_tokens: RefCell<Option<nsh_engine::Tokens>>,
     /// JS asked for a display frame.
     js_frame: Cell<bool>,
     /// Paces animation frames while something animates.
@@ -203,6 +207,7 @@ impl Surface {
             environment: Cell::new(environment),
             appearance: Cell::default(),
             host_environment: Cell::default(),
+            host_tokens: RefCell::new(None),
             js_frame: Cell::new(false),
             ticker: RefCell::new(None),
             frame_clients: RefCell::default(),
@@ -300,7 +305,14 @@ impl Surface {
         *self.rx.borrow_mut() = Some(rx);
         *self.pending.borrow_mut() = pending;
         let family = crate::text::ui_family();
-        match Engine::start(config, move || DWriteTextEngine::new(family), sink) {
+        // The embedding app's theme overrides are in the first frame.
+        let tokens = self.host_tokens.borrow().clone();
+        let setup = |control: &EngineControl| {
+            if tokens.is_some() {
+                control.set_tokens(tokens);
+            }
+        };
+        match Engine::start_with(config, move || DWriteTextEngine::new(family), sink, setup) {
             Ok(engine) => {
                 // The test moves its clock through the engine.
                 #[cfg(feature = "dev")]
diff --git a/third-party/nsh/crates/nsh-testing/Cargo.toml b/third-party/nsh/crates/nsh-testing/Cargo.toml
index a833c5bb39..4cdd7ee9cd 100644
--- a/third-party/nsh/crates/nsh-testing/Cargo.toml
+++ b/third-party/nsh/crates/nsh-testing/Cargo.toml
@@ -22,8 +22,9 @@ live = ["dep:ureq"]
 # hosts link it.
 agent = ["dep:nsh-dev", "dep:serde", "dep:serde_json", "dep:base64"]
 # `nsh test`'s side: the `nsh/test` module, test files on an engine of their
-# own (`run_file`) and apps' headless runners (`runner`): QuickJS.
-js = ["live", "nsh-engine/js", "dep:nsh-dev", "dep:base64", "dep:dunce", "dep:serde", "dep:serde_json", "dep:tokio"]
+# own (`run_file`) and apps' headless runners (`runner`): QuickJS, which also
+# runs the web page's host module (`page_host`).
+js = ["live", "nsh-engine/js", "dep:nsh-dev", "dep:base64", "dep:dunce", "dep:rquickjs", "dep:serde", "dep:serde_json", "dep:tokio"]
 # Building entries in this process (`compile_tsx`, `tsx`, `compiler`): nsh's
 # bundler. Off in apps' headless runners, which `nsh` builds the entries for.
 bundle = ["js", "dep:nsh-bundler", "dep:nsh-js"]
@@ -39,6 +40,8 @@ crossbeam-channel.workspace = true
 dunce = { workspace = true, optional = true }
 log.workspace = true
 regex.workspace = true
+# The web page's host module of headless runs (`page_host`).
+rquickjs = { workspace = true, optional = true }
 serde = { workspace = true, optional = true }
 serde_json = { workspace = true, optional = true }
 # The requests routes' JS functions answer, waiting for the test file's VM.
diff --git a/third-party/nsh/crates/nsh-testing/src/agent.rs b/third-party/nsh/crates/nsh-testing/src/agent.rs
index 5cf050d728..576ee8cb7a 100644
--- a/third-party/nsh/crates/nsh-testing/src/agent.rs
+++ b/third-party/nsh/crates/nsh-testing/src/agent.rs
@@ -851,15 +851,11 @@ pub trait Native: Send + Sync {
     }
     /// Presses `keys`, a keystroke as `nsh/keymap` names it (`"mod+k"`;
     /// `mod` the platform's command key), on a hardware keyboard: the app's
-    /// shortcuts hear it, else what has the keyboard focus. Platforms
-    /// without app shortcuts (the phones) refuse it.
+    /// shortcuts hear it, else what has the keyboard focus. Every
+    /// platform's agent makes them; one that cannot refuses it.
     fn press_keys(&self, keys: &str) -> Result<(), String> {
         let _ = keys;
-        Err(
-            "this platform has no keyboard shortcuts (`nsh/keymap`) to press: \
-             run the test headless, or on a desktop or the web"
-                .into(),
-        )
+        Err("this platform's test agent presses no hardware keys: run the test headless".into())
     }
     /// Types `text` into the focused input, key by key.
     fn type_text(&self, text: &str);
diff --git a/third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs b/third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs
index 611d89735f..65d46072a2 100644
--- a/third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs
+++ b/third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs
@@ -1,9 +1,10 @@
 //! The step `keyboard.press(<keys>)`: a key with its modifiers, as a
-//! hardware keyboard gives it. On a desktop platform (`open`'s
-//! `platform`) the app's shortcuts (`nsh/keymap`) take it first, as the
-//! platforms' keymaps do: the newest binding of the
-//! keystroke that fires where the focus is reports `onKeyCommand`. Else
-//! the focused input takes the keys that edit it, and Escape goes back.
+//! hardware keyboard gives it, on every platform (`open`'s `platform`: the
+//! phones and tablets have one too, a tablet's keyboard, a Chromebook's).
+//! The app's shortcuts (`nsh/keymap`) take it first, as the platforms'
+//! keymaps do: the newest binding of the keystroke that fires where the
+//! focus is reports `onKeyCommand`. Else the focused input takes the keys
+//! that edit it, and Escape goes back.
 
 use nsh_core::{
     EventKind, EventPayload, PayloadValue, ROOT_ID,
@@ -24,10 +25,11 @@ impl Headless {
         self.step(format!("keyboard.press({keys})"));
         let stroke = Keystroke::parse(keys, self.environment.os).map_err(|e| e.to_string())?;
         let editing = self.screen.focused().is_some();
-        // Desktop platforms fire the app's shortcuts (`on_key`, `keymap.bind`).
+        // A hardware keyboard fires the app's shortcuts (`on_key`,
+        // `keymap.bind`), on every platform.
         let found = match self.screen.keymap() {
-            Some(keymap) if self.environment.desktop() => keymap.find(stroke, editing, false),
-            _ => KeyMatch::None,
+            Some(keymap) => keymap.find(stroke, editing, false),
+            None => KeyMatch::None,
         };
         if let KeyMatch::Fire(id) = found {
             self.engine.control().dispatch_event(
diff --git a/third-party/nsh/crates/nsh-testing/src/headless/mod.rs b/third-party/nsh/crates/nsh-testing/src/headless/mod.rs
index 2b1c8cc23e..d597999896 100644
--- a/third-party/nsh/crates/nsh-testing/src/headless/mod.rs
+++ b/third-party/nsh/crates/nsh-testing/src/headless/mod.rs
@@ -158,6 +158,11 @@ type Observer = Box<dyn FnMut(&str, &MountBatch) + Send>;
 /// Answers a dialog ([`Headless::on_dialog`]).
 type DialogHandler = Box<dyn FnMut(&Dialog) -> DialogAnswer + Send>;
 
+/// Takes the app's `nsh/host` calls ([`Headless::on_host_call`]): their
+/// id, the host's function and its argument.
+#[cfg(feature = "js")]
+type HostCalls = Box<dyn FnMut(u64, &str, &nsh_core::JsonValue) + Send>;
+
 /// The answer to a dialog (`AlertDialog.show`, an action sheet).
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum DialogAnswer {
@@ -213,7 +218,8 @@ enum Pumped {
 /// It plays the platform's part the NSH protocol defines and nothing
 /// else: the native animation graph runs frame by frame (nsh-core's `animation`),
 /// a back pops screens and closes modals, dialogs are answered (by the
-/// test, else [`default_answer`]), the clipboard holds text. Images are
+/// test, else [`default_answer`]), the clipboard holds text, and `nsh
+/// test`'s web page host answers `nsh/host` calls. Images are
 /// not loaded: no `onLoad` comes, and `Image.getSize` / `Image.prefetch`
 /// fail.
 pub struct Headless {
@@ -238,6 +244,9 @@ pub struct Headless {
     clock: Clock,
     /// On a manual clock: the engine asked for a frame.
     wanted: Option<Arc<AtomicBool>>,
+    /// Where the app's `nsh/host` calls go (the web page's host).
+    #[cfg(feature = "js")]
+    host_calls: Option<HostCalls>,
 }
 
 impl Headless {
@@ -270,6 +279,8 @@ impl Headless {
             observer: None,
             clock,
             wanted,
+            #[cfg(feature = "js")]
+            host_calls: None,
         })
     }
 
@@ -448,6 +459,18 @@ impl Headless {
         self.done()
     }
 
+    /// Sends the app's `nsh/host` calls (the root's `Command::HostCall`) to
+    /// `calls` as their batches are applied; the environment must say the
+    /// platform has a host (`Environment::host`), else the core rejects
+    /// them before they reach a batch.
+    #[cfg(feature = "js")]
+    pub(crate) fn on_host_call(
+        &mut self,
+        calls: impl FnMut(u64, &str, &nsh_core::JsonValue) + Send + 'static,
+    ) {
+        self.host_calls = Some(Box::new(calls));
+    }
+
     /// Puts `text` on the clipboard (`None`: nothing), as the user copying.
     pub fn set_clipboard(&mut self, text: Option<String>) {
         self.screen.set_clipboard(text);
@@ -479,6 +502,18 @@ impl Headless {
         for item in &batch.items {
             self.animator.apply(item);
         }
+        #[cfg(feature = "js")]
+        if let Some(calls) = &mut self.host_calls {
+            for item in &batch.items {
+                if let MountItem::Command {
+                    id: ROOT_ID,
+                    command: Command::HostCall { id, name, args },
+                } = item
+                {
+                    calls(*id, name, args);
+                }
+            }
+        }
         self.applied += 1;
         if let Some(observer) = &mut self.observer {
             observer(&self.step, batch);
@@ -1738,10 +1773,14 @@ mod tests {
                 .unwrap_err()
                 .contains("nothing")
         );
-        // Phones fire no app shortcuts.
+        // Phones and tablets fire them from a hardware keyboard too; on
+        // Android `mod` is Control.
         let mut phone = open_shortcuts(nsh_core::platform::Os::Ios);
-        phone.press_key("mod+k").unwrap();
-        assert!(phone.shows("Palette 0"));
+        phone.press_key("cmd+k").unwrap();
+        phone.wait_for("Palette 1").unwrap();
+        let mut android = open_shortcuts(nsh_core::platform::Os::Android);
+        android.press_key("ctrl+k").unwrap();
+        android.wait_for("Palette 1").unwrap();
     }
 
     #[cfg(feature = "js")]
diff --git a/third-party/nsh/crates/nsh-testing/src/lib.rs b/third-party/nsh/crates/nsh-testing/src/lib.rs
index d2da6a7750..33baf2fd5f 100644
--- a/third-party/nsh/crates/nsh-testing/src/lib.rs
+++ b/third-party/nsh/crates/nsh-testing/src/lib.rs
@@ -39,6 +39,8 @@ pub mod menu;
 #[cfg(feature = "js")]
 mod module;
 #[cfg(feature = "js")]
+mod page_host;
+#[cfg(feature = "js")]
 mod run;
 #[cfg(feature = "js")]
 mod runner;
diff --git a/third-party/nsh/crates/nsh-testing/src/page_host.rs b/third-party/nsh/crates/nsh-testing/src/page_host.rs
new file mode 100644
index 0000000000..ddd7861c98
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/src/page_host.rs
@@ -0,0 +1,332 @@
+//! The web page's host in headless runs ([`TestRun::page_host`]): the
+//! module `nsh.json` `web.host` names, answering the `nsh/host` calls of
+//! the apps tests open.
+//!
+//! The module is the page's as it is: its `createHost(player)` runs in a
+//! QuickJS context of its own, on a thread of its own, once per app opened
+//! (a page loads it once per app), before the app starts. What it returns
+//! answers every `Command::HostCall` with a `Host` `{ type: "reply" }`, as
+//! `nsh.js`'s `hostCall` does on the page: always, `ok: false` with the
+//! message when `call` throws, rejects or returns what is not JSON.
+//! `player.hostEvent(event, payload)` sends `Host` `{ type: "event" }`;
+//! what is sent before the app's engine runs waits and reaches it in order
+//! once it does. The context has `console` (the log's target `js`, as the
+//! app's), `setTimeout` / `setInterval` and their `clear*` on the app's
+//! clock (a manual one moves them as it moves the app's timers), and
+//! nothing of the browser's;
+//! imports do not resolve (the page loads the module as it is, so its
+//! imports are absolute URLs a headless run cannot load either).
+//!
+//! [`TestRun::page_host`]: crate::TestRun::page_host
+
+use std::{
+    path::Path,
+    sync::{Arc, Mutex},
+    time::{Duration, Instant},
+};
+
+use crossbeam_channel::{Receiver, Sender};
+use nsh_core::{EventKind, EventPayload, JsonValue, ROOT_ID};
+use nsh_engine::{Clock, EngineControl};
+use rquickjs::{CatchResultExt as _, Ctx, Function, Module, Object, Value};
+
+/// The page's host of one app: where its calls go, and where the host's
+/// replies and events go.
+pub(crate) struct PageHost {
+    calls: Sender<Call>,
+    target: Arc<Target>,
+}
+
+impl PageHost {
+    /// Loads the module at `path` and runs its `createHost(player)`. Fails
+    /// when the module does not load, exports no `createHost`, or what
+    /// `createHost` returns (or resolves with, as far as its promise
+    /// settles at once) has no `call`.
+    pub(crate) fn start(path: &Path, clock: Clock) -> Result<PageHost, String> {
+        let source = std::fs::read_to_string(path)
+            .map_err(|e| format!("nsh.json web.host {}: {e}", path.display()))?;
+        let name = path.display().to_string();
+        let target = Arc::new(Target::default());
+        let (calls, rx) = crossbeam_channel::unbounded();
+        let (loaded, load) = crossbeam_channel::bounded(1);
+        let thread_target = target.clone();
+        std::thread::Builder::new()
+            .name("nsh-test-page-host".into())
+            .spawn(move || run(&name, source, clock, thread_target, rx, loaded))
+            .map_err(|e| e.to_string())?;
+        load.recv()
+            .unwrap_or_else(|_| Err("the host module stopped".into()))
+            .map_err(|e| format!("nsh.json web.host {}: {e}", path.display()))?;
+        Ok(PageHost { calls, target })
+    }
+
+    /// The app runs on `control`: delivers what waited, then everything as
+    /// it comes.
+    pub(crate) fn attach(&self, control: EngineControl) {
+        self.target.attach(control);
+    }
+
+    /// The app's call `id` of the host's function `name` with `args`.
+    pub(crate) fn call(&self, id: u64, name: &str, args: &JsonValue) {
+        let _ = self.calls.send(Call {
+            id,
+            name: name.to_owned(),
+            args: args.clone(),
+        });
+    }
+}
+
+/// Where the module's replies and events go: the engine once it runs; what
+/// is sent before waits, in order, as the page holds events sent before
+/// the app runs.
+#[derive(Default)]
+struct Target(Mutex<TargetState>);
+
+#[derive(Default)]
+struct TargetState {
+    control: Option<EngineControl>,
+    pending: Vec<JsonValue>,
+}
+
+impl Target {
+    fn attach(&self, control: EngineControl) {
+        let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner());
+        for value in state.pending.drain(..) {
+            control.dispatch_event(ROOT_ID, EventKind::Host, EventPayload::Json(value));
+        }
+        state.control = Some(control);
+    }
+
+    fn send(&self, value: JsonValue) {
+        let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner());
+        match &state.control {
+            Some(control) => {
+                control.dispatch_event(ROOT_ID, EventKind::Host, EventPayload::Json(value))
+            }
+            None => state.pending.push(value),
+        }
+    }
+}
+
+/// A call the app made: `Command::HostCall`'s fields.
+struct Call {
+    id: u64,
+    name: String,
+    args: JsonValue,
+}
+
+/// What the module's context runs before the module: the timers and the
+/// player, and how calls are answered (as `nsh.js` `hostCall`).
+const GLUE: &str = r#"
+(() => {
+  const timers = new Map();
+  let nextTimer = 1;
+  const add = (f, ms, args, repeat) => {
+    const id = nextTimer++;
+    const delay = Math.max(0, Number(ms) || 0);
+    timers.set(id, { f, args, delay, repeat, due: __nshNow() + delay });
+    return id;
+  };
+  globalThis.setTimeout = (f, ms, ...args) => add(f, ms, args, false);
+  globalThis.setInterval = (f, ms, ...args) => add(f, ms, args, true);
+  globalThis.clearTimeout = globalThis.clearInterval = (id) => { timers.delete(id); };
+  const print = (level) => (...args) => __nshLog(level, args.map(String).join(" "));
+  globalThis.console = { log: print("info"), info: print("info"), warn: print("warn"), error: print("error") };
+
+  // Runs the timers that are due; when the next one is (ms since the start), or -1.
+  globalThis.__nshTimers = (now) => {
+    for (const [id, t] of [...timers]) {
+      if (t.due > now || !timers.has(id)) continue;
+      if (t.repeat) t.due = now + Math.max(1, t.delay);
+      else timers.delete(id);
+      try {
+        t.f(...t.args);
+      } catch (e) {
+        console.error(`web.host timer: ${e?.message ?? e}`);
+      }
+    }
+    let next = -1;
+    for (const t of timers.values()) if (next < 0 || t.due < next) next = t.due;
+    return next;
+  };
+
+  const player = {
+    hostEvent(event, payload = null) {
+      __nshSend(JSON.stringify({ type: "event", event: String(event), payload: payload ?? null }));
+    },
+  };
+  let ready;
+  let failed = "";
+  globalThis.__nshInstall = (evaluated, module) => {
+    ready = evaluated.then(() => {
+      if (typeof module.createHost !== "function") throw new Error("the module exports no createHost(player)");
+      return module.createHost(player);
+    }).then((host) => {
+      if (typeof host?.call !== "function") throw new Error("createHost(player) returned no call(name, args)");
+      return host;
+    });
+    ready.catch((e) => { failed = String(e?.message ?? e); });
+  };
+  globalThis.__nshFailed = () => failed;
+  globalThis.__nshCall = async (id, name, args) => {
+    let reply;
+    try {
+      const host = await ready;
+      const value = (await host.call(name, JSON.parse(args))) ?? null;
+      reply = JSON.stringify({ type: "reply", id, ok: true, value });
+    } catch (e) {
+      reply = JSON.stringify({ type: "reply", id, ok: false, error: String(e?.message ?? e) });
+    }
+    __nshSend(reply);
+  };
+})();
+"#;
+
+fn run(
+    name: &str,
+    source: String,
+    clock: Clock,
+    target: Arc<Target>,
+    calls: Receiver<Call>,
+    loaded: Sender<Result<(), String>>,
+) {
+    // The module's time: milliseconds of the app's clock since it loaded.
+    let origin = clock.now();
+    let setup = || -> Result<(rquickjs::Runtime, rquickjs::Context), String> {
+        let runtime = rquickjs::Runtime::new().map_err(|e| e.to_string())?;
+        let context = rquickjs::Context::full(&runtime).map_err(|e| e.to_string())?;
+        context.with(|cx| install(&cx, name, source, &target, &clock, origin))?;
+        drain(&runtime);
+        let failed: String = context.with(|cx| eval(&cx, "__nshFailed()"))?;
+        if !failed.is_empty() {
+            return Err(failed);
+        }
+        Ok((runtime, context))
+    };
+    let (runtime, context) = match setup() {
+        Ok(js) => {
+            let _ = loaded.send(Ok(()));
+            js
+        }
+        Err(err) => {
+            let _ = loaded.send(Err(err));
+            return;
+        }
+    };
+    // The clock's alarms wake the thread when the next timer is due.
+    let (wake, woken) = crossbeam_channel::unbounded::<()>();
+    let mut alarm: Option<f64> = None;
+    loop {
+        let next = context
+            .with(|cx| {
+                let f: Function = cx.globals().get("__nshTimers")?;
+                f.call::<_, f64>((ms(&clock, origin),))
+            })
+            .unwrap_or(-1.0);
+        drain(&runtime);
+        if next >= 0.0 && alarm.is_none_or(|at| next < at) {
+            alarm = Some(next);
+            let wake = wake.clone();
+            clock.at(origin + Duration::from_secs_f64(next / 1000.0), move || {
+                let _ = wake.send(());
+            });
+        }
+        crossbeam_channel::select! {
+            recv(calls) -> call => {
+                // The app closed.
+                let Ok(call) = call else { return };
+                let answered = context.with(|cx| -> Result<(), String> {
+                    let f: Function = cx.globals().get("__nshCall").map_err(|e| e.to_string())?;
+                    let args = call.args.to_string();
+                    f.call::<_, Value>((call.id as f64, call.name, args))
+                        .catch(&cx)
+                        .map_err(|e| e.to_string())?;
+                    Ok(())
+                });
+                if let Err(err) = answered {
+                    log::error!(target: "js", "web.host: {err}");
+                }
+            }
+            recv(woken) -> _ => alarm = None,
+        }
+        drain(&runtime);
+    }
+}
+
+/// Defines the glue and its native functions, then loads the module and
+/// starts its `createHost`.
+fn install(
+    cx: &Ctx<'_>,
+    name: &str,
+    source: String,
+    target: &Arc<Target>,
+    clock: &Clock,
+    origin: Instant,
+) -> Result<(), String> {
+    let error = |e: rquickjs::Error| e.to_string();
+    let globals = cx.globals();
+    let send = {
+        let target = target.clone();
+        move |json: String| match serde_json::from_str(&json) {
+            Ok(value) => target.send(value),
+            Err(err) => log::error!(target: "js", "web.host: {err}"),
+        }
+    };
+    globals
+        .set("__nshSend", Function::new(cx.clone(), send).map_err(error)?)
+        .map_err(error)?;
+    globals
+        .set(
+            "__nshNow",
+            Function::new(cx.clone(), {
+                let clock = clock.clone();
+                move || ms(&clock, origin)
+            })
+            .map_err(error)?,
+        )
+        .map_err(error)?;
+    globals
+        .set(
+            "__nshLog",
+            Function::new(cx.clone(), |level: String, message: String| {
+                let level = match level.as_str() {
+                    "error" => log::Level::Error,
+                    "warn" => log::Level::Warn,
+                    _ => log::Level::Info,
+                };
+                log::log!(target: "js", level, "{message}");
+            })
+            .map_err(error)?,
+        )
+        .map_err(error)?;
+    eval::<Value>(cx, GLUE)?;
+    let (module, evaluated) = Module::declare(cx.clone(), name, source)
+        .and_then(Module::eval)
+        .catch(cx)
+        .map_err(|e| e.to_string())?;
+    let namespace: Object = module.namespace().map_err(error)?;
+    let install: Function = globals.get("__nshInstall").map_err(error)?;
+    install
+        .call::<_, Value>((evaluated, namespace))
+        .catch(cx)
+        .map_err(|e| e.to_string())?;
+    Ok(())
+}
+
+fn eval<'js, V: rquickjs::FromJs<'js>>(cx: &Ctx<'js>, source: &str) -> Result<V, String> {
+    cx.eval::<V, _>(source).catch(cx).map_err(|e| e.to_string())
+}
+
+/// Runs the promise jobs that are queued.
+fn drain(runtime: &rquickjs::Runtime) {
+    while runtime.is_job_pending() {
+        if let Err(err) = runtime.execute_pending_job() {
+            log::error!(target: "js", "web.host: {err}");
+        }
+    }
+}
+
+/// Milliseconds of `clock` since `origin`.
+fn ms(clock: &Clock, origin: Instant) -> f64 {
+    clock.now().duration_since(origin).as_secs_f64() * 1000.0
+}
diff --git a/third-party/nsh/crates/nsh-testing/src/run.rs b/third-party/nsh/crates/nsh-testing/src/run.rs
index 3cd72566d3..14b5076303 100644
--- a/third-party/nsh/crates/nsh-testing/src/run.rs
+++ b/third-party/nsh/crates/nsh-testing/src/run.rs
@@ -36,6 +36,7 @@ use crate::{
         DirectoryAssets, Headless, RecordingNetwork, StubNetwork, UreqNetwork, WAIT, print_batch,
     },
     module::{Case, Hook, Item, Launch, Mode, NetworkSetup, Platform, SafeArea, Suite},
+    page_host::PageHost,
 };
 
 /// Builds an entry ([`Entry`]; an app's may be its directory).
@@ -84,6 +85,11 @@ pub struct TestRun {
     /// Opens the apps on a real platform's host instead of a headless
     /// engine (`nsh test --target`).
     pub host: Option<StartHost>,
+    /// The web page's host module (`nsh.json` `web.host`, exporting
+    /// `createHost(player)`): each app opened headless gets one of its own,
+    /// which answers its `nsh/host` calls and sends its events, and
+    /// `host.available()` is true. `None`: the apps have no host.
+    pub page_host: Option<PathBuf>,
 }
 
 /// Starts the app whose entry is the path on a real platform's host, as
@@ -453,6 +459,15 @@ impl Session {
         let files = set_files(&self.run.ui_sets);
         let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
         let safe_area = launch.safe_area;
+        // Loaded before the app starts, as the page's host is: what it
+        // sends meanwhile waits for the app.
+        let page_host = match &self.run.page_host {
+            Some(module) => Some(
+                PageHost::start(module, clock.clone())
+                    .map_err(|e| format!("open({entry:?}): {e}"))?,
+            ),
+            None => None,
+        };
         let default = EngineConfig::new(app);
         let config = EngineConfig {
             viewport: nsh_core::Size::new(
@@ -485,6 +500,7 @@ impl Session {
                     Platform::Ios | Platform::Android => Idiom::default(),
                     _ => Idiom::Desktop,
                 },
+                host: page_host.is_some(),
                 ..Default::default()
             },
             ui_sets: self.run.ui_sets.clone(),
@@ -493,6 +509,10 @@ impl Session {
         };
         let mut app = Headless::start(config)
             .map_err(|e| format!("open({entry:?}): the engine did not start: {e}"))?;
+        if let Some(host) = page_host {
+            host.attach(app.engine().control());
+            app.on_host_call(move |id, name, args| host.call(id, name, args));
+        }
         if let Some(out) = &self.run.trace {
             let out = out.clone();
             app.observe(move |step, batch| {
diff --git a/third-party/nsh/crates/nsh-testing/src/runner.rs b/third-party/nsh/crates/nsh-testing/src/runner.rs
index f15d563b2a..1ea3615c2b 100644
--- a/third-party/nsh/crates/nsh-testing/src/runner.rs
+++ b/third-party/nsh/crates/nsh-testing/src/runner.rs
@@ -26,7 +26,7 @@ use crate::run::{Built, Entry, TestResult, TestRun, run_file};
 
 /// The version of the [`TestRequest`], [`RunnerMessage`] and [`Reply`]
 /// formats: `nsh` and the runner it built speak the same.
-pub const PROTOCOL: u32 = 3;
+pub const PROTOCOL: u32 = 4;
 
 /// What `nsh` asks of a runner: the test files to run and how.
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -46,6 +46,8 @@ pub struct TestRequest {
     pub trace: bool,
     /// Forwards debug lines (`nsh -v`).
     pub verbose: bool,
+    /// The web page's host module ([`TestRun::page_host`]).
+    pub page_host: Option<PathBuf>,
 }
 
 /// A line a runner writes on its standard error for `nsh`.
@@ -236,6 +238,7 @@ pub fn runner(ui_sets: Vec<Registry>, root: Option<fn() -> Root>) -> ExitCode {
         // On a real platform, `nsh` runs the test files itself: the apps run
         // in hosts built with the app's crates.
         host: None,
+        page_host: request.page_host,
     };
     for file in &request.files {
         let mut report = |result: &TestResult| {
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/App.tsx b/third-party/nsh/crates/nsh-testing/tests/testing/host/App.tsx
new file mode 100644
index 0000000000..b2b7653c37
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/App.tsx
@@ -0,0 +1,40 @@
+import { mount, signal, Text, View } from "nsh";
+import { host } from "nsh/host";
+
+function App() {
+  const result = signal("(none)");
+  const ticks = signal<unknown[]>([]);
+  host.on("tick", (payload) => ticks.update((t) => [...t, payload]));
+  const call = (name: string, options?: { timeout?: number }) => () =>
+    host.call(name, { n: 1 }, options).then(
+      (value) => result.set(JSON.stringify(value)),
+      (e: Error) => result.set(`rejected: ${e.message}`),
+    );
+  return (
+    <View>
+      <Text>{`available: ${host.available()}`}</Text>
+      <View onPress={call("echo")} accessibilityLabel="echo">
+        <Text>echo</Text>
+      </View>
+      <View onPress={call("fail")} accessibilityLabel="fail">
+        <Text>fail</Text>
+      </View>
+      <View onPress={call("big")} accessibilityLabel="big">
+        <Text>big</Text>
+      </View>
+      <View onPress={call("other")} accessibilityLabel="other">
+        <Text>other</Text>
+      </View>
+      <View onPress={call("slow", { timeout: 1000 })} accessibilityLabel="slow">
+        <Text>slow</Text>
+      </View>
+      <View onPress={call("later")} accessibilityLabel="later">
+        <Text>later</Text>
+      </View>
+      <Text>{`result: ${result()}`}</Text>
+      <Text>{`ticks: ${JSON.stringify(ticks())}`}</Text>
+    </View>
+  );
+}
+
+mount(App);
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/Broken.test.tsx b/third-party/nsh/crates/nsh-testing/tests/testing/host/Broken.test.tsx
new file mode 100644
index 0000000000..1e06ea5177
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/Broken.test.tsx
@@ -0,0 +1,5 @@
+import { expect, open, test } from "nsh/test";
+
+test("a module without createHost fails open", async () => {
+  await expect(() => open("./App.tsx")).rejects.toThrow("createHost");
+});
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/Host.test.tsx b/third-party/nsh/crates/nsh-testing/tests/testing/host/Host.test.tsx
new file mode 100644
index 0000000000..5e3f3f3a54
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/Host.test.tsx
@@ -0,0 +1,51 @@
+import { expect, open, test } from "nsh/test";
+
+test("the page's host answers calls", async () => {
+  const app = await open("./App.tsx");
+  expect(app.shows("available: true")).toBe(true);
+  await app.press("label:echo");
+  await app.waitFor('text:result: {"n":1}');
+});
+
+test("a call the host fails rejects with its message", async () => {
+  const app = await open("./App.tsx");
+  await app.press("label:fail");
+  await app.waitFor("text:result: rejected: boom");
+  await app.press("label:big");
+  await app.waitFor("text:/result: rejected: .*BigInt/");
+});
+
+test("events sent before the app started reach it in order", async () => {
+  const app = await open("./App.tsx");
+  await app.waitFor("text:ticks: [1,2]");
+});
+
+test("each app gets a host of its own", async () => {
+  await open("./App.tsx");
+  const second = await open("./App.tsx");
+  await second.waitFor("text:ticks: [1,2]");
+});
+
+test("a function the app does not declare is refused", async () => {
+  const app = await open("./App.tsx");
+  await app.press("label:other");
+  await app.waitFor("text:/result: rejected: .*capabilities\\.host/");
+});
+
+test("a call the host does not answer times out on the app's clock", async () => {
+  const app = await open("./App.tsx", { clock: {} });
+  await app.press("label:slow");
+  await app.clock.runFor(900);
+  expect(app.shows("result: (none)")).toBe(true);
+  await app.clock.runFor(200);
+  await app.waitFor("text:result: rejected: nsh/host: slow timed out after 1000 ms");
+});
+
+test("the host's timers run on the app's clock", async () => {
+  const app = await open("./App.tsx", { clock: {} });
+  await app.press("label:later");
+  await app.clock.runFor(400);
+  expect(app.shows("result: (none)")).toBe(true);
+  await app.clock.runFor(200);
+  await app.waitFor('text:result: "late"');
+});
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/NoHost.test.tsx b/third-party/nsh/crates/nsh-testing/tests/testing/host/NoHost.test.tsx
new file mode 100644
index 0000000000..f935c3426e
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/NoHost.test.tsx
@@ -0,0 +1,8 @@
+import { expect, open, test } from "nsh/test";
+
+test("without a page host, calls reject", async () => {
+  const app = await open("./App.tsx");
+  expect(app.shows("available: false")).toBe(true);
+  await app.press("label:echo");
+  await app.waitFor("text:result: rejected: nsh/host: this platform has no host");
+});
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/host.js b/third-party/nsh/crates/nsh-testing/tests/testing/host/host.js
new file mode 100644
index 0000000000..78235efe09
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/host.js
@@ -0,0 +1,17 @@
+// The page's host of the tests: answers `echo` with its argument, throws
+// for `fail`, returns what is not JSON for `big`, never answers `slow`,
+// answers `later` after 500 ms of its timers, and sends two events before
+// the app starts.
+export function createHost(player) {
+  player.hostEvent("tick", 1);
+  player.hostEvent("tick", 2);
+  return {
+    async call(name, args) {
+      if (name === "fail") throw new Error("boom");
+      if (name === "big") return 1n;
+      if (name === "slow") return new Promise(() => {});
+      if (name === "later") return new Promise((resolve) => setTimeout(() => resolve("late"), 500));
+      return args;
+    },
+  };
+}
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/none.js b/third-party/nsh/crates/nsh-testing/tests/testing/host/none.js
new file mode 100644
index 0000000000..e056e20962
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/none.js
@@ -0,0 +1 @@
+export const notAHost = 1;
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/host/nsh.json b/third-party/nsh/crates/nsh-testing/tests/testing/host/nsh.json
new file mode 100644
index 0000000000..a6a50cba77
--- /dev/null
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/host/nsh.json
@@ -0,0 +1,5 @@
+{
+  "capabilities": {
+    "host": ["echo", "fail", "big", "slow", "later"]
+  }
+}
diff --git a/third-party/nsh/crates/nsh-testing/tests/testing/main.rs b/third-party/nsh/crates/nsh-testing/tests/testing/main.rs
index 9a43f1fad3..2a2a06c95b 100644
--- a/third-party/nsh/crates/nsh-testing/tests/testing/main.rs
+++ b/third-party/nsh/crates/nsh-testing/tests/testing/main.rs
@@ -17,6 +17,7 @@ fn test_run(filter: Option<&str>) -> TestRun {
         filter: filter.map(str::to_owned),
         trace: None,
         host: None,
+        page_host: None,
     }
 }
 
@@ -231,6 +232,24 @@ fn a_manual_clock_moves_as_the_test_says() {
     assert_eq!(results.len(), 7);
 }
 
+#[test]
+fn the_page_s_host_answers_the_apps_tests_open() {
+    let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/testing/host");
+    let run = |module: Option<&str>, file: &str| {
+        let run = TestRun {
+            page_host: module.map(|m| dir.join(m)),
+            ..test_run(None)
+        };
+        let results = run_file(&run, &dir.join(file), &mut |_| {}).unwrap();
+        let failed: Vec<_> = results.iter().filter(|r| r.failure.is_some()).collect();
+        assert!(failed.is_empty(), "{file}: {failed:#?}");
+        results.len()
+    };
+    assert_eq!(run(Some("host.js"), "Host.test.tsx"), 7);
+    assert_eq!(run(None, "NoHost.test.tsx"), 1);
+    assert_eq!(run(Some("none.js"), "Broken.test.tsx"), 1);
+}
+
 #[test]
 fn apps_run_under_the_capabilities_their_nsh_json_declares() {
     let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/testing/capabilities");
diff --git a/third-party/nsh/docs/dev/elements.md b/third-party/nsh/docs/dev/elements.md
index 3ff090e203..999023acc6 100644
--- a/third-party/nsh/docs/dev/elements.md
+++ b/third-party/nsh/docs/dev/elements.md
@@ -208,7 +208,12 @@ misses the deadline is not waited for again until it catches up (`docs/dev/macos
 - **Keyboard shortcuts** (`nsh/keymap`): the root's `Prop::Keymap` mounts the surface's whole table
   (`MountItem::Keymap`); the platform matches a key against it on the spot, consumes it and reports the root's
   `KeyCommand` (the binding's id), so the main thread never waits for JS (`docs/dev/js-modules.md`, the macOS and
-  Linux pages' "Keyboard"). Rust views' bindings (`on_key`) join JS's in that one table, and the core runs
+  Linux pages' "Keyboard"). iOS and Android match a hardware keyboard's keys: iOS in `pressesBegan:` of its text
+  inputs (before UIKit's text system) and of the root view (presses nothing inside handled), keys named by their HID
+  usage (`Key::from_hid_usage`) and the character they type, a `repeat` binding firing on a timer of its own while
+  the key is held (UIKit sends a held key's press once); Android in `NshView.dispatchKeyEvent` (before the focused
+  view, after the input method) through `Native.keyDown` / `keyUp`, the table kept in Rust (`keymap.rs`), never
+  encoded for Java. Both leave a composing input method its keys and consume a taken key's release too. Rust views' bindings (`on_key`) join JS's in that one table, and the core runs
   their handlers (`docs/dev/components.md`).
   Terminals resolve `mod` to Control on every host OS, and reserve Control+Q for quitting; graphical Apple
   backends keep Command. Rust components read the same renderer through `renderer()` / `is_terminal()`.
diff --git a/third-party/nsh/docs/dev/embedding.md b/third-party/nsh/docs/dev/embedding.md
index 088252f421..fd99592b4e 100644
--- a/third-party/nsh/docs/dev/embedding.md
+++ b/third-party/nsh/docs/dev/embedding.md
@@ -108,6 +108,22 @@
   toasts) follows too. `None` / `nil` / `null` / 0 follows the system's again (its settings are read again). The text
   size scales what allows font scaling (`Core::set_font_scale`): texts, inputs, the components' labels; lengths, `rem`
   in classes included, stay as they are. The layout direction stays the system's language's.
+- **The host's theme overrides**: a host that embeds one app in several branded host apps overrides the app's theme
+  tokens (brand colors, `radius`, ...) at run time: Rust `AppView::set_theme_overrides(Option<&str>)` (AppKit, GTK,
+  Win32; the iOS `AppView`'s too), Swift `NshAppView.setThemeOverrides(_:)` (C `nsh_app_set_theme_overrides`), Java
+  `NshView.setThemeOverrides`. The value is a theme file in the format of `nsh.json`'s `"theme"` (shadcn's `cssVars`:
+  `theme`, `light` and `dark` sections, values as CSS writes them; `components.md` "The theme"); it is parsed on the
+  calling thread by `nsh_base::theme_overrides`, which keeps only the tokens the file sets (a `var()` it names and
+  does not set is the default theme's), and a file that does not parse is an error (Swift throws `NshError`, Java an
+  `IllegalArgumentException`, C returns it) that changes nothing. The tokens go to the engine as host tokens
+  (`EngineControl::set_tokens`, `Core::set_host_tokens`): they win over the app's tokens of the same names in both
+  appearances, the app's others stay (`Tokens::over`), and classes naming the changed tokens (`bg-primary`,
+  `rounded-(--radius)`) are resolved again at once, as are Rust components reading `theme()`. Each platform keeps
+  them on its surface and starts every engine with them (`Engine::start_with`), so they are in the first frame of
+  every app loaded later, a development reload included; `None` / `nil` / `null` gives the app its own theme back. A
+  token takes both appearances: one the file gives only in `light` (or `theme`) is that color in dark mode too, as
+  in a theme file, so an override that differs in dark mode names it in `dark`. GPUI's `AppView::set_theme` /
+  `set_theme_json` (below) give a whole theme instead, the default's where the file is silent.
 - **The iOS SDK** (`crates/xtask/src/ios.rs`): per platform slice (device, simulator, Catalyst) a static
   `NativeShell.framework` whose binary is `nsh-platform-ios` with feature `embed` (built by `cargo rustc --crate-type
   staticlib`, so the hosts never build or export it) merged by `libtool` with the Swift module, compiled with library
diff --git a/third-party/nsh/docs/dev/js-modules.md b/third-party/nsh/docs/dev/js-modules.md
index 1488700103..2a65975928 100644
--- a/third-party/nsh/docs/dev/js-modules.md
+++ b/third-party/nsh/docs/dev/js-modules.md
@@ -49,6 +49,7 @@ re-exported by `nsh`). Apps import a primitive's tag from `nsh` (`native-pipelin
 | `nsh/gesture` | `PanResponder` | `touch.rs` |
 | `nsh/clipboard` | `Clipboard.writeText(text)`, `Clipboard.readText()` / `hasText()`, `writeImage(src)`, `readImage()` / `hasImage()`, `readFiles()` (promises the core resolves from the root's `Clipboard` event; the `has*` reads do not read the content, so iOS shows no paste notice). Images cross as base64 `data:` URIs (an image source and a `fetch` URL, so bytes stay native until JS asks for them), files as `file:` URLs one per line; the backends build both with `nsh_core::platform::cl [...]
 | `nsh/storage` | `Storage.getItem` / `setItem` / `removeItem` / `keys` / `clear` (async functions of the core; the entries live in memory in `nsh_core::storage`, loaded once from the platform's `StorageBackend` (`EngineConfig::storage`: `nsh_engine::FileStorage::open` in `<EngineConfig::data_dir>/<app id>` (the id travels with the app: `app.json`, the dev server, packages, `Root::app_id`), one per file in the process so a reload reads unflushed writes; a damaged file is set aside as `st [...]
+| `nsh/host` | `host.call(name, args, { timeout })` (a task of the core (`runtime/host.rs`), racing the answer against the app's clock: 30 s unless `timeout` says, 0 none; a call that timed out drops its slot at once (`Action::ForgetHostCalls`), so `Core::answer_host` drops a late reply with a debug line; the root's `Command::HostCall { id, name, args }`, answered by the root's `Host` event, `EventPayload::Json` `{ type: "reply", id, ok, value?, error? }` (an id that is not a whole numbe [...]
 | `nsh/keymap` | `keymap.bind(keystroke, handler, { when, repeat, within })` / `keymap.bind({ keystroke: handler }, options)`: keyboard shortcuts. Keystrokes are parsed and checked in Rust (`nsh_core::keymap`: syntax, `mod`, reserved keystrokes, which ones text inputs keep); the whole table goes to the platform as the root's `Prop::Keymap` → `MountItem::Keymap`, which matches keys on the spot and reports the root's `KeyCommand` event (the binding's id). Host-supported `within: YNode` res [...]
 | `nsh/webview`, `nsh/video` (deprecated: `nsh`) | `WebView`, `Video` | `platform_modules.rs` |
 | `nsh/native` | `nativeComponent(name, { props, events })`: a component for a native component the embedding app registered; `nativeModule(specifier)`: the exports of a UI set the embedding app gave its engines (`EngineConfig::ui_sets`), as one object made by the loader's code (`modules::host_module`, the same values an import of the set gets) and kept per specifier; only the host's sets, never nsh's own modules or the runtime's registries | `platform_modules.rs`, `modules.rs` |
diff --git a/third-party/nsh/docs/dev/macos.md b/third-party/nsh/docs/dev/macos.md
index e2cfb8b50c..9ab412736e 100644
--- a/third-party/nsh/docs/dev/macos.md
+++ b/third-party/nsh/docs/dev/macos.md
@@ -84,6 +84,12 @@ server client, log forwarding, the error overlay: `dev.rs`). The runtime needs m
   the events got to (`settle`).
 - **One hop per commit.** The engine runs on its own thread; its sink queues each commit and wakes the main queue once
   (`DispatchQueue::main`), and the main thread applies every queued batch in order (`surface.rs`, `mount.rs`).
+  While native animations or the platform's own (a scroll glide, a zoom) run, batches wait for the start of the next
+  frame, and each frame applies them for at most 4 ms (`FRAME_BATCHES`, at least one whole batch), carrying the rest:
+  a scroll's frames are not delayed by batches landing between them, and a burst of batches spreads over frames. What
+  the engine sends after a waiting batch waits behind it, in order. A frame JS asked for alone does not hold batches
+  back (what it draws shows in that frame); otherwise a batch is applied as it lands, and a resize applies the waiting
+  ones first.
   Dispatched closures must be `Send`, so they find their surface by key in a thread-local map; no globals are shared
   between surfaces.
 - **Resizes are shown at once** (`Surface::resized`, the one place the main thread waits for the engine): when the
diff --git a/third-party/nsh/docs/dev/performance.md b/third-party/nsh/docs/dev/performance.md
index c8bf517d86..32462f8128 100644
--- a/third-party/nsh/docs/dev/performance.md
+++ b/third-party/nsh/docs/dev/performance.md
@@ -63,6 +63,10 @@ coalescing interval, with the latest coordinates kept independently per view and
   bundling included); applying a hot update takes 0.2–0.5ms on the engine thread.
 - gallery Photos list (`examples/gallery/Bench.tsx`, debug, headless): first frame 653 ops / 224 nodes, ~2.7ms (list bind
   rounds included).
+- Paced list binds (`Bench.tsx` scrolled 3000 pt, release, headless, three runs): the first commit binds the viewport,
+  786 ops / 641 mount items in ~1.1ms (1226 / 1029 in ~1.5ms when the whole overscan bound with it); the overscan
+  follows in commits of ~165 ops / 138 items. 43 commits and 1923 items in all (40 and 1943 before), commit median
+  ~0.26–0.31ms either way.
 - Photos scrolling (release): a frame that rebinds one row is 4 ops / 4 mount items; frames that reveal no new row call no JS and produce
   no batch. `list_bench` runs at ~9µs per frame back to back (JS ~4µs; ~30µs / JS ~20µs before reactivity went native) and
   ~50µs with caches flushed between frames, but ~60–150µs after the thread sleeps 10ms. The difference is the CPU clocking down while the thread sleeps (Apple CLPC), not the code path; neither QoS (the engine
@@ -278,6 +282,25 @@ draw a panel.
   p50 ~1.9 → ~1.7 ms, CPU ~38–40% both; a test canvas (524×340: a 400-point area and line, 400 bars, a pulsing ring,
   a moving square) presented 86–117 fps before and 92–119 after, `main` p50 ~1.2 → ~1.0 ms, CPU ~17% both.
 
+  List binds while scrolling (release host, 120 Hz MacBook Pro; the list scrolled by chained native
+  `scrollToOffset({ animated: true })` every 300 ms, no input; each display link refresh logged with its link
+  timestamp and main-thread time; three runs each, refreshes missed = link gaps of two periods or more). Before =
+  four cells per turn on `Wake`; after = the viewport bound at once, the overscan paced per frame, one item per view
+  part per merged frame, batches applied at frame start (4 ms), the cursor set only when it changes:
+
+  | Scenario | Before: fps, missed, main mean / p99 / max | After: fps, missed, main mean / p99 / max |
+  |---|---|---|
+  | 1100 rows of 76 pt, 390×844, 6000 pt/s | 119.2–119.9, 1–12, 0.31–0.32 / 1.2 / 1.9–2.0 ms | 118.5–119.2, 12–22, 0.25–0.26 / 1.0 / 1.9–4.0 ms |
+  | The same at 16700 pt/s | 119.0–120.0, 0–6, 0.51–0.52 / 1.9–2.0 / 3.2–6.3 ms | 119.7–120.0, 0–2, 0.39–0.40 / 1.7 / 3.1–4.7 ms |
+  | Photos grid, 1200×800, 6000 pt/s | 118.1–120.0, 0–53, 0.30–0.34 / 0.9–1.0 / 8.5–11.3 ms | 119.8–120.0, 0–6, 0.44–0.48 / 2.2–2.5 / 4.2–5.4 ms |
+
+  The rows' "after" runs were taken before the cursor change. Where the mouse was is not controlled: hover and cursor
+  work happen only with it over the window, which may be why the grid's mean rose. The missed refreshes do not follow the main thread's time (a run missing 18% had a 0.7 ms mean): the machine was loaded
+  (load average 8–29 from other processes), and one excluded run had ~100 ms stalls every 5 s with the main thread
+  idle. Frames over a refresh were the Photos grid's: setting the cursor as content moved under the mouse, which
+  renders its image again when the pointer is customized in Accessibility settings (45% of the main thread's
+  samples). Without that, a single batch of the first tiles a scroll shows costs 4–5 ms.
+
 - GPUI backend (`nsh-platform-gpui`; release with thin LTO, rootful Xwayland at scale 2, a focused 285×315 pt window at
   60 Hz, against the GTK backend (`GSK_RENDERER=ngl`) on the same display and bundle). `draw` is the surface element's
   prepaint and paint (the whole view tree, every frame), `frame` GPUI's whole frame with presenting (`ZED_MEASUREMENTS=1`),
diff --git a/third-party/nsh/docs/dev/pitfalls.md b/third-party/nsh/docs/dev/pitfalls.md
index a452ac897a..abed678baa 100644
--- a/third-party/nsh/docs/dev/pitfalls.md
+++ b/third-party/nsh/docs/dev/pitfalls.md
@@ -182,6 +182,12 @@ Things that were found the hard way. Read the section of the area you change bef
   prevented every press there, so a navigation bar's back button did nothing (seen on an iPhone foldable simulator; a
   real tap through XCUITest reproduced it). `PressRecognizer` is not preventable by tap recognizers on a `UIWindow`
   (`press.rs`, `is_window_tap`); edge-gesture gates are not taps and still cancel presses.
+- **iOS, a hardware keyboard's keys** reach the first responder as presses (`pressesBegan:withEvent:`) before
+  UIKit's text system acts on them: a text input that does not call `super` for a press neither moves its caret,
+  inserts a line break nor types it, and must not call `super` for that key's release either. A held key's press
+  comes once (the text system repeats on its own), so a binding that repeats needs a timer of its own
+  (`keymap.rs`). A press's `responder` is the first responder it went to: presses bubbling up to the root view from
+  an input were matched there already.
 
 - **iOS, the screen scale**: a `UIView` that draws nothing itself keeps a `contentScaleFactor` of 1 on a 3x screen, and a
   drawn one raises it while zoomed, so neither is the screen's scale. Images decoded at it came out at a third of their
@@ -246,6 +252,11 @@ Things that were found the hard way. Read the section of the area you change bef
   so a line is wider than its sum of advances; `TextLayouts.Prepared.desired` adds the largest negative spacing back, or text laid
   out at its natural width wraps (`tracking-tight`).
 
+- **Android, a hardware keyboard's keys** go to the input method first, then down the window's views
+  (`dispatchKeyEvent`, the parents before the focused view): `NshView`, which holds the focus when no input does,
+  sees every key meant for the views inside it. Keys with Control come as `dispatchKeyShortcutEvent` first, where a
+  text field selects all, copies and pastes, so the app's shortcuts are matched there too.
+
 ## Linux, GTK
 
 - **inotify reports reads** (`Access` events), FSEvents does not: file watchers (`nsh dev`, `cargo run`'s xtask) ignore
@@ -296,6 +307,9 @@ Things that were found the hard way. Read the section of the area you change bef
 - **Made-up edge swipes do not start `interactivePopGestureRecognizer`**; the back button is tapped instead. A
   scroll view's glide or bounce is not a Core Animation animation: `isDecelerating` says it moves, and a tap during
   it only stops it.
+- **A hardware keyboard's keys are IOHID keyboard events** (`IOHIDEventCreateKeyboardEvent`, usage page 7) handed to
+  UIKit the way touches are: they become presses, key commands and typing as a real keyboard's do. Modifiers are
+  keys of their own (left Control, Shift, Option, Command), down before the key and up after it.
 - **A menu's backdrop covers the header**: on a touch screen a menu is a sheet over a backdrop on the whole window,
   so a tap at the header's back button lands on the backdrop and closes the menu (`backdropPress`) instead of going
   back from a submenu. iOS has no back key, so while the app listens to going back the agent's `back` is VoiceOver's
diff --git a/third-party/nsh/docs/dev/sandbox.md b/third-party/nsh/docs/dev/sandbox.md
index dcb5fa104c..7b2f8b6959 100644
--- a/third-party/nsh/docs/dev/sandbox.md
+++ b/third-party/nsh/docs/dev/sandbox.md
@@ -37,13 +37,19 @@ trusted: every limit and check is enforced in Rust, never in JS.
   (`hosts`: `*`, `*.example.com` or a host; `http`: a host narrowed by methods, schemes, a port, paths and path prefixes),
   `webview` (`hosts` a web view may show and navigate to), `statusBar`, `process` (`execute`: exact executable names or absolute paths; `exit`: host exit requests; `env`: additional
   visible environment names), `fs` (`read` / `write`: trusted path roots), `clipboard` (`nsh/clipboard`: reading and
-  writing the system clipboard; the copy menu of selectable text is the user's own act and needs none). `nsh/storage`
+  writing the system clipboard; the copy menu of selectable text is the user's own act and needs none), `host`
+  (`nsh/host`: the embedding app's functions `host.call` may call, names or prefixes ending in `*`, `true` for all;
+  names and grants are 1 to 128 of `[A-Za-z0-9_.:-]` without `..` (`access::valid_host_name`), and the core refuses
+  the call of any other name before it reads the grants, since hosts may put names in paths or URLs;
+  never granted without it, also in apps without a declaration (`Capabilities::all` leaves it empty), and the bundler
+  warns of an app importing `nsh/host` without it (`bundle.rs` `ungranted_host`); the host's events need none, the
+  host sends them). `nsh/storage`
   is no capability: an app's storage holds only its own data, and the host decides where it is kept. Everything not listed is denied and unknown keys
   are build errors (`Capabilities::from_json`); a signed package's declaration is read with `from_json_lenient`, as a
   newer signer may have written it: an unknown capability is not granted, an unknown key inside one drops that
   capability or `http` grant (it may be a restriction), so unknown keys never widen access; an app without the key
-  retains unrestricted network, webview, status bar and clipboard access. Process execution and filesystem access
-  always require explicit declarations, including standalone apps. The embedding app
+  retains unrestricted network, webview, status bar and clipboard access. Process execution, filesystem access and
+  the host's functions always require explicit declarations, including standalone apps. The embedding app
   may grant less (`NshAppView.grantCapabilities` / `NshView.setGrantedCapabilities`, the same JSON): a request must be
   allowed by both (`Access`). The declaration travels with the bundle (`capabilities.json` next to it, written by
   `nsh build` / `nsh package`; the dev protocol's `Bundle` message, so development enforces the same capabilities; a change
@@ -51,7 +57,7 @@ trusted: every limit and check is enforced in Rust, never in JS.
 - Enforced in Rust: `fetch` in the engine (`access.rs`; refused requests never reach the network, and redirects are
   followed by the engine with `follow_redirects` off for the platform, so every hop is checked — hops are issued on the
   engine thread when a hop's head arrives; a streamed request body is not sent again, so a 307 / 308 then fails); remote images, placeholders, canvas images, `Image.getSize` / `prefetch`, video sources, web view
-  sources, the status bar and the clipboard in the core (`Core::check_prop`; a refused image fails with `onError`, clipboard reads resolve empty, the rest show nothing;
+  sources, the status bar, the clipboard and host calls in the core (`Core::check_prop`, the commit's commands; a refused image fails with `onError`, clipboard reads resolve empty, host calls reject, the rest show nothing;
   each refusal is logged once, target `js`, saying which key to add); top-level web view navigations on the platforms
   (`WKNavigationDelegate decidePolicyForNavigationAction` / `WebViewClient.shouldOverrideUrlLoading` → `Native.allowsWebview`).
   `asset:` URIs are always allowed. Host checks take the host after the last `@` (`https://ok.com@evil.io` is evil.io).
diff --git a/third-party/nsh/docs/dev/scrolling-and-lists.md b/third-party/nsh/docs/dev/scrolling-and-lists.md
index d2dca9848b..1124b70061 100644
--- a/third-party/nsh/docs/dev/scrolling-and-lists.md
+++ b/third-party/nsh/docs/dev/scrolling-and-lists.md
@@ -95,5 +95,13 @@
 - **Indexed data**: TSX `each` also accepts `ReadonlyListSource<T>`, a readonly snapshot with `length` and `at(index)`.
   Only bound and viewable items are read. Replacing a snapshot's identity replaces the full range without reading its
   items; arrays retain their identity-based common-prefix / suffix splice behavior. Counts use the core's existing u32 range.
+- **Paced binding** (the runtime's lists): the window keeps a viewport of overscan on either side, and a fast scroll or
+  a jump assigns a screen of cells at once, updating each cell's item and index signals together. What shows binds in
+  the commit that assigns it: the viewport, two scroll steps ahead of it, sticky items and cells whose item moved (a splice tells them
+  their new index). The rest of the overscan waits in the core (`Slot::requested`: what JS was last asked to show, so
+  nothing stale is queued and cells whose items went are never asked for) and the engine binds it as its per-turn
+  budget allows (`PACED_BIND_OPS` ops or `PACED_BIND_TIME` of JS, at least one cell; nearest the leading edge first,
+  one cell per list in turn), then on the next display frames (`MountSink::request_frame`). Cells assigned but not
+  bound yet are moved out of sight like unassigned ones. Rust components' lists bind in the core, unpaced.
 - **Rust components**: the same props are `Scrollable`'s methods (`scroll()`, `virtual_list(..)`) and
   `VirtualList`'s (header, footer, empty view, separators, sticky rows, viewability); `docs/dev/components.md`.
diff --git a/third-party/nsh/docs/dev/tooling.md b/third-party/nsh/docs/dev/tooling.md
index 0a2a219669..663a95ba7e 100644
--- a/third-party/nsh/docs/dev/tooling.md
+++ b/third-party/nsh/docs/dev/tooling.md
@@ -282,7 +282,7 @@ and more than one fails the step; a press whose match, or a view between it and
   submenu's as `"Share > Mail"`) until it is chosen from (the root hears `Menu` with the action's id, then
   `Menu { closed }`) or closed;
 - `press_key` (`headless/keyboard.rs`, `app.keyboard.press`): the keystroke as `nsh/keymap` parses it for the
-  environment's OS; on a desktop environment (`open`'s `platform`: `Environment::desktop`) the root's keymap
+  environment's OS; on every environment (a hardware keyboard: phones and tablets have one too) the root's keymap
   (`MountItem::Keymap`, `Screen::keymap`) finds the binding that fires where the focus is (`Keymap::find`) and the
   root hears `KeyCommand`; else the focused input takes a character, Backspace or the return key, else Escape goes
   back when there is something to go back from;
@@ -321,6 +321,18 @@ Headless runs play the platform's part NSH's protocol defines, and refuse what o
   them after each step itself (`answer_dialogs_myself`), so its JS handler runs on the test file's VM;
 - the clipboard holds text (`SetClipboard`, `ReadClipboard` answered with `onClipboard`), modals get `onShow` when
   inserted;
+- with `TestRun::page_host` (`nsh test`'s `--web-host`, else `nsh.json` `web.host`; `TestRequest::page_host` for an
+  app's runner) the web page's host answers `nsh/host` (`page_host.rs`, feature `js`): each app `open` starts
+  headless gets a host of its own, started before the engine (`createHost(player)` in a QuickJS context of its own on
+  its own thread: timers on the app's clock, woken by its alarms (`Clock::at`), `console` to the `js` log, no imports), `Environment::host` set, and the
+  driver hands the root's `Command::HostCall`s to it as their batches are applied (`Headless::on_host_call`); it
+  answers each with a `Host` `{ type: "reply" }` event as `nsh.js` `hostCall` does (`ok: false` with the message on a throw, a rejection or
+  a value that is not JSON), and `player.hostEvent` sends `Host` `{ type: "event" }`; what it sends before the engine runs waits in
+  `page_host::Target` until `attach`, so the app's top-level `host.on` hears it, and the host goes with the app
+  (its thread ends when the driver drops its calls). A module that does not load, exports no `createHost` or whose
+  `createHost` returns no `call` fails the `open`. A call's timeout (`runtime/host.rs`) is the core's timer, on the
+  engine's clock: virtual on a manual clock, real otherwise, as the host module's own timers are. `--target web`'s
+  page runs `nsh.json`'s `web.host` (`web::with_page_host`); the other real hosts (`--target`) have none;
 - the app runs under what its `nsh.json` declares (`Bundle::with_capabilities_json`: `nsh test`'s compiler and an
   app's runner, `Reply::Js::capabilities`, as `nsh build` writes `capabilities.json`), so headless refusals are a
   device's;
@@ -429,7 +441,8 @@ menus of its own; the web, whose `Menu.popup` is the app's own UI, has none), th
 `Native::mouse`: macOS hands the move to the surface's input, as AppKit gives posted
 moves to the first responder only), keystrokes (`press_keys`: macOS's posted key events, Windows' and GPUI's key entry points with
 `keymap::Key::virtual_key`, the web's CDP key events, Linux's bindings fired as its shortcut controller would, as GTK
-takes no made-up keys; the phones, without app shortcuts, refuse them),
+takes no made-up keys; iOS's IOHID keyboard events (`synth::key`, modifiers down first) that UIKit takes as a
+real keyboard's, Android's `KeyEvent`s handed to the window's root view as its input stage hands them over),
 `back`, dialogs, screenshots, the window's size and appearance),
 then waits until neither a batch came nor a native animation ran for 100 ms (2 s at most; the web's page counts its own
 finite animations too: a screen sliding in; Android counts a scroll view drawing, as its overscroll stretch relaxes
diff --git a/third-party/nsh/docs/dev/web.md b/third-party/nsh/docs/dev/web.md
index 72cc7225dc..e80eb77e15 100644
--- a/third-party/nsh/docs/dev/web.md
+++ b/third-party/nsh/docs/dev/web.md
@@ -61,7 +61,8 @@ build to the same file) and takes `nsh.js` from the crate.
   bytes: the engine reads fonts synchronously, `Assets::read`, to measure text with them). Fonts the entry or the UI
   sets register are fetched before the app starts; one only chunks register (`font("asset:/…")` in their code) comes
   with the first of them, through `createPlayer`'s `prepareChunk(id)`, which `nsh.js` awaits with the chunk's download
-  (`web::font_loads`). It runs the bundle with `run(code, { release: true, harden, capabilities, appId, chunks })`
+  (`web::font_loads`). With `nsh.json` `web.host` the page also imports `host.<digest>.js` and passes `createPlayer` a
+  `host` (Platform services). It runs the bundle with `run(code, { release: true, harden, capabilities, appId, chunks })`
   (`appId`: the `nsh.json` id, the page's `app.id`; a Rust app's root carries its own), without `limits`: a published
   app runs on its own, with none (`sandbox.md`); an app whose capabilities restrict `network`
   gets a CSP `connect-src` meta with `'self'` and explicit HTTP / WebSocket scheme, host and port sources
@@ -81,7 +82,10 @@ build to the same file) and takes `nsh.js` from the crate.
   `bytecode_version` (the server then sends JS source, without the warning a mismatched version gets), it runs
   each bundle with its message's `app_id` (the app's storage), applies patches with `player.hotUpdate` (`nsh_hot_update`; when it reports a failure, `nsh_hot_failed`, the bundle that
   follows loads from scratch), shows build errors and `CannotRun` over the app, sends the app's log lines back, and
-  reads fonts with a synchronous `XMLHttpRequest` from `/assets/`.
+  reads fonts with a synchronous `XMLHttpRequest` from `/assets/`. With `nsh.json` `web.host` it imports the module
+  (`web::with_page_host` serves it as `nsh-host.js`, read when the session starts, checked as `nsh package` checks it)
+  and runs its `createHost(player)` before the first bundle, as a published page does; `nsh test --target web` serves
+  each app's the same way (headless `nsh test` runs the module itself: `docs/dev/tooling.md` "Headless runs").
 - **Tests** (`nsh test --target web`, `docs/dev/tooling.md` "On a real platform"): the CLI opens the development host
   in a headless Chromium browser it drives over the DevTools protocol (`nsh-cli/src/cdp.rs`, `test_web.rs`). When the
   server's hello says `test`, the page calls `player.testStart()` (`nsh_test_start`): from the next bundle on, every
@@ -609,9 +613,33 @@ pointer, touch or key takes it over.
   deleted video is stopped (a detached media element plays on). Browsers only start a video with sound after a user
   gesture (a warning says so).
 - **Registered native components** are the native apps' own: their view stays empty, with a warning once per name.
+- **The host** (`nsh/host`): the page answers the app's calls with `createPlayer`'s `host` option, an object whose
+  `call(name, args)` returns the result or a promise of it. `Command::HostCall` is the command `hostCall` (`encode.rs`;
+  the id written whole, not rounded as `num` would), and `nsh.js` `hostCall` answers every one with `onHost`
+  `{ type: "reply", id, ok, value?, error? }` (`EventPayload::Json`, `nsh_event`): `ok: false` with the error's message when the page has no host, `call` throws or
+  its promise rejects, or the result is not JSON, since the app's call waits until it is answered. The page sends
+  events with `player.hostEvent(event, payload)` (`onHost` `{ type: "event", event, payload }`; one `EventKind` for
+  both keeps the kinds within `EventMask`'s 64 bits): the payload is serialized at once (a payload that is
+  not JSON throws to the page, not inside a deferred engine call where it would drop the calls queued after it), and
+  events sent before the app runs are queued and delivered in order right after `begin` starts it, so its top-level
+  `host.on` listeners get them (no replay to listeners registered later); every `begin` queues again until the new app
+  has started, and a start that throws (a trap) drops the queue and the events sent after it. `web/nsh.test.mjs`
+  tests `hostCall` and this queue on a fake engine (`node --test crates/nsh-platform-web/web/nsh.test.mjs`).
+  `Environment::host` is set when the player has a `host` (`nsh_environment`'s `host`, read at start only).
+  `nsh package --target web` copies `nsh.json` `web.host` (a JS module relative to `nsh.json`, `package::WebConfig`; `package::web_host_module` requires it inside the app's
+  directory once symbolic links are resolved, as `nsh test` does) as `host.<digest>.js`; the page imports it alongside the
+  runtime and runs its `createHost(player)` once the player exists, before `run`, and its `host` option forwards to
+  what that returns (no callable `call`: the page's "could not be loaded" error). The host module shares the page's
+  CSP: an app restricting `network` gets a `connect-src` of `'self'`, its `capabilities.network` and `web.connect`
+  (`package::WebConfig::connect`, host patterns checked as `HostPattern`s, which keeps any CSP syntax out:
+  `web::page_connect`). `web.connect` is the page's alone: the core checks the app's requests against its
+  capabilities before `fetch` sees them, so only the redirect hops CSP guards widen with it. The module is copied as it
+  is: its own imports are absolute URLs.
+  Headless `nsh test` runs the same module for each app a test opens (`nsh_testing` `page_host.rs`,
+  `docs/dev/tooling.md` "Headless runs").
 
 ## Not implemented
 
-Platform services other than the theme color, toasts, dialogs and the clipboard; image download progress; a web
+Platform services other than the theme color, toasts, dialogs, the clipboard and the host; image download progress; a web
 view's events and commands for pages of other sites (beyond `postMessage` and loads), user agents. A published app
 ships no source map.
diff --git a/third-party/nsh/examples/embed-android/README.md b/third-party/nsh/examples/embed-android/README.md
index 2af8d2580c..723050eb8a 100644
--- a/third-party/nsh/examples/embed-android/README.md
+++ b/third-party/nsh/examples/embed-android/README.md
@@ -20,6 +20,14 @@ nsh.registerComponent("embed.badge", context -> new Badge(this, context));  // B
 The component gets its props (`setProp`, JSON as maps and lists) and commands, emits events with
 `context.emit("tap", data)` and may report its size (dp) with `context.setSize(w, h)`.
 
+Theme overrides: "Brand" puts the host's brand over the app's theme. The tokens of a theme file
+(`nsh.json`'s `"theme"` format) win over the app's of the same names, light and dark, and
+classes such as `bg-primary` restyle at once; `null` gives the app its own theme back:
+
+```java
+nsh.setThemeOverrides("{ \"cssVars\": { \"light\": { \"primary\": \"#e11d48\" } } }");
+```
+
 Signed packages (`nsh publish`; see `docs/dev/signed-packages.md`), e.g. updates the app
 downloads: verify off the UI thread, then run on it.
 
diff --git a/third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java b/third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java
index 0647c6b867..681698b440 100644
--- a/third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java
+++ b/third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java
@@ -18,11 +18,19 @@ import com.longbridge.nsh.NshView;
  * An existing Android app that embeds a nsh app below its own native UI.
  * The nsh bundle (`nsh build ../embedded`) is packaged as APK
  * assets under `nsh/`; it uses a native component the app registers
- * (`embed.badge`).
+ * (`embed.badge`). "Brand" puts the host's brand over the app's theme
+ * (`setThemeOverrides`).
  */
 public final class MainActivity extends Activity {
     private NshView nsh;
     private int loads;
+    private boolean branded;
+
+    /** The host's brand: the tokens it sets win over the app's, light and dark. */
+    private static final String BRAND = "{ \"cssVars\": {"
+            + " \"theme\": { \"radius\": \"1rem\" },"
+            + " \"light\": { \"primary\": \"#e11d48\", \"primary-foreground\": \"#ffffff\" },"
+            + " \"dark\": { \"primary\": \"#fb7185\", \"primary-foreground\": \"#1c1917\" } } }";
 
     @Override
     protected void onCreate(Bundle state) {
@@ -48,6 +56,11 @@ public final class MainActivity extends Activity {
             count.setText("loaded " + (++loads) + "×");
         });
         column.addView(reload);
+        Button brand = new Button(this);
+        brand.setText("Brand");
+        // null gives the app its own theme back.
+        brand.setOnClickListener(v -> nsh.setThemeOverrides((branded = !branded) ? BRAND : null));
+        column.addView(brand);
         column.addView(count);
 
         // The embedded nsh app, in a bordered frame.
diff --git a/third-party/nsh/examples/embed-ios/README.md b/third-party/nsh/examples/embed-ios/README.md
index 63f5ac38d3..928980e206 100644
--- a/third-party/nsh/examples/embed-ios/README.md
+++ b/third-party/nsh/examples/embed-ios/README.md
@@ -33,6 +33,14 @@ app.register(component: "embed.badge") { context in Badge(context) }  // Badge:
 The component gets its props (`setProp`, JSON as Foundation objects) and commands, emits events with
 `context.emit("tap", ["count": n])` and may report its size with `context.setSize(_:)`.
 
+Theme overrides: "Brand" puts the host's brand over the app's theme. The tokens of a theme file
+(`nsh.json`'s `"theme"` format) win over the app's of the same names, light and dark, and
+classes such as `bg-primary` restyle at once; `nil` gives the app its own theme back:
+
+```swift
+try app.setThemeOverrides(##"{ "cssVars": { "light": { "primary": "#e11d48" } } }"##)
+```
+
 With `NativeShellDev.xcframework`, `try app.connectDevServer(URL(string: "ws://127.0.0.1:8790/ws")!)`
 loads from `nsh dev` instead and reloads on every change.
 
diff --git a/third-party/nsh/examples/embed-ios/main.swift b/third-party/nsh/examples/embed-ios/main.swift
index 4d26b6836e..44a280bf5b 100644
--- a/third-party/nsh/examples/embed-ios/main.swift
+++ b/third-party/nsh/examples/embed-ios/main.swift
@@ -5,7 +5,8 @@
 // a native component the bundle uses (`embed.badge`). "Load update" runs a
 // signed package (`nsh publish`) verified against a root key; a real app
 // downloads the package, here it ships as `nsh/update.nshpkg` with
-// `nsh/root.pub`.
+// `nsh/root.pub`. "Brand" puts the host's brand over the app's theme
+// (`setThemeOverrides`).
 
 import NativeShell
 import UIKit
@@ -14,6 +15,7 @@ final class EmbedController: UIViewController {
     private let app = NshAppView()
     private let status = UILabel()
     private var count = 0
+    private var branded = false
     private lazy var trust: NshTrust? = {
         let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
         return try? NshTrust(stateDirectory: support.appendingPathComponent("nsh"))
@@ -36,7 +38,10 @@ final class EmbedController: UIViewController {
         status.font = .systemFont(ofSize: 13)
         status.textColor = .secondaryLabel
         status.numberOfLines = 0
-        let buttons = UIStackView(arrangedSubviews: [reload, update])
+        let brand = UIButton(type: .system)
+        brand.setTitle("Brand", for: .normal)
+        brand.addTarget(self, action: #selector(toggleBrand), for: .touchUpInside)
+        let buttons = UIStackView(arrangedSubviews: [reload, update, brand])
         buttons.spacing = 16
         let header = UIStackView(arrangedSubviews: [title, buttons, status])
         header.axis = .vertical
@@ -67,7 +72,7 @@ final class EmbedController: UIViewController {
     }
 
     @objc private func load() {
-        guard let url = Bundle.main.url(forResource: "bundle", withExtension: "qbc", subdirectory: "nsh") else {
+        guard let url = Bundle.main.url(forResource: "bundle", withExtension: "nshc", subdirectory: "nsh") else {
             return
         }
         do {
@@ -79,6 +84,24 @@ final class EmbedController: UIViewController {
         }
     }
 
+    /// The host's brand over the app's theme: the tokens it sets win, light and
+    /// dark, the app's others stay; `nil` gives the app its own theme back.
+    @objc private func toggleBrand() {
+        branded.toggle()
+        let brand = """
+            { "cssVars": {
+                "theme": { "radius": "1rem" },
+                "light": { "primary": "#e11d48", "primary-foreground": "#ffffff" },
+                "dark": { "primary": "#fb7185", "primary-foreground": "#1c1917" }
+            } }
+            """
+        do {
+            try app.setThemeOverrides(branded ? brand : nil)
+        } catch {
+            status.text = "\(error)"
+        }
+    }
+
     @objc private func loadUpdate() {
         guard let trust,
             let package = Bundle.main.url(forResource: "update", withExtension: "nshpkg", subdirectory: "nsh"),
diff --git a/third-party/nsh/examples/embedded/App.tsx b/third-party/nsh/examples/embedded/App.tsx
index 5fbb47b794..d64012d0ee 100644
--- a/third-party/nsh/examples/embedded/App.tsx
+++ b/third-party/nsh/examples/embedded/App.tsx
@@ -1,8 +1,10 @@
 // The nsh app the embedding samples (examples/embed-ios, examples/embed-android)
 // run. The badge is a native view of the host app, registered as
 // `embed.badge` (`NshAppView.register(component:)`, `NshView.registerComponent`).
+// The pill is drawn with the theme's tokens, which the host apps override
+// with their brand (`NshAppView.setThemeOverrides`, `NshView.setThemeOverrides`).
 
-import { mount, signal, Text, type YNode } from "nsh";
+import { mount, signal, Text, View, type YNode } from "nsh";
 import { nativeComponent } from "nsh/native";
 import { SafeAreaView } from "nsh/base";
 
@@ -23,6 +25,9 @@ function App() {
       <Text style={{ color: "#57606a" }}>
         The badge below is a native view of the host app: nsh lays it out, sends it props and gets its events.
       </Text>
+      <View class="self-start rounded-(--radius) bg-primary px-3 py-2">
+        <Text class="text-primary-foreground">Styled by the host app's theme</Text>
+      </View>
       <Badge
         ref={(node) => (badge = node)}
         text={taps() === 0 ? "Tap the native badge" : `Tapped ${taps()}×`}
diff --git a/third-party/nsh/examples/gallery/tests/fixtures/mention.tsx b/third-party/nsh/examples/gallery/tests/fixtures/mention.tsx
new file mode 100644
index 0000000000..209498be15
--- /dev/null
+++ b/third-party/nsh/examples/gallery/tests/fixtures/mention.tsx
@@ -0,0 +1,39 @@
+// An @-mention candidate list driven by the keyboard while the message
+// input keeps the focus: `up`, `down` and `enter` bound with
+// `{ when: "always" }` take the keys before the input does.
+import { Input, mount, signal, Text, View } from "nsh";
+import { keymap } from "nsh/keymap";
+
+const people = ["Alice", "Bob", "Carol"];
+
+function Candidates(props: { onPick: (name: string) => void }) {
+  const at = signal(0);
+  keymap.bind("up", () => at.set((at() + people.length - 1) % people.length), {
+    when: "always",
+    repeat: true,
+  });
+  keymap.bind("down", () => at.set((at() + 1) % people.length), { when: "always", repeat: true });
+  keymap.bind("enter", () => props.onPick(people[at()]), { when: "always" });
+  return <Text>{`candidate: ${people[at()]}`}</Text>;
+}
+
+function App() {
+  const text = signal("");
+  return (
+    <View style={{ flex: 1, paddingTop: 80 }}>
+      <Input
+        testID="msg"
+        autoCapitalize="none"
+        value={text()}
+        onChange={(t) => text.set(t)}
+        style={{ height: 40 }}
+      />
+      <Text>{`text: ${text()}`}</Text>
+      <If when={text().endsWith("@")}>
+        <Candidates onPick={(name) => text.set(text() + name + " ")} />
+      </If>
+    </View>
+  );
+}
+
+mount(App);
diff --git a/third-party/nsh/examples/gallery/tests/mention.test.tsx b/third-party/nsh/examples/gallery/tests/mention.test.tsx
new file mode 100644
index 0000000000..8e91834203
--- /dev/null
+++ b/third-party/nsh/examples/gallery/tests/mention.test.tsx
@@ -0,0 +1,15 @@
+// A hardware keyboard's arrows and return move through @-mention
+// candidates while the message input keeps the focus (`nsh/keymap` with
+// `when: "always"`): on phones and tablets as on the desktops.
+import { open, test } from "nsh/test";
+
+test("the arrows pick a candidate and return inserts it", async () => {
+  const app = await open("./fixtures/mention.tsx");
+  await app.type("id:msg", "hi @");
+  await app.waitFor("candidate: Alice");
+  await app.keyboard.press("down");
+  await app.keyboard.press("down");
+  await app.waitFor("candidate: Carol");
+  await app.keyboard.press("enter");
+  await app.waitFor("text: hi @Carol ");
+});
diff --git a/third-party/nsh/examples/host-echo/App.test.tsx b/third-party/nsh/examples/host-echo/App.test.tsx
new file mode 100644
index 0000000000..f3951f94b6
--- /dev/null
+++ b/third-party/nsh/examples/host-echo/App.test.tsx
@@ -0,0 +1,17 @@
+// The app with its page's host (nsh.json `web.host`, `host.js`), which
+// headless `nsh test` runs as the page would: `nsh test examples/host-echo`.
+import { expect, open, test } from "nsh/test";
+
+test("calls reach the page's host and come back", async () => {
+  const app = await open("./App.tsx");
+  expect(app.shows("available: true")).toBe(true);
+  await app.press("label:call");
+  await app.waitFor('text:{"n":1}');
+});
+
+test("events reach host.on, those sent before the app started too", async () => {
+  const app = await open("./App.tsx");
+  // `createHost` sent one before the app ran, then one a second.
+  await app.waitFor("ticks: 1");
+  await app.waitFor("ticks: 2");
+});
diff --git a/third-party/nsh/examples/host-echo/App.tsx b/third-party/nsh/examples/host-echo/App.tsx
new file mode 100644
index 0000000000..d93edca4f2
--- /dev/null
+++ b/third-party/nsh/examples/host-echo/App.tsx
@@ -0,0 +1,34 @@
+import { mount, signal, Text, View } from "nsh";
+import { host } from "nsh/host";
+
+function App() {
+  const result = signal("(not called)");
+  const ticks = signal(0);
+  host.on("tick", () => ticks.update((n) => n + 1));
+
+  const call = () => {
+    host.call("echo", { n: 1 }).then(
+      (value) => result.set(JSON.stringify(value)),
+      (err: Error) => result.set(`rejected: ${err.message}`),
+    );
+  };
+
+  return (
+    <View style={{ flex: 1, backgroundColor: "#f4f5f7", padding: 24, paddingTop: 64, gap: 16 }}>
+      <Text style={{ fontSize: 28, fontWeight: "bold", color: "#1f2328" }}>nsh/host</Text>
+      <Text style={{ fontSize: 16, color: "#1f2328" }}>available: {String(host.available())}</Text>
+      <View
+        onPress={call}
+        accessibilityLabel="call"
+        role="button"
+        style={{ backgroundColor: "#1f883d", borderRadius: 12, paddingVertical: 12, alignItems: "center" }}
+      >
+        <Text style={{ color: "#ffffff", fontSize: 18, fontWeight: "bold" }}>call</Text>
+      </View>
+      <Text style={{ fontSize: 16, color: "#1f2328" }}>{result()}</Text>
+      <Text style={{ fontSize: 16, color: "#1f2328" }}>ticks: {ticks()}</Text>
+    </View>
+  );
+}
+
+mount(App);
diff --git a/third-party/nsh/examples/host-echo/host.js b/third-party/nsh/examples/host-echo/host.js
new file mode 100644
index 0000000000..0e8e2795fe
--- /dev/null
+++ b/third-party/nsh/examples/host-echo/host.js
@@ -0,0 +1,13 @@
+// The web host of examples/host-echo (nsh.json `web.host`): answers
+// `host.call("echo", args)` with its argument and sends `tick` at once (it
+// reaches the app when it starts) and then every second.
+export async function createHost(player) {
+  player.hostEvent("tick", {});
+  setInterval(() => player.hostEvent("tick", {}), 1000);
+  return {
+    call(name, args) {
+      if (name === "echo") return args;
+      throw new Error(`no host function ${name}`);
+    },
+  };
+}
diff --git a/third-party/nsh/examples/host-echo/nsh.json b/third-party/nsh/examples/host-echo/nsh.json
new file mode 100644
index 0000000000..6fb3f6ef56
--- /dev/null
+++ b/third-party/nsh/examples/host-echo/nsh.json
@@ -0,0 +1,10 @@
+{
+  "name": "Host Echo",
+  "id": "com.example.hostecho",
+  "capabilities": {
+    "host": ["echo"]
+  },
+  "web": {
+    "host": "./host.js"
+  }
+}
diff --git a/third-party/nsh/examples/host-echo/tsconfig.json b/third-party/nsh/examples/host-echo/tsconfig.json
new file mode 100644
index 0000000000..b78fc0764d
--- /dev/null
+++ b/third-party/nsh/examples/host-echo/tsconfig.json
@@ -0,0 +1,4 @@
+{
+  "extends": "./.nsh/tsconfig.json",
+  "include": ["**/*.ts", "**/*.tsx"]
+}
diff --git a/third-party/nsh/tests/compat/cases/core/core-list.tsx b/third-party/nsh/tests/compat/cases/core/core-list.tsx
index d1b5a3b959..f21b7029a2 100644
--- a/third-party/nsh/tests/compat/cases/core/core-list.tsx
+++ b/third-party/nsh/tests/compat/cases/core/core-list.tsx
@@ -3,7 +3,9 @@
 // `each`, with a header, a footer and separators, rows added when `each`
 // changes, the padding of its content (`contentStyle`,
 // `contentClass`), and what an empty list shows; `Scroll`'s view around its
-// content (`contentStyle`, `contentClass`). Its script checks each.
+// content (`contentStyle`, `contentClass`); the rows of the overscan around
+// the viewport, rendered once the frames after the first bind them. Its
+// script checks each.
 import { List as NativeList, Scroll, Text, View, signal, type ListItemsProps, type ListProps } from "nsh";
 import { List, type ReadonlyListSource } from "nsh/list";
 
@@ -98,6 +100,13 @@ function App() {
       <Scroll testID="scroll2" style={{ height: 80 }} contentClass="p-2">
         <View testID="inner2" style={{ height: 20 }} />
       </Scroll>
+      <List testID="long" each={Array.from({ length: 30 }, (_, i) => i)} style={{ height: 100 }}>
+        {(item) => (
+          <View style={{ height: 40 }}>
+            <Text>Long {item()}</Text>
+          </View>
+        )}
+      </List>
     </View>
   );
 }
diff --git a/third-party/nsh/tests/compat/cases/core/script.txt b/third-party/nsh/tests/compat/cases/core/script.txt
index 89d143b702..2ffc556682 100644
--- a/third-party/nsh/tests/compat/cases/core/script.txt
+++ b/third-party/nsh/tests/compat/cases/core/script.txt
@@ -112,6 +112,11 @@ frame id:inner2 8 8 374 20
 expect Plum
 absent No plums
 expect Nothing here
+# A viewport of overscan below the visible rows (100 pt): bound after the
+# first frame, never further.
+expect Long 0
+expect Long 4
+absent Long 6
 tap id:add
 expect 4. Item 4
 expect 3. Cherry
diff --git a/third-party/nsh/tests/compat/cases/modules/App.tsx b/third-party/nsh/tests/compat/cases/modules/App.tsx
index a333f8eb04..bed62a5756 100644
--- a/third-party/nsh/tests/compat/cases/modules/App.tsx
+++ b/third-party/nsh/tests/compat/cases/modules/App.tsx
@@ -9,6 +9,7 @@ import ModAndroid from "./mod-android";
 import ModClipboard from "./mod-clipboard";
 import ModDesktop from "./mod-desktop";
 import ModDevice from "./mod-device";
+import ModHost from "./mod-host";
 import ModIos from "./mod-ios";
 import ModKeymap from "./mod-keymap";
 import ModStorage from "./mod-storage";
@@ -20,6 +21,7 @@ const cases: Record<string, () => JSX.Element> = {
   "mod-clipboard": ModClipboard,
   "mod-desktop": ModDesktop,
   "mod-device": ModDevice,
+  "mod-host": ModHost,
   "mod-ios": ModIos,
   "mod-keymap": ModKeymap,
   "mod-storage": ModStorage,
diff --git a/third-party/nsh/tests/compat/cases/modules/mod-host.tsx b/third-party/nsh/tests/compat/cases/modules/mod-host.tsx
new file mode 100644
index 0000000000..0317fec720
--- /dev/null
+++ b/third-party/nsh/tests/compat/cases/modules/mod-host.tsx
@@ -0,0 +1,35 @@
+// A case of the API's test suite (docs/dev/compatibility.md): `nsh/host`
+// on a platform without a host (the headless runtime's): `available()` is
+// false, a call rejects at once, and the root's host events reach
+// `host.on`'s listeners until they are removed. Its script checks each.
+import { Text, View, signal } from "nsh";
+import { host } from "nsh/host";
+
+function App() {
+  const called = signal("not called");
+  const heard = signal<unknown[]>([]);
+  const sub = host.on("tick", (payload) => heard.update((h) => [...h, payload]));
+  return (
+    <View style={{ flex: 1, paddingTop: 64 }}>
+      <Text>{`Host available: ${host.available()}`}</Text>
+      <Text>{`Host call: ${called()}`}</Text>
+      <Text testID="heard">{`Host heard: ${JSON.stringify(heard())}`}</Text>
+      <View
+        testID="call"
+        onPress={() =>
+          host.call("echo", { n: 1 }, { timeout: 1000 }).then(
+            (value) => called.set(`ok ${JSON.stringify(value)}`),
+            (e: Error) => called.set(`rejected ${e.message}`),
+          )
+        }
+      >
+        <Text>Call</Text>
+      </View>
+      <View testID="remove" onPress={() => sub.remove()}>
+        <Text>Remove</Text>
+      </View>
+    </View>
+  );
+}
+
+export default App;
diff --git a/third-party/nsh/tests/compat/cases/modules/script.txt b/third-party/nsh/tests/compat/cases/modules/script.txt
index 8a398e2f29..1fc7b0a9ee 100644
--- a/third-party/nsh/tests/compat/cases/modules/script.txt
+++ b/third-party/nsh/tests/compat/cases/modules/script.txt
@@ -133,3 +133,14 @@ expect Storage removed: a=1 missing=null
 tap id:clear
 expect Storage cleared: none missing=null
 covers [nsh/storage] Storage
+# mod-host
+tap id:case-mod-host
+# no host on this platform: the call rejects at once; the root's events
+# reach the listener.
+expect Host available: false
+expect Host call: not called
+tap id:call
+expect Host call: rejected nsh/host: this platform has no host
+event id:heard onHost type=event event=tick payload=1
+expect Host heard: [1]
+covers [nsh/host] host
diff --git a/third-party/nsh/upstream.json b/third-party/nsh/upstream.json
index a21d2d7bb8..fb0c2d16bf 100644
--- a/third-party/nsh/upstream.json
+++ b/third-party/nsh/upstream.json
@@ -34,10 +34,10 @@
     ".github/workflows/windows.yml": "f3ce2cef5ba8f05156459dcbeba3857c816a2871744729d7a7dd8e58079ecf6e",
     ".gitignore": "09f8ab58f988ed6ac0047bb8d497cdb6a3cc8af3af02a676f6e39a31addec242",
     ".rustfmt.toml": "3e2351e140d029ca8104ed001ae088b8041aaf2e5d9606d5ede1807a986740da",
-    "AGENTS.md": "8f2c3d834c60111abb7389c6716caa7b2a54aaaeb177ddc4c0d1d156da196913",
+    "AGENTS.md": "2da6fc36e3f2065ec906afff28fb55649b186bb5e387fbc80e2f7b4a8b6ae11a",
     "CLAUDE.md": "336cc4fbf19beaada7ccf9986414fa91851a8d7a07dfb3ccbe800a69eed0ab49",
     "CONTRIBUTING.md": "fb27100fec01ba22dde6da1546f9cd761b8e23bec7d47eca87146c7991e85de2",
-    "Cargo.lock": "f1765287f4b60baad0fe758919f878f9da61b6ac0f3e8a17ed9488b3608955c6",
+    "Cargo.lock": "3102413ebdd1dfd794d127aea1344c48d16ea159c65177133eb51bcaa2752e6c",
     "Cargo.toml": "95f2a961a6236d03988ab3d4124c9d7586e9da91f775a2bf9f8e669a95e428a5",
     "LICENSE-APACHE": "ec75427ad955e3c740f22e7dc3f6db6fc5b2cc1d9135c2af91cece8dd47a2d38",
     "README.md": "bfbb302f6b0689fcfdc8d30cd1133fc11bca801677d17e517b2adf2d67b77fca",
@@ -126,7 +126,7 @@
     "crates/nsh-base/src/dock/value.rs": "a8f75f77b4b770755247eac7c07d4340e49541bc3dfd164fbc189fa27d09771e",
     "crates/nsh-base/src/dock/value/tests.rs": "3c730d09df8502d04211f7f3f00ede95be06ff1efac11deebc3d802ef8993449",
     "crates/nsh-base/src/keyboard.rs": "797de960194d5a7c29c0f88c1e14429895067d68ccd00987777615f73673e856",
-    "crates/nsh-base/src/lib.rs": "de0c6516d558654521318e20b97ad0d1609de40d0801a13347903f6aef629f8d",
+    "crates/nsh-base/src/lib.rs": "290ef42b9145ceffe02cf046d72ed4d007068ff7690f9891e7816460a2106dde",
     "crates/nsh-base/src/menu.rs": "ba6339940ab1f77de454cd32a3d01fcaa370f2234bf44352a8ade0d043f1cd8a",
     "crates/nsh-base/src/navigation.rs": "0fa4be89464b3feffacb521991192ca5d71db53a0021f90461c4e37df77c1278",
     "crates/nsh-base/src/popover.rs": "d42902bb2396513bc9eeb98b7e584cee2b99647523779e2e888bf4b0fbbf1063",
@@ -156,14 +156,14 @@
     "crates/nsh-base/src/text_view/mod.rs": "f6d200e37c21aece2cf3355781577539a9830118d3e55d34356a55776d3b5a7b",
     "crates/nsh-base/src/text_view/parts.rs": "1cc12fdfdb815ea5eb44ceaa37467849b9005d99353809150379708f4b7c1add",
     "crates/nsh-base/src/text_view/view.rs": "e1162a8a0e79876951ffc30fd3f930346f2cfb68969d03c284ca350c9e5e4105",
-    "crates/nsh-base/src/theme.rs": "cf2c7a0d55987301434e8c61a96d4190c0945fed9c52632076c50812aef8b432",
+    "crates/nsh-base/src/theme.rs": "2eedb3df1738c45b6c295f18cd5a8a2f4959195c87d9c304fa2d8e5ecd8bb9c5",
     "crates/nsh-base/src/tooltip.rs": "5ee4deb7358e62d9e88b0287d06624e538c21619229b4cace4ecda09c214c5a0",
     "crates/nsh-base/tests/base/dock.rs": "d6657e7a078fec7ee3f3640540b8f2935255d68a176af79444713574d8177c27",
     "crates/nsh-base/tests/base/main.rs": "d4bd54e22c71fee7a6ea0f4c242b77aaafd24c7af9773b86db6d26b6e1f9b699",
     "crates/nsh-base/tests/base/resizable.rs": "77d40746a511c641b8332fa3187ecc890f278cac1582a4c5a613476fbcf200b7",
     "crates/nsh-base/tests/base/sheet.rs": "2a7cfda4b7087d85df7f4e65e23c2386fe3b91a868339aa7dba159e42e27f56a",
     "crates/nsh-bundler/Cargo.toml": "8284a211f8f7fbf7fc14de5ac6739a5b837ad2db30729bdbffd70001df95000f",
-    "crates/nsh-bundler/src/bundle.rs": "812e38f4d8522267f832f4381d388c2ee8226729beb9605ceeea610c35600721",
+    "crates/nsh-bundler/src/bundle.rs": "19e06f32851a6f70a1ae37b709bf53c26d2165d08d9692654bac63c15e321668",
     "crates/nsh-bundler/src/classes.rs": "b6fc4affe5ca9ee0dc38b0b00579375dd2c6d6684424e4b6aa08371ab965fb40",
     "crates/nsh-bundler/src/compile.rs": "a8c3881697eedf15074495c7bee24c5bf79af9667ecf5a99fb6089dabdeb6445",
     "crates/nsh-bundler/src/hot.rs": "c3655c9dc2c1c561831bf33d0a7915bfa596063ac130cca4b79e37a021d76c10",
@@ -183,12 +183,12 @@
     "crates/nsh-cli/src/build.rs": "930e9e61189b299f9b4121db7810c48a463f50d2e45d3dae2a88d820ab08d309",
     "crates/nsh-cli/src/cdp.rs": "a7df2735afb1013b4946b9d21393c9d72e1afa2e28e23667eaa242eb5878681d",
     "crates/nsh-cli/src/check.rs": "4caecd505d9a8464a99eb6ebc3a34e047aafa205c86e794524f865b4761677ff",
-    "crates/nsh-cli/src/dev.rs": "e25c40cf05389b1c2eb8d0de22a5d4fd33492eecd1ef0c0a5ec96d36421dc4f3",
+    "crates/nsh-cli/src/dev.rs": "0c512351de593209eac4c22c1e388b77c5c5a086fb1bfa38e7da88c7ff6e3161",
     "crates/nsh-cli/src/entry.rs": "8880023842ca765687e3ce6f356a9f8498881fa8d90f459c025702964098ea41",
     "crates/nsh-cli/src/init.rs": "953623491dc2fc64dc1f72c113b8d18edb564271b54a3e62ca7ff8648bb41f08",
     "crates/nsh-cli/src/logger.rs": "09574fa1381d8dc650d820f6953541601180698657cf3eda2f1429165122840d",
-    "crates/nsh-cli/src/main.rs": "feb8ecbb8f3e4b33cd29e3df840198f28cead2c3341bce22407d234b734ed46e",
-    "crates/nsh-cli/src/package.rs": "40c91f4ec6e23cacd7fc256c3bed159a35a52e833de67f21797fb9917fbd542d",
+    "crates/nsh-cli/src/main.rs": "f01c93d5be6e074924c45875082b44dfc1dee6c088d039d5ae56c2c10592d683",
+    "crates/nsh-cli/src/package.rs": "f75470945d7db517f8d493f6ca613d6f14a9e06b2df0edb9dda7c6aa8fdcdc06",
     "crates/nsh-cli/src/run.rs": "077ebada325b5ac5470a21a4be510a2507c28f47711be42844e53914cf8f9f51",
     "crates/nsh-cli/src/runtime.rs": "5c0c87e5f3793083bc66dcd67c0faff2362de0ee788145fa00198cd29c5b9628",
     "crates/nsh-cli/src/rust_app.rs": "ada2a98a27628f508eea1fe818a2dc4252a7c60b1d73179dd3eeceba076ddd8e",
@@ -206,19 +206,19 @@
     "crates/nsh-cli/src/templates/tsconfig/3.json": "832b63d1c87c6381f7d49d7f55394ce4ed2503d3cf0d0124b81a88ec9621a330",
     "crates/nsh-cli/src/templates/tsconfig/4.json": "d7972c656ce45ce90df97fd5370c26c660c5fe8fca5355cf24822498039685ee",
     "crates/nsh-cli/src/templates/tsconfig/5.json": "5d6ee953f0366848a5cbe9fe9d0a6b31637d14e8dfb20761e7730c9228753b1a",
-    "crates/nsh-cli/src/test.rs": "ebf44d8fa58c05c0375a0cd7586a3f0ee38968f9c2897682c7411bae8599bce4",
+    "crates/nsh-cli/src/test.rs": "a938625c17784be9648ab3525bb76b3e549c73c8f3173a4028db531f0fba2867",
     "crates/nsh-cli/src/test_android.rs": "6f3ca5c6caf8e46d48fa7d3046e82a9d1d5f50efd1a6a68a08f3c0472295c48c",
     "crates/nsh-cli/src/test_host.rs": "1d180f234bed65f2e53fbae83e3b9547644e17d3d5bfeb165a12e15b874cf892",
     "crates/nsh-cli/src/test_ios.rs": "aa5e20afee9e655471d7971c31f94ed9d3973784dc13edc20019f3ef45de3ef5",
-    "crates/nsh-cli/src/test_web.rs": "0f4d0ce39f86ffd4a4de2e6d2d6b9d6f69427aaf10e7c1a3dfa397fc440bdba9",
+    "crates/nsh-cli/src/test_web.rs": "cedb081afca9ece1830142ce344f80cfcc08f217ae6d92dd05ebb7a260fc1748",
     "crates/nsh-cli/src/tui.rs": "edadbdd4996dce76cf87b4e8eb4e4101fa6ed338f2ebb7f0725a6c61d2bde6c0",
     "crates/nsh-cli/src/ui.rs": "b76573d4d63e976026013526e661b0f32d74f9da995fc1facc7f3f48c040735e",
-    "crates/nsh-cli/src/web.rs": "736b5930852cca6f86b5c7ceb376308607a1804dc42ca092869c8c67cfcffcfb",
-    "crates/nsh-cli/src/web/dev.html": "6ee194e1e1eeae680741635ff7c4f1f2e8a24613bf190d2ffa81a718dba27bf0",
-    "crates/nsh-cli/src/web/index.html": "e31fd8c3e4d72b3b82cf47e79c3dc5fc8405321b9c26558ec86904a90703e49a",
+    "crates/nsh-cli/src/web.rs": "9990b4ddd89ef561c10da236f9613ce731b2aca9bd91c4b867452012c34332e0",
+    "crates/nsh-cli/src/web/dev.html": "e4ff740d6776686ce3fea4f9520f4e811b4adfdd04fe76dbbf3ed167fbf31f06",
+    "crates/nsh-cli/src/web/index.html": "706946ee021a1b0e62c11200242b526d351fb1cde61711fdc449512ad9610dbc",
     "crates/nsh-cli/src/windows.rs": "81b1e8af3c91b3d45f7c0d05ac2abef4ca426403e30f7c71c8029d74bfde6b24",
     "crates/nsh-core/Cargo.toml": "9e5e298894de41f52334ba29bec2c3cbc37ce8676f754701c9f4680f99f7a7de",
-    "crates/nsh-core/src/access.rs": "5e880fb21cc43c8c28fe42bd06a08f56520d4eca33c8e404b199cdc83ac3d764",
+    "crates/nsh-core/src/access.rs": "9b45558ea450cab93f570d28de3d9ec521b2f6f5f82700bdcc63188202a4e3ec",
     "crates/nsh-core/src/animation/animator.rs": "a5ccd0ab2bf2f353bf7e31c0e8ef11151f443d3a551ee63ee7487f77b87a7506",
     "crates/nsh-core/src/animation/driver.rs": "68f951a4572f81335911c19b65329b3f920a85d9ad402d566716f84c1372311e",
     "crates/nsh-core/src/animation/graph.rs": "a4fe8ca39d20fffce8f2114ee8d834c82044053ad5ef34c17e3bfa037c7e1fe2",
@@ -230,18 +230,18 @@
     "crates/nsh-core/src/clipboard.rs": "36b1c87f8762288ba9f8282daa03a5deb5541769afe75d58a7ba0cf8fe7defb1",
     "crates/nsh-core/src/color.rs": "93afe9bf38e8f7b12108fedc4863f8e1bee3a55ae7d0c75e788b79b02fb72a93",
     "crates/nsh-core/src/core/classes.rs": "242ff14438c0785b6d29af6315273d4f0256cc048fad221b4cabd2f3a0f2027f",
-    "crates/nsh-core/src/core/components.rs": "93f3cb23f3a570c620d8204bf46a125885f35cebe71e56e637eaafcb673dd057",
+    "crates/nsh-core/src/core/components.rs": "8f7530a4c7734f039b6eede7a50201b2baa4e6617d4c5ea74e583dfe72220421",
     "crates/nsh-core/src/core/layout_tree.rs": "767715a7dbbb6d28718cfea4b2893dddc7a0237d48057618738ea80616080c33",
-    "crates/nsh-core/src/core/list.rs": "e709dcf029ac3d8d0c4bc87b4e1984ce07f50e18266d0759eaed773de1902684",
+    "crates/nsh-core/src/core/list.rs": "cac76d6037c248f885412ae5a08a7a93162cdb327e7d58bb12f28940c695b3f3",
     "crates/nsh-core/src/core/measure.rs": "c6692abd3002576afef5b6d777d7694e28e4754bf7ada0adf8ed1c92419289c1",
-    "crates/nsh-core/src/core/mod.rs": "50805dbbb2f7f3b00a100d6b7a30cb4c5702c22063ddb4fa38f8d4e3a8d57906",
+    "crates/nsh-core/src/core/mod.rs": "218e5d4f39a5333c2bbe621e52a54fa88e9d156344671160df9cef63a0fed046",
     "crates/nsh-core/src/core/overlay.rs": "7839b8e7da75b4599321d486a8891dfbd3a4514bc1992926b60fdb5b64640f29",
     "crates/nsh-core/src/core/parent_viewport.rs": "1326e1b74de2d14cb9b0a99be4ff00b6f2509f79c6a98a3597e8508ac08d3fa6",
     "crates/nsh-core/src/core/reading.rs": "3bb3d25dc8b97f35eb73dcbaf2d6f1b5dff8cdbdd012a8edcef7fab6661f3d9f",
     "crates/nsh-core/src/core/renders.rs": "eba08379b95272762630258bba8c9d1908f8e8ec94d9518bfd59470edf49b5cd",
     "crates/nsh-core/src/core/services.rs": "8812eefd00661841ff2b1de5d4f8cf68dd667da4151b4c79ed966cc940934752",
     "crates/nsh-core/src/core/sticky.rs": "2c508038c1677fc10f11b5e6c1bf245cb78a75a02beaac24d0b09b2033a4beaa",
-    "crates/nsh-core/src/core/tests.rs": "d6f30766f59f6a3ad46c8e9d46b7416dd14142666f7743e6be2f82797316585a",
+    "crates/nsh-core/src/core/tests.rs": "7564074f04082156eb00acf4efb5f0e47da753a2f3158d3e1d6238c76cbafa61",
     "crates/nsh-core/src/core/touch.rs": "a8559c023a443a6defad135af2f319e60130e7df23af2a8353239b0862e494a8",
     "crates/nsh-core/src/core/views.rs": "c32c59533d5ee33bc16677b0459816f7670c224f3f8de3507c72029989660834",
     "crates/nsh-core/src/css.rs": "56760520c706f64505849d5a32a1eada2d9fd243a5c70e07f21bc8ed0105217f",
@@ -264,20 +264,20 @@
     "crates/nsh-core/src/input.rs": "9ef67d528e7a00bb4b325a425aa50e20ee3ebbf7f62325201520b6a3f4ffab07",
     "crates/nsh-core/src/js/animation.rs": "e2bb991a9e414e81f1d4e9449b4c017d42a5a54f529f4713651e1c5badca32d8",
     "crates/nsh-core/src/js/canvas.rs": "348900df6c3d12e2061e09d7d7c1d50fc409cb1f42fe4da2e1400e89fab93679",
-    "crates/nsh-core/src/js/host.rs": "3ff652f77711cb48a1255805fdd51d34d5c514e14ba9d1d4ad3cb8c192c97613",
+    "crates/nsh-core/src/js/host.rs": "8dea87bc668dadf7b6427a0fd749fd336d63dbd16ecc771f75119fc7ce60e269",
     "crates/nsh-core/src/js/host_values.rs": "f8da8a7af5cf78c4e514357c7d15b5cec5d32b60dfdfd103a28a32bbc89dac09",
     "crates/nsh-core/src/js/limits.rs": "44bafb43b6fc14a6899f7f7219fb8d7505485179bc0ec470f76d9266d9b35fb3",
-    "crates/nsh-core/src/js/mod.rs": "0377b9d8f67c77acc47a467dc709090d3c5951a3f4ae6118abe680e97bfef9ca",
+    "crates/nsh-core/src/js/mod.rs": "3dc93ddfd5bb86533bb7289df1ee9b5af071cc493617b06b05fce67f667690c3",
     "crates/nsh-core/src/js/ops.rs": "c43398b95c3d9a6d58027b19bd3fb4d073287986939d9b894e9d4d1c905c4cd4",
     "crates/nsh-core/src/js/protocol.rs": "19c4c34368d2a05a196e580d5813159f44e3d8183b9c6e9168f9b3ab3db4f91a",
     "crates/nsh-core/src/js/style.rs": "8b80f7b45468d22da5403a25092087d42340ca119e6940309e38f22ef1651926",
-    "crates/nsh-core/src/keymap.rs": "c55d58813d5d84d2c33dc9a9cc9d9ef95e3e5eaef4e03a1af42c863674281685",
+    "crates/nsh-core/src/keymap.rs": "0ee7d4c0d5fa74ca6ee4bc32ac7889caf51a1cf0727651e4648e4cd109aadeea",
     "crates/nsh-core/src/lib.rs": "5106d92ca05d9f46573efa426d1abeef8d7b5cddf71d4b1f7e3f7beda339c82f",
     "crates/nsh-core/src/mount.rs": "d8b4f34e10db29189b29538041345b7c3cc90f2d743e48f6d20ad453d51471d3",
     "crates/nsh-core/src/navigation.rs": "d79dd1d10a04157cbc5a383dc7b01aee820e6a33a16c682d3e43dc5d4909f5ea",
-    "crates/nsh-core/src/ops.rs": "cc2c363638171332617f74ea3534c6da6180c4cfb6ab1ef2cf065b79ccc43d2e",
+    "crates/nsh-core/src/ops.rs": "7bc0cbe50b8669cd22f2d84e315437cabfd0fa687d322e6d8840da61aff389f9",
     "crates/nsh-core/src/overlay.rs": "e3029e8e8827a4365a4ac91162c77baa2a8e76fcbf22ded7c46cafac8cc33b13",
-    "crates/nsh-core/src/platform.rs": "d0acf7489905e456a648aac2cd2e892a0a7b0a34ead24de921a7cb33bd0029e3",
+    "crates/nsh-core/src/platform.rs": "2170c0865a2dab9f20a99ec99d2fc92a4242042964f6a89852c06dd2ac6c50cd",
     "crates/nsh-core/src/platform/clipboard.rs": "42bad81067d3339c38fbd45de16611c92bb6dcbf0c400c5486962ca19e70f282",
     "crates/nsh-core/src/props.rs": "88d70945adcdbcf384e6b13ed1105d6f683b877e7a429a9b0804b9b356415963",
     "crates/nsh-core/src/reactive.rs": "0d2d4c952be9b7f1c6c9c12898aa5d63b30e9401e8621194fda80904470e41df",
@@ -288,8 +288,9 @@
     "crates/nsh-core/src/runtime/clipboard.rs": "916e9bfcf05cc6b8702f0fccaa20e7e1062a71dcacc820d625fc34ecfccecab8",
     "crates/nsh-core/src/runtime/desktop.rs": "9f178654e9ddb48d258ccf198b5faac58f68cee3c3ef9fbeb079ed9523b87b46",
     "crates/nsh-core/src/runtime/fs.rs": "b7f6770ccaefe5a04b034d649ef639601876651bece06c03b5ed4e5265d05d97",
+    "crates/nsh-core/src/runtime/host.rs": "e66077432dc0ec52f61f3b3b1b8e3a0a80779e22323dc16f539ee8d7498060d8",
     "crates/nsh-core/src/runtime/image.rs": "df044f7d2f061309859de8b1c0f293747c85721eaae7ee265805da8c0517b811",
-    "crates/nsh-core/src/runtime/mod.rs": "6bb4ba9ac5272e343f2b3453c56151388bc2a49a108a53d11f8b7b12449de0bc",
+    "crates/nsh-core/src/runtime/mod.rs": "e16f88133118e0c7152d757929636d3775da374f9546a085e9574583d13b5642",
     "crates/nsh-core/src/runtime/net.rs": "698fadd3e298e8da089e4d4c88ed5f9a714028b021e5155c89fc6f5e86bf143c",
     "crates/nsh-core/src/runtime/os.rs": "741a77c8a93dc6343873aea7b90a951f9340bd9a61ddaf185e6e6a3108bb81ea",
     "crates/nsh-core/src/runtime/process.rs": "53eaea00026fce5689ee9d500aba7508f1e79d9ea3e4867232f9fdc281978efb",
@@ -319,7 +320,7 @@
     "crates/nsh-core/src/time.rs": "c342c2e1d620cd2e8d44691e10064e56505253b696f543c1223c6ca4e34a137d",
     "crates/nsh-core/src/transform.rs": "7a5fef9eb9b544d114ccf0f3c11e272aaed77eea1124da06bf247ef354e3b07c",
     "crates/nsh-core/src/transition.rs": "f468b6d0a6e07d12ff76d7abbfef4c00233f69484fdfa4bb6eb7f288116375d9",
-    "crates/nsh-core/src/ui/ambient.rs": "864367f4f2d4999056e9d804ea0584807c37b377dfada1f5864756817d6bea7a",
+    "crates/nsh-core/src/ui/ambient.rs": "eb874449051ba9c5898ecd9c47b7deaab2d81d7ecd99f2dcf41f4cb8fe36a88b",
     "crates/nsh-core/src/ui/animated.rs": "b5e802d1f29181f663e375cbf6d6f09cb1706e4141ebf0b9289f25cc48579344",
     "crates/nsh-core/src/ui/asset.rs": "ecc4b40dbe52c239f69de438eab9444768bb5d7e4069aa15a51c3adc7477702e",
     "crates/nsh-core/src/ui/back.rs": "ef98d58af2314de48ec9fef54677a69d663a6fbd6fcbe90ba5f80f8c253bbce7",
@@ -332,7 +333,7 @@
     "crates/nsh-core/src/ui/element.rs": "0caf17597339f476132700eb31fba3a992ab4e43818b83e51b10113801daa535",
     "crates/nsh-core/src/ui/element_ref.rs": "18505f479859e82aeaabe2522318adf0004f2301ba9d7119bfa032f1fc5bc233",
     "crates/nsh-core/src/ui/engine_context.rs": "fc9f4487a2ec47939119d2c6f5c75dc081ede1feb0422be8127b19fcb129fa1a",
-    "crates/nsh-core/src/ui/env.rs": "6560f20704070b7bb08c1e8e7c58f63c86afc53c150aa41fc571460a0c8e6b37",
+    "crates/nsh-core/src/ui/env.rs": "46aeba0e33d4335410b4e173e91f49dc343985007a20e5f8ec91d83d7bc19cfe",
     "crates/nsh-core/src/ui/events.rs": "40d4d1b562781ac13732685d5decdffb81860010790469c9b4219cb3df9736fa",
     "crates/nsh-core/src/ui/flow.rs": "3984b86ad1aaba5a1dd8fc32f93c139dcf3260d86500f6791ac8dd15d6de756d",
     "crates/nsh-core/src/ui/host.rs": "03366afe31db0cef32adf10f5bd9eccb2406365bf1f5df711ad0e063ace1f408",
@@ -351,11 +352,11 @@
     "crates/nsh-core/src/ui/responder.rs": "b5e419ced70b1457743c4c9fd61e3cc51c93db348581cc9bd14b61fe695e0e43",
     "crates/nsh-core/src/ui/rows.rs": "a5fd4db97517c39b1e4d898c19cb4a8e60429ffc6de1021527df0f2cd53bb365",
     "crates/nsh-core/src/ui/scrollable.rs": "704ae082a1f10e2b344e20299e52177ee77990bacf83eb71c6523e0419f8ec6a",
-    "crates/nsh-core/src/ui/services.rs": "df1e10972fbdea7b4af78016948c214eb393c49ef8d4e023e9f55d022e91a8d6",
+    "crates/nsh-core/src/ui/services.rs": "3009cbdcb6dd76d922d1295759c019bd200e084b3112c944d904b2b1a9e8b498",
     "crates/nsh-core/src/ui/status_bar.rs": "2fff306388c47bfee440e1042f9143251fe0b210cd57a370e5e8a8247c537acc",
     "crates/nsh-core/src/ui/style.rs": "3a39cfbd53c8a754d59685e73ccb7cf1da431063c8b7a4ec0379bb9fd039498c",
     "crates/nsh-core/src/ui/system.rs": "077e9dada158047e159eae15a86eadbdff62875e473e11255afacf32a20f9607",
-    "crates/nsh-core/src/ui/task.rs": "e19a244fd39fb716a71ad33f653f3f24cc3e390c40159224ad341aa51c37e174",
+    "crates/nsh-core/src/ui/task.rs": "9416e77ea03b568807b95a3c84bd85b3353b7d5788adf6d4bc605be3d17404d7",
     "crates/nsh-core/src/ui/tokens.rs": "c95b983afc27c65290b22f5db7e9ece7b48d05e7f4c769c2e882e81695ab4756",
     "crates/nsh-core/src/ui/value.rs": "81804c80fb2a357b40f55fa4586c55a818370cb641e8c074e6f3f8f6046ab0b8",
     "crates/nsh-core/src/ui/virtual_list.rs": "e3d0ae1417072a65323d978a8fdf7911d936d82f4304993e5ed74b2589335abe",
@@ -374,7 +375,7 @@
     "crates/nsh-engine/src/access.rs": "37cb4b18aa2f1d47325969b035bd08f5d6cb9db6c5b437a6a9aa7bd2677f6707",
     "crates/nsh-engine/src/app_directory.rs": "bf99d20949b392e5b919f8185a6e234308d53ae7db02c29b0d18ce678b74986f",
     "crates/nsh-engine/src/error.rs": "54bce4d455c05a1c37b3e0ce8e46271492369855482283ffae3851605b44bc51",
-    "crates/nsh-engine/src/lib.rs": "e443d068bc5197bbc89df2e6bb648a3ba1c2cc679baeaef15eb110f43bc04252",
+    "crates/nsh-engine/src/lib.rs": "96617c9770085ca93389e32017d14a375e8b1fe75ba332a6d1e1f37d322dafbb",
     "crates/nsh-engine/src/local_storage.rs": "bf75d06d4d917a14b6d4f08abb10d385c68ed4ab50b6c70c4851cbc72ad3f631",
     "crates/nsh-engine/src/net.rs": "3a1fe7de92639eb17aac9e97156b7a9c6cf9384bbd9a973548c12434b48910f9",
     "crates/nsh-engine/src/perf.rs": "29aeede364da40ee8cbdf2acb750abaab7cee523b2df34870b7bbeaf291f1a33",
@@ -386,23 +387,24 @@
     "crates/nsh-engine/tests/engine/clock.rs": "b11f9c4240f2787b39dc819bdbe90b2d3666b4ff3f89d267ec90d5356780495f",
     "crates/nsh-engine/tests/engine/compiled.rs": "ed35fbe5be966d3bdd3d3cf6f7d090dce664e1312a8484b36cdab2dbbbeaaa54",
     "crates/nsh-engine/tests/engine/component_bench.rs": "3d9625ac6a683620aff4c277addb251f724ce1fdc20fc1dfd228a7b3fbf17aa4",
-    "crates/nsh-engine/tests/engine/components.rs": "8bc03ffd81b3ef86d101cac1d2fbcab88df98aa198bb8d55fe01a1e65653a21e",
+    "crates/nsh-engine/tests/engine/components.rs": "49dbba00e8c97e005d872161e40bcbf20314e97dd215388bc9bbfee87cf11b25",
     "crates/nsh-engine/tests/engine/counter.rs": "1b0f9981461d121290e72789c2efe495c46dd19df138a9dc908977fa7364aa05",
     "crates/nsh-engine/tests/engine/errors.rs": "a050ab6f85a865b7701563de2230c2c316cf94a40eea15a17702a020d2657c42",
     "crates/nsh-engine/tests/engine/external_wake.rs": "4c114be3b7489e607e9826c8264bbf9bdd161479fbda8f598da6595661ab09ff",
     "crates/nsh-engine/tests/engine/fetch.rs": "30a4ad7fea1aa1f3e481d9774ed3aefcd92832f36f3347841a8ead672e05368d",
     "crates/nsh-engine/tests/engine/fs.rs": "71f693539ff71b4ddccddd925739a52a695e2fd22335cfad396304395147b01f",
     "crates/nsh-engine/tests/engine/handle_bench.rs": "fdfb1c00cbae1dfa73b3b5fcd373bd839774d2f137642a5dd1b09bbc8ad12675",
+    "crates/nsh-engine/tests/engine/host.rs": "b9d3364dd822cb84fae74a159fcb1d51ef13279907d1e86ab527d6b26124a498",
     "crates/nsh-engine/tests/engine/host_modules.rs": "261558f3b5badd08e89586af65893603f195588508b99e3d6164f572bc045cba",
     "crates/nsh-engine/tests/engine/hot.rs": "98e44b2518e746f86a61ef503af030e7d460becfa3b66891d3a98228bcf8be46",
     "crates/nsh-engine/tests/engine/hover.rs": "555c2bf8043e52d181dae2e4a35d01182fe44258d5051be1c5412ad1613b7482",
     "crates/nsh-engine/tests/engine/images.rs": "775dad08ac5ca247b8cb5388e40c9a70b3583636bcc1bf8bc4d09551aacad372",
     "crates/nsh-engine/tests/engine/js_touch.rs": "dee2b2ac3299459a90ef785e24081e0fd280dc9aa91d650de69e8c938ce61888",
     "crates/nsh-engine/tests/engine/keymap.rs": "85fe9f59f13bc07c66148a1d2cf8ef62858e914a83d84dd451c4d6af7d8ad1a4",
-    "crates/nsh-engine/tests/engine/list.rs": "757a325fb32271922cf977d35307431e6c8fb6e1db19d4991cc9cb783e06e1bd",
-    "crates/nsh-engine/tests/engine/list_bench.rs": "df4cb2074bf17123776168d8650ce2575595a3b7162796b0dfe793c880e022c6",
-    "crates/nsh-engine/tests/engine/logs.rs": "e40d974c261a3b025b80dbbbb2be83e2d58f8ccd07ef9a77b82f740d5795bd9b",
-    "crates/nsh-engine/tests/engine/main.rs": "0db1654e25ae62e4d496446e38e8c0b369fc82d1435ea8b791ed5c0e8ab703f1",
+    "crates/nsh-engine/tests/engine/list.rs": "a2167ff0e4eba7d4066b9687ecfd155609e81fa76967bdfcac661f6b1b61e9b1",
+    "crates/nsh-engine/tests/engine/list_bench.rs": "54cb3ab6e5b415e02cc7f8fb6e79f0a2c3ca2f3fc022fcb5f4a545f25c179080",
+    "crates/nsh-engine/tests/engine/logs.rs": "22201bd9432b8e3801064aba2a5b77d3a64f4a4b148133af5a39e2e26a33c0e2",
+    "crates/nsh-engine/tests/engine/main.rs": "cd4bd38e3eee7064bbad3d25eb8a72f9db14bcb1d0d0d4883c319a343cd09249",
     "crates/nsh-engine/tests/engine/native.rs": "91e9f9255e84e0f4f93e40e72fe1953d4bf305818cee95b102be715ba9bf6fc3",
     "crates/nsh-engine/tests/engine/net.rs": "d6b6b5cb734f7a0ded0063d193919ea8ca1d783511102d5078bb4e8e6c07ceb7",
     "crates/nsh-engine/tests/engine/package.rs": "5dd7e22c6d63c010b1ed3d701810442d112120b143fef6481cd44d33c43f09fa",
@@ -426,7 +428,7 @@
     "crates/nsh-engine/tests/engine/storage.rs": "271620e86942712de84bbc3c0ed71763f38d8e9f56bee3717fa38ab6494131f2",
     "crates/nsh-engine/tests/engine/system.rs": "fc1b5c7d5417d256cdeb51451110b728f31b85862aab377e3730540df13d4a63",
     "crates/nsh-engine/tests/engine/test_ui.rs": "e5e6f8b9acd4124668deec4fc8860ab1849c1f3be82c1244330ace6e4599b5fb",
-    "crates/nsh-engine/tests/engine/theme.rs": "fb247e36f8ebf78c568fa6319e4dd4b5532f2f973783737f6c8778246be97c08",
+    "crates/nsh-engine/tests/engine/theme.rs": "32eaa004b7b5ace88ec4b855d7734f6e9dddf5dbc0db170058f00cdd1e34cf55",
     "crates/nsh-engine/tests/engine/websocket.rs": "d598ae8ba425749118c9269d6cde057c38fae0f029758b58538d8e79368cf133",
     "crates/nsh-engine/unitless.txt": "608551ec4de1da091099bad8e64d961a352681da134c4af9b58b9a29a9e43018",
     "crates/nsh-engine/untyped.txt": "0e94c66fb47844aa29a7b5deab131001f3ab89ea46e79c1d75679bf618788c10",
@@ -450,16 +452,16 @@
     "crates/nsh-js/src/dom.rs": "b75190400a363ed60f5d6737be4cb4d14f8d69e819201bb090bfc4dedb98d56d",
     "crates/nsh-js/src/env.rs": "571522044cb3d43799d185e363edf87622a03580487db85e5827ea59229d0ac5",
     "crates/nsh-js/src/error.rs": "01a5f911656faa39aa30dbcd71789ce30c35b3e740c5f3ec5125c87b85282bd5",
-    "crates/nsh-js/src/host.rs": "3740d306e43b9eb5c81f30623044f44ee166828c9100425f691bc3d244e94931",
+    "crates/nsh-js/src/host.rs": "b089d561e7872692f75459055b117a732f6ae5f0c4930e2f1e7c485ab0976789",
     "crates/nsh-js/src/hot.rs": "6286e13ee994063c1c72292bfdb44c964dad45b127edb24a2d76461bd163d621",
     "crates/nsh-js/src/input_action.rs": "eb70b0792eff82ccda139f63700656167ac04753872fca2aa63485673d9ab047",
-    "crates/nsh-js/src/keymap.rs": "cf6a81a283c6f3ea77b07dabc1ab8ed3d64b88cdc3d51e8889cff6cc757806a3",
+    "crates/nsh-js/src/keymap.rs": "aece0cd38a1bb2fd432069f4135cb3f2efaa15ebc6fd048d11f498458f1b8d8b",
     "crates/nsh-js/src/lib.rs": "396374fe32e378c38d5ad936b6146569fcf183c16f5133e6ba8d46b231ba29d1",
     "crates/nsh-js/src/list.rs": "dafec1262d993ed59e80067ed925ba3e1ee4481a68a1a53dd604126e4e81a998",
-    "crates/nsh-js/src/modules.rs": "c23d93fbf0bd5d5680fa02e402ed2c95bd574f6c3d6a900e9722bdf55230362e",
+    "crates/nsh-js/src/modules.rs": "28a938a4cb141000513eac5e70e8e4c419d2b709a31f15506252a8f49108cb8c",
     "crates/nsh-js/src/navigation.rs": "23d6bce4611eff5da1516561caf7d89082059b2aaae0cec47f72efe955998401",
     "crates/nsh-js/src/net.rs": "2c1e993f6e58ecb095a78ceac05357ab747cf8d21769507ebbc3c74c63fe6950",
-    "crates/nsh-js/src/platform_modules.rs": "26c25867b012588b1d5fc0783a99b0c650915a9a48d797a57f73ecae34008d09",
+    "crates/nsh-js/src/platform_modules.rs": "971d3d89b57be8abd8f521e92eaca170df7e04485be861360bee18cefd0bc002",
     "crates/nsh-js/src/protocol.rs": "058fa87197c16aa0bbc76345244eddef44f0fb7a8c669ff974486889648ac4dd",
     "crates/nsh-js/src/reactive.rs": "36773de3b3e8e1368cb9960fe3925d7d15e746dc85df03c659c55527d7683e67",
     "crates/nsh-js/src/rejections.rs": "7873bcd5daa9b755951b0ded491e72ba9c9a82f77526dfc41df829a998fda7da",
@@ -568,34 +570,35 @@
     "crates/nsh-platform-android/java/com/longbridge/nsh/Json.java": "80d814fa3a0357463394fb0d84d98ec02687abd42b2d3521037db2e1ad20db27",
     "crates/nsh-platform-android/java/com/longbridge/nsh/ModalHost.java": "a40b13eadf1061ae52d9ef92ea55352d836af64e0d846a48a3e9856c56a4ef79",
     "crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java": "6ae84283cfb51b9976a3c1c59550e826c13add4c0c7f00ae4ed59b85dea69465",
-    "crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "b97b3c79261f9362e07c70cf73c288fe27ea20e7a5f66e9a48a6bf4a8e1017a3",
+    "crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "5949506a2d24366244796464fa41195987236c8c5804d1a9f4c865ff8ab9af37",
     "crates/nsh-platform-android/java/com/longbridge/nsh/Net.java": "891adea10cb4f8cf0020ed3854eac241572b00b32506e129987715db2a183149",
     "crates/nsh-platform-android/java/com/longbridge/nsh/Node.java": "d16e8e7d2375e3633c3cff5edeba08cf38d08726801f67721b12ba51bcce4e5c",
     "crates/nsh-platform-android/java/com/longbridge/nsh/NshComponent.java": "70005073c746cc06ea2421a788443abd20447dacbdbe62a9cd4e8b2cbb4b3011",
     "crates/nsh-platform-android/java/com/longbridge/nsh/NshComponentContext.java": "2c1a1fcb84cb5c9742ff2a51021be1c304a8dd7db5e1841a6078bc3a0fab0d97",
     "crates/nsh-platform-android/java/com/longbridge/nsh/NshPackage.java": "8f15ba9b542631a20ff0edba6c2a159ff547fe713ea98e1a160e8b52c0c76474",
     "crates/nsh-platform-android/java/com/longbridge/nsh/NshTrust.java": "9a9027f5eab16e5f54641c72914b319c53e0de2f2502a1baaf3eec941f2ea527",
-    "crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": "f9e25b8234d2786c8bb13073f02b958b7739d8a38db4f5c122450e2b4a29ebb6",
+    "crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": "5f6259c51630cc857ce95526d433f5d5eda3fc8e54ae3dba772a1a6c1fdb059e",
     "crates/nsh-platform-android/java/com/longbridge/nsh/Pressable.java": "5bb17da3a0e1fd7519d34498752183f4e86fdc6e8d17102cd2e5a31fb3bdf98d",
     "crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java": "9fa43f2b9a68c711fbafde8e7c7eda2ac44782838288b1e6ca42c05472d6f9d3",
     "crates/nsh-platform-android/java/com/longbridge/nsh/SelectionArea.java": "376a3abe901a71c03ca95edd4189a7fce1b3c620b5b4830b269ff68c99de213e",
     "crates/nsh-platform-android/java/com/longbridge/nsh/SelectionHandle.java": "5c0ad0957390540d21c4b9e351c343a2c7f0532934f6ed54b8cb621102d169a8",
     "crates/nsh-platform-android/java/com/longbridge/nsh/StackBox.java": "f712333f289b2eba41611a7ef36b677d1f12a7850d69a9fa389e77959ec5eb1c",
     "crates/nsh-platform-android/java/com/longbridge/nsh/SystemUi.java": "6001517437d9828a2b5cf65f9b4c7bea96380decf5f5f02c81de4991934dc208",
-    "crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": "aebaf9af4985c6502a043f4dad054b0453b84cc4a2f772a031ae00a653c917cf",
+    "crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": "26b3bcdfcd9d872ebe55429f561026d8361fc8a7a9f5ceb4f63c78613d14a795",
     "crates/nsh-platform-android/java/com/longbridge/nsh/TextBox.java": "6ad8195352f29338e96c144985a4797a8894e3b0fcbf473bf7a1ea297c3ec5a5",
     "crates/nsh-platform-android/java/com/longbridge/nsh/TextLayouts.java": "bc02e66bdb1408a904d3c799f2d228487742cfa7ceb9fe24391c80fb4e368c42",
     "crates/nsh-platform-android/java/com/longbridge/nsh/TouchTracker.java": "29d218455c922e29bf69e58d48cc797637e5030c768dfb846e6cb7969cc99621",
     "crates/nsh-platform-android/java/com/longbridge/nsh/VideoBox.java": "2e7c5fed70f1227353f2f6363dcd6c4fa4d5f277a2739515fe13bba2f675e3f7",
     "crates/nsh-platform-android/java/com/longbridge/nsh/WebBox.java": "5f49a4b3748f8df0180ec45cb3b3c296694ca0ba3fdf2c7d117cb4e021b39965",
     "crates/nsh-platform-android/java/com/longbridge/nsh/WidgetBox.java": "51340bdf442b737f2271e091ec2114e0550b005e8a5eba02ad0947c53c574120",
-    "crates/nsh-platform-android/src/agent.rs": "7ef85fa373ab3d4516afa3b9b6fc221de133edd12d092366d0ec37a372f3e6b5",
+    "crates/nsh-platform-android/src/agent.rs": "c67465629a6d49e13dad3c12bc44550d1d500bcea1222905634df8b39c68b97b",
     "crates/nsh-platform-android/src/animation.rs": "c1fef8de76e942b48c05e108723effc49ed4a57cbe726d9b73037b77e6704a26",
     "crates/nsh-platform-android/src/dev.rs": "fc60e6257001b3e5c27802d4320e138bebf21f66a561928297a68bbc5de0d5da",
-    "crates/nsh-platform-android/src/encode.rs": "1f7b9bb75a90b6c882577af186dd72c41b342f358262f3453100187a07f4b115",
-    "crates/nsh-platform-android/src/host.rs": "15101195191e4d8b60fcb04f0a7ae1b02f488dd368c216908256f89151c60b3f",
+    "crates/nsh-platform-android/src/encode.rs": "053353b68306cc26811df3a0e8d63f607efca8e47e80b943f167619befe37c0c",
+    "crates/nsh-platform-android/src/host.rs": "bc55d6f5bb8cb0c02f620f4ad31e7cc215aaaf7d943a45332a08ff4fe065880d",
     "crates/nsh-platform-android/src/java.rs": "342ea89f068da2261731704ba4cc3628835a1c06f5b8db07e071abcac8d4ef3f",
-    "crates/nsh-platform-android/src/lib.rs": "f7dd6f6e125ff9a6c488fb9322cea08da0fa84a234e7e03a9e251007b874a352",
+    "crates/nsh-platform-android/src/keymap.rs": "7710df8e27d117d3902ab7788a425406cc6b2b2226f3a42abbc60f16239b4f6d",
+    "crates/nsh-platform-android/src/lib.rs": "10282c14a4d56f60291ae8bb6c07593275592ad7ce034b63ec91c96d5d41660c",
     "crates/nsh-platform-android/src/logger.rs": "7fe95c849533e54aa753757090b5747c1131e0523f2f5b24f260fb77bd0aa2f8",
     "crates/nsh-platform-android/src/net.rs": "e51b9e715af5652a6d0697beaa707d021935128c06751010e773c0bda9baaa49",
     "crates/nsh-platform-android/src/package.rs": "f69038eb79ac0e6885cf831fce67572e4b068fb164c21ff64abd40c82ef56fa0",
@@ -604,8 +607,8 @@
     "crates/nsh-platform-android/src/text.rs": "5ca4425a2ab7a7030667c0b012ba3c85b3a126f3f015fafa785c5343cc3b6b8a",
     "crates/nsh-platform-ios/Cargo.toml": "0fe3eabb15eceffb19a896318ce623e6f0752da8b8abb174dfd517162c5dd3c6",
     "crates/nsh-platform-ios/include/module.modulemap": "687c700460603d8f9ca214ff058f91d0d6edc9e3a7350d981015c73872e5cea1",
-    "crates/nsh-platform-ios/include/nsh.h": "b8505a1f2a0cb882be5dc798d475249b7bb2ae2850cf5575137a8c9e1c5d174f",
-    "crates/nsh-platform-ios/src/agent.rs": "adb7c1b931ad01ccdb88dd42aa6ba9f27e407d7dbbecb34181ef4f4e5ee1e45a",
+    "crates/nsh-platform-ios/include/nsh.h": "20924d096a81b7a91ee4ccdafad44d0ce76b13eaa3f17c59046c911a0cd60104",
+    "crates/nsh-platform-ios/src/agent.rs": "a94f761b87e51ce68ebd13fa24477a452f04a6ad9b96005da6851aa52c526be3",
     "crates/nsh-platform-ios/src/animation.rs": "662ffef6013aa336d05648576e149e7736a35c059ab549cc50f26cbfd75f966f",
     "crates/nsh-platform-ios/src/app.rs": "b307e39e915d38961129c41a5a5c3858198a38b2f9e0e28655d2c020658e513d",
     "crates/nsh-platform-ios/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1",
@@ -613,36 +616,37 @@
     "crates/nsh-platform-ios/src/component.rs": "b53803ac0876795ea7b574dab2cfa771c18f30360ebcb2ad2989d716d52e9e66",
     "crates/nsh-platform-ios/src/decor.rs": "bf54501f9a913cd17d03eea457fa3e20a90a765ff7171208bbee11136e5c7249",
     "crates/nsh-platform-ios/src/dev.rs": "6a4d83df5efd1b0690558d817a37d2404459f855c389e7103463e06dc08e46c3",
-    "crates/nsh-platform-ios/src/embed.rs": "3e9aa5fbcc4996305604aeee1a90863303e09405864968506205034fadddc64d",
+    "crates/nsh-platform-ios/src/embed.rs": "6b45045a29a9c5cf78b4754943f618df68b3ef957b0b6faa712f36876930214a",
     "crates/nsh-platform-ios/src/image.rs": "67dab1f77c0809b63ffb798989f51b595b0663d3ef1bd3f6ffa93cc2dc982b34",
-    "crates/nsh-platform-ios/src/input.rs": "5d6f6d43a3d011c8a513f9208590789408f31b0eed2067845d6ccbd7851712b2",
+    "crates/nsh-platform-ios/src/input.rs": "d32418441866e41b8a38df75beba6060acd18acc13ce8da11c3c6a6087a1e38a",
     "crates/nsh-platform-ios/src/interaction.rs": "10b85e580a1c507a884d62c222c3f0b115ff82eac0f38e944954e17f9219d0ec",
-    "crates/nsh-platform-ios/src/lib.rs": "25c0ad2a5615c46eca35d5420d02c3e24d869b092533023569d5599c5a32f6a9",
-    "crates/nsh-platform-ios/src/mount.rs": "8c85c83c95e1e3eb9a29b79ea49820ec37b11955ee4998175d6c293b102eace2",
+    "crates/nsh-platform-ios/src/keymap.rs": "578fb8e85781ffa08f53ff1aaf65baa1ea80a08dc6892f4d6f180d649aaf54d3",
+    "crates/nsh-platform-ios/src/lib.rs": "57025f16e8c3081ad070eaad14458e64bf0059f8c195e4e6653faf0dd8c7f901",
+    "crates/nsh-platform-ios/src/mount.rs": "4eda9f5a722b53e6ac26cfa14b2adee1895081cdf09a6acc26679c0297b5ac19",
     "crates/nsh-platform-ios/src/native_view.rs": "e73867cf00a330e184a6be6830d7b25894db62c1149e85fbe97b316f374831e6",
     "crates/nsh-platform-ios/src/navigation.rs": "f881a30c8db5b5b01d360b1c497547dd1cbaa228ecfee7c55ccccece87d2a3bb",
     "crates/nsh-platform-ios/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760",
     "crates/nsh-platform-ios/src/press.rs": "3a68966f15bb361d8eba789b9a3cce2b4877c7fa9a7e8aca4f9868e2faeb2536",
     "crates/nsh-platform-ios/src/selection_area.rs": "d492cfabf02baaeac68898eaf8a231141f635c5875d8336a401414346edbdfc8",
-    "crates/nsh-platform-ios/src/surface.rs": "554efab1eafd60337541b6fca2278807c182ab525533169717ab33f2fbeb8efa",
-    "crates/nsh-platform-ios/src/synth.rs": "db7aef0e5815132a49d334f2f3bc5324ffe00a3913825672af87936854321c8b",
+    "crates/nsh-platform-ios/src/surface.rs": "6696d2eb70b4202995c5755b41bbca46939f4aaf285a33fd51a9bf58053dece5",
+    "crates/nsh-platform-ios/src/synth.rs": "5ed5e9e78a632820a05ae9114d275283a5b7bccb782d1b1f80031ca013c69a52",
     "crates/nsh-platform-ios/src/system.rs": "3c96018a1a03e54ccad60d4b3cdb3848d4a687864e865558c257ff4a4e4abd2f",
-    "crates/nsh-platform-ios/src/target.rs": "994f0a282f57b553a1c392b191a2853dbd50b453b7155fa4e13d7c771e810545",
+    "crates/nsh-platform-ios/src/target.rs": "6dae6b295faeb46f663b167c0b45c70dcd418c4e2013a9a3058cc6a602fa8c59",
     "crates/nsh-platform-ios/src/text.rs": "142376f7efed62e4d4c2ed8059d7ab0642356ce95d32db4767cb4f756905377c",
     "crates/nsh-platform-ios/src/text_selection.rs": "cd68c073fed1d2baed7121fd72d2e703e7c14e5b9963fbc4f75098f38a00b6b2",
     "crates/nsh-platform-ios/src/text_view.rs": "b7bd9d38bab972227360ba7bee4050d895283e406de2bc5e153f29dcf2465d1b",
     "crates/nsh-platform-ios/src/touch.rs": "cb910d75ca29435063749c0620df765b116f739e19d79889497572a5c9461f42",
     "crates/nsh-platform-ios/src/video.rs": "aad3d01262fc4520b9523eabff0a68fa6373c71433f0331bf6200e69b6907bf1",
-    "crates/nsh-platform-ios/src/view.rs": "042e66b4200c4713809decb8bc0833d15dfffd6b8a2f0f77581732463b2f071a",
+    "crates/nsh-platform-ios/src/view.rs": "1807d29d3965e290e0c2d94751acc04f2e27e8833ebe9978c2c31de19fc607b5",
     "crates/nsh-platform-ios/src/visual.rs": "dc968d6bbfa977b5791ae1f9cf3d859d3aef5567799f02ac3588b3f280744480",
     "crates/nsh-platform-ios/src/web.rs": "2bf4ec4a10212c735585347f72175af0e784b7fe50b7e44037093c48e56c2844",
     "crates/nsh-platform-ios/src/zoom.rs": "274010fd16832c2c93bde502c2c1d6ad308d59e594cdf859d28d87b5ec6ab865",
-    "crates/nsh-platform-ios/swift/NativeShell.swift": "052e146f7bc8c99509f459fd908efa4718ce1e9a0dc5a9c0948786598d162df7",
+    "crates/nsh-platform-ios/swift/NativeShell.swift": "6058b673239941e863b8cc97180d09b4fbd8a4957cd32cedfb1e2d1ae6104d87",
     "crates/nsh-platform-linux/Cargo.toml": "2ea41863aacd26d6bd568126f0c6f089f682555b4adc8fac5e2a7bd49dde007c",
     "crates/nsh-platform-linux/src/access.rs": "9b9aa4d728b4451dfd80e0c3306deca2d7abf1308bb3acc3c2f0957257ed06b0",
     "crates/nsh-platform-linux/src/agent.rs": "433d0a4d9b12c7373a2941d255c5f65103a34639d27b9c60caf9e6799c3c23d8",
     "crates/nsh-platform-linux/src/animation.rs": "7fc1b4f7ec5b1f2010216bfbe8f4a773b7ac08549b326d439e266f0188be514b",
-    "crates/nsh-platform-linux/src/app.rs": "bdc83b899efa9d5aed4ede07303e7ef82803b0ce7b4648f4fa6e0c5edc2b3e52",
+    "crates/nsh-platform-linux/src/app.rs": "3a1acad9886b53f25f263cd39cf29d9c2ad12c02f254b12aae0088b25fb891a6",
     "crates/nsh-platform-linux/src/canvas.rs": "555850bb5a9cb0b9f09438ce360f460fb9f2144c4701001ba0bfd7dd91726dfe",
     "crates/nsh-platform-linux/src/chrome.rs": "b0b1ba6b3e5280a5ede9afe7cc629738c1f99b3f57af3a5864c41ca5038e309e",
     "crates/nsh-platform-linux/src/decor.rs": "9250081c55b8248278ccdcc8971184d6bfb820f2e6893d407ab76e84c21f37c1",
@@ -658,7 +662,7 @@
     "crates/nsh-platform-linux/src/navigation.rs": "77be5eef778948105eeea4d91306fbd70f2afede4d43d5432bff377db6b49d93",
     "crates/nsh-platform-linux/src/scroll.rs": "fb7b5cf5843a20d67f1a0550bbb53a1ac807684660ae691367d9bcbef2894310",
     "crates/nsh-platform-linux/src/selection_area.rs": "f38e30a766c3d7f52954c6b0c8aca3a52dbdee94c90c4ad6f21b0b6aff66ea4d",
-    "crates/nsh-platform-linux/src/surface.rs": "3182b74b110d3a684fad43616c7542bc61e6bd0017a2932fe974f101045833cd",
+    "crates/nsh-platform-linux/src/surface.rs": "f2087a9d1df07a1f5a94a49037fa0acd3409a873c334b66a858cff67161a617c",
     "crates/nsh-platform-linux/src/synth.rs": "ca932e93904d5b3b734a874be5337c2f6eeeea72c35e4c5bf57c04d864c30df2",
     "crates/nsh-platform-linux/src/system.rs": "fea65629cfbcf37eb050fdf9bb9f736e092af0375bfa8dd4f6ea425390f378be",
     "crates/nsh-platform-linux/src/text.rs": "87d1b461b411da98a59e120e13290cc41d665bbd9c7eeb65d62db58c81fcc6ff",
@@ -669,7 +673,7 @@
     "crates/nsh-platform-macos/src/access.rs": "5bafcc7cb9aa39c5d931f5c0fa5fef547b3d32ee6bcb1b38212cd7f1915c0b25",
     "crates/nsh-platform-macos/src/agent.rs": "db50231031040a91216b29a9b41b6a936016d6ff2d1c8cac2399dc08d8f9b6e7",
     "crates/nsh-platform-macos/src/animation.rs": "2458ba49973e39a739ad031482ef4c158a29e80c69413cf06bc615add190ad29",
-    "crates/nsh-platform-macos/src/app.rs": "c1562689dc7c467a0e763a39b874babfebd92b0201b6709ce7c3dadc08af60c3",
+    "crates/nsh-platform-macos/src/app.rs": "f4866d3554882d2e91c78725a8da7191d9a1f52e743c975f97f713862bcb6580",
     "crates/nsh-platform-macos/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1",
     "crates/nsh-platform-macos/src/canvas.rs": "6134e252a2751484fb7a6db63f9c5ed95364126f38f988fc016a846de3a54963",
     "crates/nsh-platform-macos/src/chrome.rs": "83129a8a83998c110487f496c04ad9c5e8363c6d6ea6f64666789ac13d1fbb6c",
@@ -688,7 +692,7 @@
     "crates/nsh-platform-macos/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760",
     "crates/nsh-platform-macos/src/scroll.rs": "824c167ffa1e9b6cfee91ab7aa1504b557da5c71003b18f736ab96f0872e1bdc",
     "crates/nsh-platform-macos/src/selection_area.rs": "0425f31d882802bdee2f7bc2275dfcffa8274f194c2fe2a5a096efc8b91df4e6",
-    "crates/nsh-platform-macos/src/surface.rs": "3068eae4156da32035457523a212c0f03a40096043d6972ca6bcd97fd79fbbca",
+    "crates/nsh-platform-macos/src/surface.rs": "52cb73ba0201bcb9fef631535a07144b9ca32f87bad03265070732404e75d054",
     "crates/nsh-platform-macos/src/synth.rs": "c6510dcd66cb600e382e74d2d86352196188303bd86a85f12afb21213cf51776",
     "crates/nsh-platform-macos/src/system.rs": "1e7be08090cecf5741c52799b7770a0b0dc82ffe78a752abee8ff98f2321577f",
     "crates/nsh-platform-macos/src/text.rs": "ff5de5d8db0099f156a8dd643f7d85a804325f78bb9d3b7ea965e36c97fbaabf",
@@ -727,10 +731,10 @@
     "crates/nsh-platform-web/build.rs": "6e60cd4d9737c2766e2631ff775410cc0aca1fc53d8463356ccce49f3a11f803",
     "crates/nsh-platform-web/src/animation.rs": "b19491130e0b1009a483abccfd0a335684973072137c8e14d500827f7acf65b0",
     "crates/nsh-platform-web/src/canvas.rs": "9fc4c10800eaa2b69ae34903fbc8aa0a3e4a65e53b8151d3390ee883bd8cb4b9",
-    "crates/nsh-platform-web/src/encode.rs": "b776129aee4d45596a5d6c1a6c2631a055e6aceb2a8161a96e96dd870a3c8a30",
+    "crates/nsh-platform-web/src/encode.rs": "ee16f1f491eb67549e5f0479fffdf762e1f4e1d16fb556e66237c00a169e4a07",
     "crates/nsh-platform-web/src/ffi.rs": "1ef25ae33cc562695438e574d7fead4a8b9d1536ce9ed78e8619d73c4c616d23",
     "crates/nsh-platform-web/src/filesystem.rs": "34cb25848b20fe11967437368e5f5feda3f1297982232eb54aa27ab2611cfccb",
-    "crates/nsh-platform-web/src/lib.rs": "4470f61a9480768ceea2654edc371997e558cdd6ca2001e1696deca8480dc778",
+    "crates/nsh-platform-web/src/lib.rs": "3479021a47c4c54b29e79bb20cf94062b9d4acf6aac2155895cf62d474c0246f",
     "crates/nsh-platform-web/src/local_time.rs": "63f2781b1a5364c3914bbefb1bf0538d77876a1324a2e7a9548231711b8241f4",
     "crates/nsh-platform-web/src/net.rs": "6ad23669ffb7f920da1d4c0acd9cdf6bf6a1b8f33317dfd27eb40ce0b133fe9e",
     "crates/nsh-platform-web/src/storage.rs": "fdff261938b0d75b6b1ff633b4788cc386de23aede2b563fb226508042a3c27a",
@@ -740,7 +744,8 @@
     "crates/nsh-platform-web/web/local-time.test.mjs": "e8b62dde3208d979eeb281bad19fcb7c19566732500e969ab2ae5c4cf373c9b2",
     "crates/nsh-platform-web/web/mouse-browser.test.mjs": "8f3e5d180558a4d4553d01be86dd080ae80638734c83095d90b5507e2ee65420",
     "crates/nsh-platform-web/web/mouse.test.mjs": "b6db2b6bb88c0e5b13bab082027c0d84162156350e1c86bde91cbf1f98f63644",
-    "crates/nsh-platform-web/web/nsh.js": "55bfc70c093b86520a668cb46db9082265fa534b2965363f7c782787f2157cf0",
+    "crates/nsh-platform-web/web/nsh.js": "2ef82397994e5a5ee1fae4e08ed8c8c0e964f014411335a5a324e2608ed97adc",
+    "crates/nsh-platform-web/web/nsh.test.mjs": "f67b256bcb312be5ac46a7e0c11e07ee52fdb4e467f41551db460def709062f0",
     "crates/nsh-platform-web/web/scroll-browser.test.mjs": "3f8150ebd2b69d7ab306307868b938b99435308ff37c2c999cf297a0f94fd30d",
     "crates/nsh-platform-web/web/scroll.test.mjs": "4ea1e845ddd2e480a284caef06f2fcb4dd26f85d0d0808ac8216977cfaff0b30",
     "crates/nsh-platform-windows/Cargo.toml": "ca891951f7e5d38889c956e0f00aacffa11da045ed4bb849065c781950c75d9f",
@@ -748,7 +753,7 @@
     "crates/nsh-platform-windows/src/access/uia.rs": "9b37dace78dd3ec658728ff71c73b61d79da7e7852dbaa573a8588d02769ea9c",
     "crates/nsh-platform-windows/src/agent.rs": "a4094f8b42c1096461dd13e2b6145fd497f2dfbf7b171ad35cda3d349ce45e99",
     "crates/nsh-platform-windows/src/animation.rs": "5e849ee6e81ef77bfcd70e255228431f3ea2b67266530a10d962582541b78c29",
-    "crates/nsh-platform-windows/src/app.rs": "5f98ffac0f08e7dfc58d21f90cf6d4f7e5f4a346b4456cfe47b76d99c6899ff6",
+    "crates/nsh-platform-windows/src/app.rs": "29fe32d6642335242fee5afda1859f905947a55c4af8cb979e350df1af877072",
     "crates/nsh-platform-windows/src/canvas.rs": "ada51da124bade5308b0dbdc3301b5001c0ae0963ec47164800b316174c21f17",
     "crates/nsh-platform-windows/src/chrome.rs": "1d7cddfb2cbcb9e6fd0c02e4ff72ee468caf89871142c3a6a8ffb5207e9717e0",
     "crates/nsh-platform-windows/src/compose.rs": "fb996e3c8fa97934361aa72c27f599255f242fc98c79ee1c6226b4ee94c314e9",
@@ -772,7 +777,7 @@
     "crates/nsh-platform-windows/src/render.rs": "50cc1fd242bd15fc495a77ca2383251ce5ed712688a552de908e3fc268d3d9f8",
     "crates/nsh-platform-windows/src/scroll.rs": "28279bf52c6a8e36f173e9e3501470f1c7a17d5b7899390905a08f66202da461",
     "crates/nsh-platform-windows/src/selection_area.rs": "79cc6116c2cfd1d33dd5b5e6bdc15222c3460aeb6eddf288fccc09489417b3fe",
-    "crates/nsh-platform-windows/src/surface.rs": "680f0210bcfa893a1f4c182a097b4a96dd18d4095c5ad67f27ba0ff539fdae63",
+    "crates/nsh-platform-windows/src/surface.rs": "2477b05ae6b5711c8614c240251f04b7d5b7799aa012267341026714b7107808",
     "crates/nsh-platform-windows/src/synth.rs": "27fc28c8d4d0b54dec35abfaeaa283f39bc3fc33eeb9d5b1cd9c280a6511966a",
     "crates/nsh-platform-windows/src/system.rs": "0466f5714b717e08d7fbaec35cc7109be0bac8d30d2a997db13420c7eb23436b",
     "crates/nsh-platform-windows/src/text.rs": "da82b3aba6af2b92c2615dbd27bc40ac7cdda0009027a7190faa349a8d2070e7",
@@ -780,24 +785,25 @@
     "crates/nsh-platform-windows/src/transition.rs": "dac21868ca439f92c4ecb41197fec60f4b8cf54e0364213dbcf4b8ae9e01cc24",
     "crates/nsh-platform-windows/src/view.rs": "064cccd47c5b13724fafe4082dc86954b676b746186c01100e466ad764294ea1",
     "crates/nsh-platform-windows/src/window.rs": "84b85a4d05b9194198b647a7244f4dbeec4323df9a85ff6475f73c3ffec46ce3",
-    "crates/nsh-testing/Cargo.toml": "a32d7aa17b13e244f7e9c2c6c8a8859cbdec3e878c1ddf1655fdd4b5b7113c0e",
-    "crates/nsh-testing/src/agent.rs": "dadd07658ee16f5d6e57f89629ad14233e380ca4b614645d3816df7c6755aee3",
+    "crates/nsh-testing/Cargo.toml": "8cab49bdd6a4fce1584e25482263ccb82327a95fde6a2b83b32d9f9447fd974a",
+    "crates/nsh-testing/src/agent.rs": "be0bc6365aa19cbe444fbd8522675d99d6e907ef12ec71a5de86c8228fe86961",
     "crates/nsh-testing/src/agent/forward.rs": "6e62c09570ba63b910caa280eba059fd2ef87f056bca59d2e5413b4c313ad4b2",
     "crates/nsh-testing/src/driver.rs": "9a97ff5d9f7796965e7659298c2b885f214e826ff218c946d64f25f3d7712895",
     "crates/nsh-testing/src/headless/animation.rs": "a38102dc120bd208331b64d94c28cfb2b1aba52845bf31d7f387fa552a6ad18a",
-    "crates/nsh-testing/src/headless/keyboard.rs": "986176b5894e26d549dc9d6a1db119e06a4c7b224ff32e4e13dcb77f73e4c44e",
+    "crates/nsh-testing/src/headless/keyboard.rs": "57f9729e15ab46479abbac9926d81f3c751cacd3be3e2b6136009a11b6dfb34d",
     "crates/nsh-testing/src/headless/live.rs": "97e9ebda01076dbbc525618303316f379397af8f07a4e0fcc3372a89096f6597",
     "crates/nsh-testing/src/headless/menus.rs": "79276a0066e4505bcf3e5043f2bb800415a84e537415633f79815235b122461f",
-    "crates/nsh-testing/src/headless/mod.rs": "fc6ed20e9dd3f5ebb55d03ea8129f43d213934c6ba3adf307308c348fa12bdf8",
+    "crates/nsh-testing/src/headless/mod.rs": "7d91786f0375f981748d58887bd81c1b41ea24f835a7ed16cd6986afdfc17df1",
     "crates/nsh-testing/src/headless/net.rs": "05b51b76f89a71f11b085001950812cd581bc9f13f9111a867b6cb3dc608f3c4",
     "crates/nsh-testing/src/headless/net_fetch_tests.rs": "798c72ea94333a2ce720d313a95b7e5612cf73c1830c9c8aed4ea5550e52f6e9",
     "crates/nsh-testing/src/headless/pointer.rs": "b535b3b01c7d7803b8c92aca81258db65caedeff7d13b59d28f8874def6f6087",
     "crates/nsh-testing/src/headless/stub.rs": "4f6a52cea583a94614c5552ec02b188d40878e69e65cd5b04ff8904a9f73ff3b",
-    "crates/nsh-testing/src/lib.rs": "ac12cd696637cae87b62c08396b334a01b565ae9d0698383ebb2623e1f1eb160",
+    "crates/nsh-testing/src/lib.rs": "f2ba1344b98370030cd612d5c03c7a8cdfed8c5d346fd1f9005eb091e66cc837",
     "crates/nsh-testing/src/menu.rs": "4bc761c4ef832b71e25630b3232d0b0a5367394b6632b2745edabb807248486e",
     "crates/nsh-testing/src/module.rs": "209147852295109003570770a3383fd938a10175849fb8e2be80823c6a660394",
-    "crates/nsh-testing/src/run.rs": "69b39dc5e84d927b39269e73b921c7c497b88db2bd073f7475c7ad578e5794c5",
-    "crates/nsh-testing/src/runner.rs": "9ac5462e7834dacf12916681b65e2edb7b482f350dba658d8ee338594b220447",
+    "crates/nsh-testing/src/page_host.rs": "6c8c8d516a491dc41568cde09fda8bc7f5974bcbce3350b4d907260eaf3eb2e8",
+    "crates/nsh-testing/src/run.rs": "0b1c17a4059417d52ca40aa080311db5df08c95bca4f95cd0fd05abfe77dd730",
+    "crates/nsh-testing/src/runner.rs": "320e1368ddaf6e60da66ff7faef74f753bcdc5953d4cbff0dc07170dbb7a2534",
     "crates/nsh-testing/src/screen.rs": "04cd1b008006e1b5aae501c31ac139443bdbff1b901b21038033fa746fb20895",
     "crates/nsh-testing/src/selector.rs": "9567a7d5ecba869684cbe4a9b4d7441c074dab2e6d564ca6463302da0dd40599",
     "crates/nsh-testing/src/storage.rs": "72eb5c29dec32f9a8d9afaa3b8d4053a8fc8cc76b0f2bf31f1f76cfd5ad4020e",
@@ -809,7 +815,14 @@
     "crates/nsh-testing/tests/testing/clock/App.tsx": "88a13af9015a64565d9d5a2b208e29a3f92bf5ad42c26f30fb6b52fa588b7e8d",
     "crates/nsh-testing/tests/testing/environment/App.test.tsx": "187831fa2adc650723de6e17ed53e1bd88f80169e91daa946c2b0277c3714077",
     "crates/nsh-testing/tests/testing/environment/App.tsx": "84583720c58d63385b056e0f2145d35b22b6facc1d5eeb6a49ddff86d83cbefb",
-    "crates/nsh-testing/tests/testing/main.rs": "9be3af39d046d30e1ca4c5ec969292480178bb1f033037bd605ab7e604941c8b",
+    "crates/nsh-testing/tests/testing/host/App.tsx": "4e0cbba5c962cd49fc8d4133296be0dbcc25eead3401fa2ec25b93e0409a0c7f",
+    "crates/nsh-testing/tests/testing/host/Broken.test.tsx": "38fc46c28004adf81e13f73e8e47322ba73c0b8b20fae40b4e221210a3037c7b",
+    "crates/nsh-testing/tests/testing/host/Host.test.tsx": "af3b65cc75680b47ea1c1cb73f53a34842c0bbb4ac693042652a5aa8f5206cb7",
+    "crates/nsh-testing/tests/testing/host/NoHost.test.tsx": "57da9b0c7f7dede954ad2def033eb98936f3133ad436d7ae49b695b841793990",
+    "crates/nsh-testing/tests/testing/host/host.js": "8d127bcc8a8bc959cb23c48a53fedc83f106dbfd0fd575b8812a1095bffeb1b7",
+    "crates/nsh-testing/tests/testing/host/none.js": "56c2d2a72379cc68ea04dbf5acb31d79a2c7673d3c272b2fb6a4ad6965a74ad6",
+    "crates/nsh-testing/tests/testing/host/nsh.json": "76e6426bcd425eb7673cb1e68d59e91acbb2f055e8c13ffaf9b05e21d727589c",
+    "crates/nsh-testing/tests/testing/main.rs": "31dffcf89c9c0efdff611143476e2ea6ff31fdec888e94d90a65f143ab6a9576",
     "crates/nsh-testing/tests/testing/platform/App.test.tsx": "0126c606f69d8fd7b880b0a4c046f0da6187db574ab492a21715fedcc65d8277",
     "crates/nsh-testing/tests/testing/platform/App.tsx": "d0a26d35006662ae6f6410493a647defeb5a01dbed7f7fdf78ed9b573b72b956",
     "crates/nsh-testing/tests/testing/queries/App.test.tsx": "cfc603ff619f179002576cd89994cd09c3f07cec4782ebf33538aef55c687449",
@@ -849,31 +862,31 @@
     "docs/dev/compatibility.md": "26336d6349cbedd0a70990dd5a4d2e7e48c604242974ac164e68bbeb4ad1cddd",
     "docs/dev/components.md": "9f4a41c405a016275002fc9d850a222dcf941eb47f96201a1a384ba3f8a34914",
     "docs/dev/dock.md": "51527992aeec0387fe7ebdb82b7d24ed4ab6f0dc19b3332e23236cb7a3ad1041",
-    "docs/dev/elements.md": "94da584a4b2a7dfbd54c83799329ed05220ce8f2c86107df1e556148dd2b790f",
-    "docs/dev/embedding.md": "db9490c56c428005722ff46a5c1a2e632b6f6dfd4a1283b99b766629cca58c5f",
+    "docs/dev/elements.md": "7b5f776fa9895a1a6aba0a275a8ac76a43b5660ae0468032b61e833922c9781b",
+    "docs/dev/embedding.md": "d072df7567fb6b178220b5d15926fd94e10a2b421115b4777ec9f61ac31157bd",
     "docs/dev/file-drop.md": "ee9e7e263e32789d03893108e68b47d2447a0fa19b29be6b348f269884cb81b0",
     "docs/dev/geometry.md": "cc747bc4cea9d983b1ac3d634be58a8eac27254ecb912c2940173f168de8f9a9",
     "docs/dev/gpui.md": "15cb649e427c84858f177b0d85ae0540dcd4ae23a3b8b3df414a9f1dab6b0284",
     "docs/dev/images.md": "9e797fd623bf317a5e96fdc7d04ac1bf85f2fc82d267a2d220e3e39c9a6f187a",
-    "docs/dev/js-modules.md": "991718901c2e0ee19c3776a7f806d30717c0a1842a5f4c5f1bfb18343795ebb8",
+    "docs/dev/js-modules.md": "209d69c37352648f365c24e062af5016248ac499eab4c3d7b673b48f302cb215",
     "docs/dev/layerfs.md": "df9b16b2f5f1e61f9693f63b61438a51461d05b535a0046d50ac9ba1ade12b8b",
     "docs/dev/linux.md": "4441ec64b6edc5c03084bd203aa5114c066f9e2c0541ee320be8f17b6f82c3fe",
-    "docs/dev/macos.md": "827c809b5774681c77daf05ac6603342c027dfd459e78f55558044861711dcd3",
+    "docs/dev/macos.md": "c2b332f4a6e72b60520ebf34d8875a6776662ddc9d1ffa62963744a62961c988",
     "docs/dev/native-pipeline.md": "07f803365af6ac90d5247c70d2e85ce9e03393f0e9e122c46690f85e381a0c55",
-    "docs/dev/performance.md": "4f23f433adab44865db61546c7b6e4515ea0a292ec3f625fe86881edf5e6997b",
-    "docs/dev/pitfalls.md": "80fc820be5136534546eee9790dbc3b824cd1053fe45432f0187f4d25641bdb3",
+    "docs/dev/performance.md": "2d37115997936be2c23bd57dcb9fe9411b171ce87ef0d553348c9f87baa9f8e5",
+    "docs/dev/pitfalls.md": "ec02fe363e800a5f338afed88e16a83d21971549fe47c4da553d4e865cb84d21",
     "docs/dev/reactive-components.md": "cd447fe8cfbe9026d7cadd5fa42d9c387e4c1b1b7e1c5adddd55c3f1beb94a4d",
     "docs/dev/rust-classes.md": "1f70015213dbcf56e6d6e4641019092003c22eb57ca324d9c4a85578b4468139",
     "docs/dev/rust-modules.md": "f793bfb12272ded93e80876fd2c72ff8dc0f1bd0337977f39b750af495a83592",
-    "docs/dev/sandbox.md": "4dd889e136fc64a6024a60e07372868e9b25753128a0cf3925966f083daf7f27",
-    "docs/dev/scrolling-and-lists.md": "158f7fc20773ad96683db9f6c6b2588f97274e7c9ed4e2958dc925b1ecf0e750",
+    "docs/dev/sandbox.md": "86768ef1e0d3aeb6468fa075eb9a3a00adbe4ac4f576586d82fc495ca398fe5b",
+    "docs/dev/scrolling-and-lists.md": "448cdbc996047d274b683315632f90463a1fd973c2c59c64f29e07e0f97cee3f",
     "docs/dev/signed-packages.md": "6e762f506428d53d347106e3619921a5595391105128305090da8eb8a20c220e",
     "docs/dev/styles.md": "fcb5410f36c3d0ec08c300a7d624b19ad9d62404f665e9f4a2c61dda001ae532",
     "docs/dev/text-view.md": "ef89f0a8af990356b33b5bfdd0f79e9d86342024e04ad8e08aba9e9ab13c6693",
     "docs/dev/text.md": "7a4f09386899d5e6a5c3dd7c886063db73fcb5b288b47d0ff37cbfc8b4911dfd",
-    "docs/dev/tooling.md": "1e605160ca6bb185136e1ef28f1cb703c4db5ac24647570c2c7b5a7c80f65fee",
+    "docs/dev/tooling.md": "0ae4e35b8ebc1e61db51a9fb44b25733eb36e7d11282a0cc26b418b6efa08a0c",
     "docs/dev/tui.md": "47b56095f90ba9caa3efc65acc10bb3e255b674d9cf05d71fd5a30f7a43b1770",
-    "docs/dev/web.md": "7c6f900d89b7f46f840cbaf271664f5979130ec9175b5695eed8afc30cccbbf4",
+    "docs/dev/web.md": "fd9039c76d0f8259cfdcb894add27d2cbcd04eda67d06c57d614b9f0ce6c1ade",
     "docs/dev/windows.md": "099245b28187723d0ce93f9b6f0b4775bb19f25c625e8b3db558fe175dd0bb6e",
     "examples/bench/GridTs.tsx": "e1388190424c85d044232f9dfdc897acc69fc4464b23b57db149f9276336e3b4",
     "examples/bench/README.md": "75d32dc5f73955e42cd171baaeb7df39b19cbdd25e67e661df7976a887ca3506",
@@ -887,8 +900,8 @@
     "examples/counter/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "examples/desktop/App.tsx": "069c5a14642c56403f8786b8b96187d226f08e4078f37a7551f253433c65b49d",
     "examples/desktop/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
-    "examples/embed-android/README.md": "2694fb610d63a1e2bd816533240fb0bac3c66d203a0be7e076d06098c75f2573",
-    "examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": "482e4f1f734450f2a4acb9dfacee0995ef69947e70ff25e7fa2ce93c6cb2c782",
+    "examples/embed-android/README.md": "d74d883aa1e65a564c01e897a0781adc8279aa5c6e13f5e247b117bcf2f223ab",
+    "examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": "ce94b18d9bb436c129cc38ee579c8823b1bbaa216bc7e11a5ff6d344e732dcbf",
     "examples/embed-gpui/.cargo/config.toml": "b6f90679563664c70622f32e0736e8e40b211948180594f372b175d90b9b1a1b",
     "examples/embed-gpui/Cargo.lock": "16bedb298e480edba2cf2548b07cd3583c7471c3ef7aac852c86da70da7b2886",
     "examples/embed-gpui/Cargo.toml": "a1f4727ec5fbc760f3678bcabfb14e3d34d37d71fb0b243e40920e55b8ed1f21",
@@ -907,9 +920,9 @@
     "examples/embed-gpui/src/trading.rs": "d74b621e04b6a04a622033c319574c8eca80051bb7bb2619a872377c42ebdefd",
     "examples/embed-gpui/src/watchlist.rs": "7d46e105a6f00e31a3d225e5b1430616f0360cac5b7ac8a63add0ebdb6397ce2",
     "examples/embed-gpui/themes/violet.json": "7da650a48cebde43683ef6f50bbb27a5000dd79911763e46982688160a59e2ab",
-    "examples/embed-ios/README.md": "a5f2a52371ad660b6c5e24333f7282475e4b544a564770035f10efbcb1a6937d",
-    "examples/embed-ios/main.swift": "6c2bb633521fc06e45351115ff8b34d5b182106e7171e5ab4c8d376585962020",
-    "examples/embedded/App.tsx": "11c06c478613046c7ebb3b936366d08abd66e4f82e3b45037ea1648d01cacb27",
+    "examples/embed-ios/README.md": "99c5f76f3c3a2acb2008796fb71ca864ac853a293583005acb82ca155d5da527",
+    "examples/embed-ios/main.swift": "fbb648c731f509cdb9ce20a70c91090291863a8f643482ab8727a32324a0e3f7",
+    "examples/embedded/App.tsx": "416fbcb5da5df7d68448da5e3a620efec9afa8f730efc432345e9fe7ebdbf26b",
     "examples/embedded/nsh.json": "3ee671a3404ac47b07142fc0803df84417f265279c139a0616c37f772580c8ce",
     "examples/embedded/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "examples/gallery-rs/App.test.tsx": "7aaa088af8ed0749b5dda6bb90e7c8746db0735d0706b7475ff0b38e1d8bc27e",
@@ -1055,12 +1068,14 @@
     "examples/gallery/tests/error-boundary.test.tsx": "f71d9c316fcbea37f15a7b3be31b7e82aacb09c6b550553f3636dccd9534e94d",
     "examples/gallery/tests/fixtures/error-boundary.tsx": "94516c85cc83f4824d71bcfac24ba972d5dc3bf6ea39060752a5d6e097ecca7a",
     "examples/gallery/tests/fixtures/list-search.tsx": "ead5f110c6bf3885ea230b10c2a80fb0891d26c69ba46e33a437c6e89c381143",
+    "examples/gallery/tests/fixtures/mention.tsx": "329706d1ac88a20f6f60fd82bc4a8ba97dd0092838110355463588036c7420af",
     "examples/gallery/tests/fixtures/press.tsx": "a91869ba8b693abaa3ca130a46bea568a4c251f20e4cfa7db6965a45a456181e",
     "examples/gallery/tests/form.test.tsx": "8a82127b536f73bf3fbcf23881cf802b30d0346314d2783bcf9da52d45e3a49b",
     "examples/gallery/tests/gestures.test.tsx": "adf7fcf2afe5029f2441eaf048251a1d33530467df91747f170968fbdc6f3a08",
     "examples/gallery/tests/hover.test.tsx": "dae609cc2f03243b33ef3d46f58b1dadef73645b1223f1796c3f246606baa5bb",
     "examples/gallery/tests/links.test.tsx": "db1b97338bc81b437228d2015926f55a469fb1ffef35cdd57961977952003963",
     "examples/gallery/tests/list-search.test.tsx": "80eaf7e15d7f285662f1c29c662ac075722f04a0cb44220f8a1a5c55033f1ac6",
+    "examples/gallery/tests/mention.test.tsx": "ca76505adb487f51a89b7272bcd8afa4a750807df1a9c10d8c006f780446476f",
     "examples/gallery/tests/menus.test.tsx": "2db06b0b97d2a3dfcaae959fcec37452980158e93a97a94ceda2e318a2f39313",
     "examples/gallery/tests/navigation.test.tsx": "76fcd06532791d18abb88afef2c951ab950adf148f10ecd1e8fe32cc3a718569",
     "examples/gallery/tests/network.test.tsx": "aaab1989a62f65e9c92c69ef8fe56fdba65677db382a00796b37b21199bd7d15",
@@ -1090,6 +1105,11 @@
     "examples/hopper/theme.ts": "635f518b81e7408e5969ca59dbd25860381857f37e9980d225a1a54ba373a38b",
     "examples/hopper/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "examples/hopper/ui.tsx": "d896dfce94d4c96585275f3b47e2e418e2867ef8aeec5c6148adf3401155f8a0",
+    "examples/host-echo/App.test.tsx": "ccd51aa627dc36087d36614b02c97dd1f08513146261b2b282a7997d1594d8f0",
+    "examples/host-echo/App.tsx": "b71e5c39233a5bd6891ced4f713af67e414607daa119bd3e28bff38fc0597a54",
+    "examples/host-echo/host.js": "da4dfc0d7be297fdb12ad686bcd251d9da4036ecd2aa5b2058d685f4b7ebfa9b",
+    "examples/host-echo/nsh.json": "c0059e3ecaeb57be9f29502f727184234d7166e1e0e1f9fb7c22a80f97000430",
+    "examples/host-echo/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "examples/rust-exports/App.test.tsx": "98df2fa881eb3b6d1c383c0b6f77d6753d178e93df556111258dbdfc66db7cec",
     "examples/rust-exports/App.tsx": "3c64df906f96cc4495f481c133713d33e9dd95c493f449a0e7aa7342a34b315b",
     "examples/rust-exports/README.md": "6e6e2798773876a6c586ce914d31680f2a2890c0d65c3067c794dd946d86c2c8",
@@ -1160,7 +1180,7 @@
     "tests/compat/cases/core/core-error-boundary.tsx": "c2de89bed501a5483446830f8ec19e0a1fe7131fe0af8c0fbf8dcb6b9942979a",
     "tests/compat/cases/core/core-input-action.tsx": "9e6c67b4c7cad5580219045666bd1f5d91ea377c3a371717adb9d25950194a59",
     "tests/compat/cases/core/core-list-binding.tsx": "b9e52b8e5f875188a98b09226d410410318846c3df9f97b1852365e063fe7dbd",
-    "tests/compat/cases/core/core-list.tsx": "1851cf6e7c7b0bdc7794eb2fa12b316b8e0d96fea3dd1e99398e54d1c013ec9e",
+    "tests/compat/cases/core/core-list.tsx": "0abc03a9be7e19288cba790203ccd7d0fe8e73969cb208a30124a1f6b92f0bfe",
     "tests/compat/cases/core/core-overlay.tsx": "96a006df0796850f1b9dfa68cc4fe5ed6868988635ec7f47713523c47fa303ea",
     "tests/compat/cases/core/core-primitives.tsx": "2dc25a3b00f0f6c3450128d85b52c6354e42618f0ce6599cd7b3196bea81df12",
     "tests/compat/cases/core/core-reactive.tsx": "bcde98c407709730db5a6d2a620f01f322804d9184f0f727626034660ad2f5d3",
@@ -1168,7 +1188,7 @@
     "tests/compat/cases/core/core-resource.tsx": "cc3d33c1321a04615b7fef7ac0d759fa5d76e7a3f5aae671c5ebff050a2769b5",
     "tests/compat/cases/core/module-url.tsx": "8bf4a7a606496d4d5f321a8f722892de01e845d56bec5f42d9767ab3c516bb1d",
     "tests/compat/cases/core/nsh.json": "c4354909c4780abb723666fe31c2c82d50cbe3839f62c169dc0ec2f145d8a728",
-    "tests/compat/cases/core/script.txt": "46009ab58c932240d2aa0997b14d47eac6a9d01c92cbe845b73df3abcd7d0bd2",
+    "tests/compat/cases/core/script.txt": "5820c9c5590a75d1ac7b87de7020f36616dfc2c6836b018285724e23257df97a",
     "tests/compat/cases/core/steps.tsx": "bcfbdf96ffeb91d9d4cc5abd0a562cf612fe73c04ddf9d8bfdeb5a7d14aacc37",
     "tests/compat/cases/core/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "tests/compat/cases/events/App.tsx": "cec0a2e9ebfbf3c0c0b1d6e05760023d66bad7b30ec1e27b0213ad6752eeee38",
@@ -1202,18 +1222,19 @@
     "tests/compat/cases/macos/mod-standard-system.tsx": "17363588e50524931b2a21b06b7319c39864ef77c35d65cd3708524c185337e5",
     "tests/compat/cases/macos/nsh.json": "81592a22d477fd7aa971a58119b710cd6b49d58517f6dac65e2f091567c4c020",
     "tests/compat/cases/macos/script.txt": "adfe51bb062be578136db927b767640d286ee3b4e8847f3446b0cb47a5847514",
-    "tests/compat/cases/modules/App.tsx": "a96f5194e1ccd1fbfe3c40d03ab18d4edc78465ffd15c00e768bf041372d40da",
+    "tests/compat/cases/modules/App.tsx": "48cdf7cfd2b54f36c5b3b920879411583c898e4656c2c96443b962cbc899503a",
     "tests/compat/cases/modules/mod-accessibility.tsx": "139148dc45f9402dee9aaaf78587690fa4ede4a209ec39c23c2e3eb379ae1e6b",
     "tests/compat/cases/modules/mod-android.tsx": "0bc2aca9677c91fa0d1e3693110581c5f76fe32ea2e0eb3f8fe5d4470395c8f9",
     "tests/compat/cases/modules/mod-clipboard.tsx": "0ca63a3ad7e0b77672e688fb3e5ba3d1fe79904fbd5640646074c1ca22d4951c",
     "tests/compat/cases/modules/mod-desktop.tsx": "23c2fa95911c0ac2be36fd1fddf78c1c7aacfcf7e79e66f0df89b416e8cc3909",
     "tests/compat/cases/modules/mod-device.tsx": "6b384f6830bb0da03344986ec02a1e394d307a5b5ef13dc1fd53ba272d04de34",
+    "tests/compat/cases/modules/mod-host.tsx": "6fb70e966bb0208e1fb9c2dc847146ad720227a3fb421e4b5695a2d6c525037c",
     "tests/compat/cases/modules/mod-ios.tsx": "15398c7e334c6067bce708ef6303d534380b1b2d3cf9ca90c7fdd6423fa85b3f",
     "tests/compat/cases/modules/mod-keymap.tsx": "d3d40c3ec2a9d72a42a33335255b93703e1b87f6c94a693837ab79b69ff16ae1",
     "tests/compat/cases/modules/mod-standard-access.tsx": "c6f8c9d94ac06655950666e356af86d219379f95b4d6978c9b81a07077f5fd5d",
     "tests/compat/cases/modules/mod-storage.tsx": "cfa8ffaf50301e957f0da33a075c6f4a8be93e758555d720dab07f7ae473410d",
     "tests/compat/cases/modules/nsh.json": "8aded49d92a6f2cb8db2e005d097aa70f60f26e5e6639b98f45cd89154f3feb2",
-    "tests/compat/cases/modules/script.txt": "25dcfb68b52245396ca162aa57b002b0ce100c6c10c5fd48b472de9f9b9bb8aa",
+    "tests/compat/cases/modules/script.txt": "328850d9e625f328bd10cf96100058a569bf447be7fbb6de2dffc26e70760c12",
     "tests/compat/cases/paint/App.tsx": "c8bcbb4b62d5ecabe0fc98c7e6524e451193952701215ebe614a92168107c316",
     "tests/compat/cases/paint/nsh.json": "56f891ce05e7aefa7949e3445a603cb477313be037897c75acb12f136c1a85a8",
     "tests/compat/cases/paint/paint-borders.tsx": "388fb9d77dced948c4a014ce42af65f3509153347ad574778983b0263277a428",
@@ -1383,11 +1404,11 @@
     "website/demos/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "website/demos/ui-navigation.tsx": "0ede3abc087bc9a66a69d3efb9f1997dfc0da76008e7c88311b5a13ee4c2e01d",
     "website/docs/accessibility.md": "acc7f899e192a16af5d3a07395a26c87e3ada2e05dfbad8bf453779e461fcb51",
-    "website/docs/android.md": "6056ebd29e675f8dce7acd16d711b9a7c66c5860bf3665d20b6125e31103817d",
+    "website/docs/android.md": "ded49e2a009b7e012a7d7f0e37f15c00e04c82333a99a7ca2ffbdfd6f87df83e",
     "website/docs/animation.md": "74859043103a0e12b3e2cf22f5b29ecf3cca7dd9e4009801b38c9d5d5aa2ceed",
     "website/docs/architecture.md": "1a7f3fa3704a5e1c5f4dd6c2df4986c07cdaa247977f7b1e3a13464bfd82a8e5",
     "website/docs/canvas.md": "5589e8b7ceae1856e21081b30d3dc424dc977064fcbb711331f25165c962b4e7",
-    "website/docs/cli.md": "a62a929b93aba13d6074966228300854273c7cec3e00d9c631f6465cda98017d",
+    "website/docs/cli.md": "5550c883425a234aaff5dcd9f41fbb554b02658e7b21130a26b9b73c8d30f0f5",
     "website/docs/coding-guides.md": "38953161931200894317fdb0aaa25e5fd65a52b28ac2ac03e2196f771050dca9",
     "website/docs/comparison.md": "80f11e255cf3463f29d5610213b57808e1ffa92c5de021ebff9e9e153455dca5",
     "website/docs/compatibility.md": "f2a0af8428dd5232052921e94a6a08914ee4e523c8f3dbac0db51f25c0da6650",
@@ -1401,13 +1422,13 @@
     "website/docs/images-and-assets.md": "25b3f1455863969f1ce2dcb8961a30783c425c3d8015fe51a4ef5a134cfc2aa6",
     "website/docs/index.md": "31a039507f14aaf881b9fb351b18a837c5a53650ee8449b14afd91ba1fc22281",
     "website/docs/installation.md": "a7b1a6804f151719d35b8b62eb5b3861712a24cd90175730266ee5fb0874027d",
-    "website/docs/ios.md": "2b6907f65fbe688850c11bda89d54caf4cd43f0e350d34160544fb3b8e9f5811",
+    "website/docs/ios.md": "a7a5d71586c90f020360c0287071a404b2905acc89353e857ca124d568ea6c81",
     "website/docs/jsx-and-components.md": "75c24c3cb56503befc7c06d9eaf67c844318890d9fc989427231ecc9e6e9e627",
     "website/docs/layerfs.md": "827ff94d53cb27ed7a686776b2532de70d0cfaebf0cfc7ba39b433f42ec5342b",
     "website/docs/layout.md": "80c8f0995e88d01959766d6d95e86ead1034599b848e6e572ddc0c554fe5bf89",
-    "website/docs/linux.md": "6df424ab04997a1c29fa85ec34ec883c150aaab1f1165d631445f0699a217616",
+    "website/docs/linux.md": "c5432744bf1ebf18d8afa74075ccb0581e98f5e98255a07cb9c34ab00153b917",
     "website/docs/lists.md": "b59033e583a9c147c0a7cf7309bf22b3744946b0a6a6bac3ee1a3f079aa51d54",
-    "website/docs/macos.md": "83aeb6c4d8214a401cb6413c32cb74b0d62243d59bbbececa32a6dd4ddd18420",
+    "website/docs/macos.md": "4b36f58b9662268707a5868a0443eaa55b44ad59778a25b71e421c970143412f",
     "website/docs/native-components.md": "cd8272bfb271dece1f395009077a762c972c282bbd9246265ee9f6a4ced0e656",
     "website/docs/navigation.md": "8a4a9ac5d9a44ce8e9a30680b4ab9c0a4d6d4bfd556b8c7347074eefb3ba6357",
     "website/docs/networking.md": "7447d4ffeefffb33318ada3594160ce30a47adabb4c59bb66bd360a465c5a2b4",
@@ -1415,21 +1436,21 @@
     "website/docs/performance.md": "f6c4652eef83d81abeee6f2d2dfb370b14a7bb78b2dbb25313afe3a25a20dece",
     "website/docs/platform-specific.md": "acca9b1bce6444f64370e60eb2911b6e8467a21dbf2078823236ab22c95ad872",
     "website/docs/platforms.md": "78f8662ab5b59f11c4a4f0c1f8f520800a46831b5a311cb5e7d6ea825531d09f",
-    "website/docs/project-structure.md": "8465844846b69eddb6a2bf532b2cafa8a63e583c5b1ce246952c73d972588230",
+    "website/docs/project-structure.md": "5f65867b10167d7efe437147d20e794264deff24a647f9924d956e5d8a0f88a0",
     "website/docs/reactivity.md": "8c6731f9556ec0c3a4f401c7861c7198cca53a4bd5e95b57f2f773e7acdbf7a8",
     "website/docs/rust.md": "b3d18c6b202c136d7efbba0f7f87e9d0ae26d2469551f1bb09f47e955333ed34",
-    "website/docs/sandbox-and-capabilities.md": "4938ef6da08859de906c236ead2a8e70a478c599580dbbfb3ed2201de4bb0dc1",
+    "website/docs/sandbox-and-capabilities.md": "1860458dbe33fad7df623efa372bb9568b6e839aad269429d7595b5733cfb0d9",
     "website/docs/scrolling.md": "54356ccb5d65b3f37263339c91e1fb0ee2bc5db32729a9e9b235f2cce51e6850",
     "website/docs/signed-packages.md": "203da373bbcf21d84240b2626d91860e4f3d745733cbeebc22a42a042d0185ee",
     "website/docs/styling.md": "30ecef6fd8f7e881867574898cd05dfb7bb943a996a8da48db2dbfa8af1c4bd3",
-    "website/docs/tailwind.md": "0bfcc244e1cd81cdb3fdbed95c36af126a1284a2f559712007005927c4bea4af",
-    "website/docs/testing-and-trace.md": "a0713c55a7c8c1091c85209fd43fd74911a1bba4bf78f871de0676a894b9112a",
+    "website/docs/tailwind.md": "e3c48864cf4018b4908bed97b92f69ac66395c4cf74600d84b3d1650de843292",
+    "website/docs/testing-and-trace.md": "b4afd8ac17ad3fe7a41a4979495a30575fe19392f5e7b159626479d9737cc5d4",
     "website/docs/text.md": "afecd14b1f27f340c52dd22666b26f014edd698f1d4ce96dd61188d5203e74c1",
     "website/docs/touch-and-gestures.md": "4733ec6aad04835324c174900e099737e39bb6cffa84227959193d95c5d2c39c",
     "website/docs/tui.md": "28f225fcce009393ab1a3a1ac672b3cf3a599e3712fbd344516ac0d3b8a2f7a4",
     "website/docs/web-view-and-video.md": "249dc38d55b3549052396f782f8087d3abae3e81c57fc64b24589f352acadd5e",
-    "website/docs/web.md": "1aa3728395fd9a551a0f8fa34d02d384191d8fa82ab52ed85e852078951ba817",
-    "website/docs/windows.md": "e715393ff39d08901c3a21add178277da9fdf6c38cf1b32d7a2921aab3896ea2",
+    "website/docs/web.md": "73e74007aae291bab262fb1372f41c8f61eb723005780b911555e2a45be0723b",
+    "website/docs/windows.md": "5c52b9a5e1c03a11a8d13a1686b27a082c21c9663f88050375b44f8f4b55ac03",
     "website/elements/alert-dialog.md": "ee0f2bcd0d4a622a5e1521dbd9c854cf3e315b5848104ef3f07ac8f6033d9e5c",
     "website/elements/button.md": "0ee605f47eb3588b4efeb8771bf36b63962101cacc1ed1883d8d22a7a3568da9",
     "website/elements/canvas.md": "c55d6fc031c3b04f91962ea80714c0593db133fe60b228412097b4c97ca20490",
@@ -1463,7 +1484,7 @@
     "website/elements/view.md": "a532351633e057acff510fa3dc45c6380724fb166a5784ed15b928b77a13dc37",
     "website/elements/web-view.md": "7a9ad687ee2afb124f317f4e103572eb07fa8a71d7202d09c8f971130feea591",
     "website/modules/globals.md": "2c4c6b555c0cd2d0aea5dbebd478d1eb0597a419c379fda4853d55d8f79b8215",
-    "website/modules/index.md": "ecd4c0b82d978205fa9e75673e854f04b9602550f0056cd30f2fd864a65f6fa8",
+    "website/modules/index.md": "38620a429fd176cb882a3f7e958a92e6ffeb57b576540707182cfe4b6d92dd7a",
     "website/modules/nsh-accessibility.md": "c446bab2d7fe6becb274bf60546d49e13cd4bab1fe17a80ab494c2f7b3ee775e",
     "website/modules/nsh-android.md": "3bc295d3913732b6f1b75d78a8e879e32fd00f8e65424c7b8468ecb80185a8bb",
     "website/modules/nsh-animated.md": "f50131304ae97c0537af4927bf4ab62524c91068806ce81f3b9daeae6f09662e",
@@ -1473,9 +1494,10 @@
     "website/modules/nsh-desktop.md": "74d5b00ac1d1e27da1e439aed901745108146f9495bed44ef114696e1abb5c85",
     "website/modules/nsh-device.md": "5a49c9c522b883559d6f56ec648dd8502833c69223d52737eebf74bc8f12460e",
     "website/modules/nsh-gesture.md": "242251b48db3e0c70247cfdb243468a0e2641608e70b66914b696450cfca8bfa",
+    "website/modules/nsh-host.md": "5056a6c3c92b50b37db6a20aeded716aa2fcd23b93992eee5a1615179e1dcc1d",
     "website/modules/nsh-image.md": "9291d338ebcc168cedbfc597188691994ac25d7ac800da3659bcc83b356a197e",
     "website/modules/nsh-ios.md": "2c6ea1a8c6c25bb06064e6cd6924cd3ab8a890921b151107d206880ab870eea6",
-    "website/modules/nsh-keymap.md": "b45421fbeb6179a5910a4bf2c2e001ddffbd22037a4e87d7e623a63d32d7c206",
+    "website/modules/nsh-keymap.md": "145bec74a49a6f057f8f92a00478f15d87c54037065c7ac1e599409411fbb9ae",
     "website/modules/nsh-list.md": "91d2b6895835c0327bda430508c483f08d1403d5218e99988555eb2928b38b0d",
     "website/modules/nsh-native.md": "6bcbcbebb59cafdf01ab56b9c71c6731b941bd8f8431e353cb87bbafd18b18c5",
     "website/modules/nsh-storage.md": "4d2918fcd5d8a19ff6a2716fcd5717191350f66b39af22c4ca37985387a4c1e2",
@@ -1570,11 +1592,11 @@
     "website/tests/storage.test.ts": "4711c006141dfea8792fbb1fe2c219746155f57a32650e0fc80aa56b404d1804",
     "website/tsconfig.json": "7b292fd267352f901284513544cd818de4a8a8fe15880f662b3a0900b2a93c99",
     "website/zh-CN/docs/accessibility.md": "63977505bc163dfe40b4d0e40168944c28153dde29a73be1db9931d90bb29bde",
-    "website/zh-CN/docs/android.md": "b64f4fdecf8de8eff2feec7eb185de3982f2d98e78e30cc5aa5eab7399d95aed",
+    "website/zh-CN/docs/android.md": "eba50396501d0dde571dedf1d10ce224d93edc781652025d83e366e5a421c4c5",
     "website/zh-CN/docs/animation.md": "a6d266e73b250cb3274784b48dfcc99ad791897ffdc5d6788e80a11db4fdebd2",
     "website/zh-CN/docs/architecture.md": "6759f09942a51486476a68a37e12c833be9430012e8b2da137d9123632c00294",
     "website/zh-CN/docs/canvas.md": "8a237d25ef877ce00715d88a03e50493667a9961ccfe045372485b0ff48c7b77",
-    "website/zh-CN/docs/cli.md": "d6971d9ce2f9875fcd1073a15c2171ad71d767a4365669ad172844c6d6ef3967",
+    "website/zh-CN/docs/cli.md": "0bd6cfcd9dd4bec72d407438787d0b2c990b05032d5b4ce84e7f36005ab61c11",
     "website/zh-CN/docs/coding-guides.md": "34af3e5c4f13861a7336aca26e636aad3a4a2a71ebbc5d806d25135c7882a716",
     "website/zh-CN/docs/comparison.md": "05e6ab8a44d3db224895d7029e029dbb48a9e74092e5135099ab0179a6ee974c",
     "website/zh-CN/docs/compatibility.md": "1990079dc6a9ed2c4c72a0ccd6a10aae154e5ca722f2c6d899d5874345f62d76",
@@ -1588,13 +1610,13 @@
     "website/zh-CN/docs/images-and-assets.md": "de6564e02b94897bfa6865353acfa8d007b331f0dde1abe572db12281c654d63",
     "website/zh-CN/docs/index.md": "bdde9f1324a8a2511bc1b740f3a1e42be071ab1390363a7152a4bde1705c2deb",
     "website/zh-CN/docs/installation.md": "149ece60315c3d00485a5307421a5b2d4898d6259ff59f1c9861295a56a079d9",
-    "website/zh-CN/docs/ios.md": "615b26e421eb981ece4f360544732b81e9ca4ace2440b6edf05880f142091953",
+    "website/zh-CN/docs/ios.md": "09170b3c662d4c158705a0c95bc3bafbb74432ad58e4a556c576b96dcfe7e70c",
     "website/zh-CN/docs/jsx-and-components.md": "7c80e941fdd795698c237edcfd5f600d748a870f4671b05973d1bc0c2279bf97",
     "website/zh-CN/docs/layerfs.md": "817b28c2b67d90fa5bc754f7b7dcebf07edcb8328d814e56f394ee91989c8217",
     "website/zh-CN/docs/layout.md": "34c9288812aa015497363034a38d338a3138808ea936c21948b1db1fca72733b",
-    "website/zh-CN/docs/linux.md": "b92ff6039e94e50758c13feb3c3fbe38dea346f6e644d35b5863a2bfef8c1298",
+    "website/zh-CN/docs/linux.md": "04a113952bfbc8666cfaab4a12ce971d09d5699118b169115b0bb78896e4ffd5",
     "website/zh-CN/docs/lists.md": "7f70739117e058a511ac1a5b824cd4d109ce407f17b8aacc0f1f3203112ee907",
-    "website/zh-CN/docs/macos.md": "fb469d4765f3fc5e448f28655841f670cc292878766958b604e913caaf508def",
+    "website/zh-CN/docs/macos.md": "7defc3eca134507e1c50b4593f14a121924913df0f22fa2ad645fa6926e28c1b",
     "website/zh-CN/docs/native-components.md": "53687a8c5bde004f6f8be3f15ab2a82b5c063dc4acadc7a69713c9c150512831",
     "website/zh-CN/docs/navigation.md": "cf0b0ed2c31d94591a764e54789a8016b248a71d6bbc2e83961c6febd28b7305",
     "website/zh-CN/docs/networking.md": "3f0d3eaf3d29565c18c92b227cececba879063672f66aeede14fdb05da1c61bf",
@@ -1602,21 +1624,21 @@
     "website/zh-CN/docs/performance.md": "51e47481cf34e1d5b80cb63214a1f13ad89ad2ad902d0ba476f5b52f93a6a7af",
     "website/zh-CN/docs/platform-specific.md": "418a89f4880460fd661451290ce6c5d583c6fbee7423978b1d5ac90d098e6412",
     "website/zh-CN/docs/platforms.md": "dcfde4bb011232303c45849ed0c1b84f30a0a7ef57121855e5ad32a9cd3cba0e",
-    "website/zh-CN/docs/project-structure.md": "1006d1463cf46514347f888024a9a3a492486952fe6d2fe162c0f16c43d74f9a",
+    "website/zh-CN/docs/project-structure.md": "a22b70ff32192cd92edc0bced0a192bad4275ea9f686bbe77a11c0634597fe59",
     "website/zh-CN/docs/reactivity.md": "2f5ab6ca8af99fe3b6c5aa885e3ea5ee878e612bba4b15cd75de37b2c7f4dbb3",
     "website/zh-CN/docs/rust.md": "4d8eebbf32c628977ad3d87b1c227a056a6d7be8cd140ababd3ea6294d70d473",
-    "website/zh-CN/docs/sandbox-and-capabilities.md": "9c021c767938186c7f75cfcb08bfd5200829aa755f013491933e17701dbfb741",
+    "website/zh-CN/docs/sandbox-and-capabilities.md": "878c315de9764988f997e6c7c8929e2b4b73eac8303362c737065b31cc96fdb3",
     "website/zh-CN/docs/scrolling.md": "8e6237c42f3b0d82d87c3c3163661382b00517a6866af57bdd807ddcfe71bf7f",
     "website/zh-CN/docs/signed-packages.md": "2e48499114cf8cfee2cfbfb866b46afa8b5dc9c615f2640462295e0fbaf0b47d",
     "website/zh-CN/docs/styling.md": "9bea733d0c0389b8d095b41f57ddaa10eb18a702a964708699c63833c530a798",
-    "website/zh-CN/docs/tailwind.md": "36506cf5250db89d07f86389cf2bcdf86e15a0107a4a6424e8f0585fd0b59d8b",
-    "website/zh-CN/docs/testing-and-trace.md": "1f570db23c1e31467dd358d62d7e692f1b279b0205b839828c9fa789c8aa1dc1",
+    "website/zh-CN/docs/tailwind.md": "ddb45309d81505a86bea22c582c5ae6ba17561d871c9f3a546d30286ed0fd00c",
+    "website/zh-CN/docs/testing-and-trace.md": "e32c9cb30062fc83bf33d8a680d9137709169fbd689cae429a93b5ee76dac05d",
     "website/zh-CN/docs/text.md": "3e0c854979fe43694024b1c80a9a6dc8d2632009fb2ca1468d978b7084e2ff33",
     "website/zh-CN/docs/touch-and-gestures.md": "6c52f75dee8de1b1a4db4359e2c155a11131f35bac36f3dfc8b90e758fed2cd1",
     "website/zh-CN/docs/tui.md": "759679285a6efb60adc31bddc0b29a712a8375b947f1405cd1f923345f4cf59f",
     "website/zh-CN/docs/web-view-and-video.md": "45ab7af5e283a3021de62b39b7ffe9f64414fd41fe5337d9e51c30656f3e1fb1",
-    "website/zh-CN/docs/web.md": "f0f47e4b4a0cf26d44807d9e782176f391a7134f1dd5ab192132b0215a9c5cf9",
-    "website/zh-CN/docs/windows.md": "6694ce31c742015a5f2d26d746039f5d73081ddcdd556b8c8cbd8ef988531747",
+    "website/zh-CN/docs/web.md": "d22060420052d788555373fecb5297f242542e62a388bc696da88cfb9abf1331",
+    "website/zh-CN/docs/windows.md": "2bc90fb626da5f2c2d65fc39714b21e9c38fefd32154cd16f81693f804ff34a5",
     "website/zh-CN/elements/alert-dialog.md": "ddee942832b7ddb4afbfcaec5af36245af4189b995a65d55d762634176d4213e",
     "website/zh-CN/elements/button.md": "322208c0b3b5664f6d5ed83828f77b22c7f7f7a532e84664da7ead9179d144b6",
     "website/zh-CN/elements/canvas.md": "69200ec9827d47f515d4f2fc2559cff30371d040712ce11341c1de6ab26b11c7",
@@ -1650,7 +1672,7 @@
     "website/zh-CN/elements/view.md": "07ceb854787d0ccda316a08c2f0ad34523f28f85a0b74c4adef29be612d898a2",
     "website/zh-CN/elements/web-view.md": "16fc5b2d4e1a2158f7838d96fa8361c8401f14c5ce5f36a474daef0e246d2fd9",
     "website/zh-CN/modules/globals.md": "8fe3c160dfea2a833e3af37266399a79db319bd3a8279f1bbd0f7173cc33aeea",
-    "website/zh-CN/modules/index.md": "04185943e6282fdc4cf7073f88cc080399934cc0e1636877b8c3422d0b9ff62d",
+    "website/zh-CN/modules/index.md": "7648b4fe4cc43384c7080513b144f5dbf9b23d1ffd9f6096f8fdd63eebb6a35e",
     "website/zh-CN/modules/nsh-accessibility.md": "53b1949ebe056f6e8d2cde899f6160ce6ac5f78b8e9b404b86c257513391f360",
     "website/zh-CN/modules/nsh-android.md": "5732783701f5b8eba5134b9d7f4a87be6930e3cd9a567a5d21063c479f8c9e31",
     "website/zh-CN/modules/nsh-animated.md": "0fc4cd367f3b4d87bf228017fbf8f1d941cd439f9583a8d9641738d5d222b2b8",
@@ -1660,9 +1682,10 @@
     "website/zh-CN/modules/nsh-desktop.md": "d69cebeb28d6d77643af44d53c01f2d5f135ea096b8381b0b631691fb74e05a9",
     "website/zh-CN/modules/nsh-device.md": "2aef14241d7a65e204194fe1663499820a8857b98ad0c8450bac2887beea0d25",
     "website/zh-CN/modules/nsh-gesture.md": "016833be47b4af7906a3b30834f18d2a0daa031f344ef7f655197870be3b0934",
+    "website/zh-CN/modules/nsh-host.md": "c550fb10e43d254b20b78590bbac0a27a828deaee6259ce72ecc616df0985f08",
     "website/zh-CN/modules/nsh-image.md": "ba5af499d466a18bdba503b45d7957c2bcfa64a9454538ab92e271c31cf795be",
     "website/zh-CN/modules/nsh-ios.md": "e1220439df4359e9ba96b0457169683d68f92559fc77544fc741768c45a34ed1",
-    "website/zh-CN/modules/nsh-keymap.md": "ce0661d9b5d0c1c359d984d03c721cd1d23f9b695c5e4ca01f581914993a94df",
+    "website/zh-CN/modules/nsh-keymap.md": "97a0870b80c96e3d99e199056b5c1d6b21d6554484a06e545e308f8dea3fa771",
     "website/zh-CN/modules/nsh-list.md": "bca6d00c4c39da107b74accbd135653d0ef18845fa5aa4248262e53bffa7cdb1",
     "website/zh-CN/modules/nsh-native.md": "f4938e9e7310c840b6bb5517570d7b23d19cf97d7fdd5dd5827cfd148ddb5c0b",
     "website/zh-CN/modules/nsh-storage.md": "3a4b00515785ced4dab774b340a333487f9b7c4ec5bcf64a7ff00b1eab5b7041",
@@ -1674,11 +1697,15 @@
     "website/zh-CN/modules/tailwind-reference.md": "9f366de10fe05a3fcf34f7e2ad7a6d7f8dc82379084f4958bef2c5761aff968f"
   },
   "upstream_repository": "https://github.com/longbridge/nsh",
-  "upstream_revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc",
+  "upstream_revision": "7ed244990097dbbec6b177e26dbc357cb0dba654",
   "file_sources": {
     "AGENTS.md": {
       "repository": "https://github.com/longbridge/nsh",
-      "revision": "111b3a933319fadb0f1da9a59909ca526f256386"
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "Cargo.lock": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
     },
     "Cargo.toml": {
       "repository": "https://github.com/longbridge/nsh",
@@ -1688,13 +1715,69 @@
       "repository": "https://github.com/longbridge/nsh",
       "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
     },
+    "crates/nsh-base/src/lib.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-base/src/theme.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-bundler/src/bundle.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-cli/src/dev.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-cli/src/main.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-cli/src/package.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-cli/src/test.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-cli/src/test_web.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-cli/src/web.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-cli/src/web/dev.html": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-cli/src/web/index.html": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/access.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/core/components.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
     "crates/nsh-core/src/core/layout_tree.rs": {
       "repository": "https://github.com/longbridge/nsh",
       "revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc"
     },
+    "crates/nsh-core/src/core/list.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
     "crates/nsh-core/src/core/mod.rs": {
       "repository": "https://github.com/longbridge/nsh",
-      "revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc"
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
     },
     "crates/nsh-core/src/core/overlay.rs": {
       "repository": "https://github.com/longbridge/nsh",
@@ -1706,19 +1789,395 @@
     },
     "crates/nsh-core/src/core/tests.rs": {
       "repository": "https://github.com/longbridge/nsh",
-      "revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc"
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/js/host.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/js/mod.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/keymap.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/ops.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/platform.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/runtime/host.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/runtime/mod.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/ui/ambient.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/ui/env.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/ui/services.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-core/src/ui/task.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-engine/src/lib.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-engine/tests/engine/components.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-engine/tests/engine/host.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-engine/tests/engine/list.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-engine/tests/engine/list_bench.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-engine/tests/engine/logs.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-engine/tests/engine/main.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-engine/tests/engine/theme.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-js/src/host.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-js/src/keymap.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-js/src/modules.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-js/src/platform_modules.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-android/src/agent.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-android/src/encode.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-android/src/host.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-android/src/keymap.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-android/src/lib.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/include/nsh.h": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/src/agent.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/src/embed.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/src/input.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/src/keymap.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/src/lib.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/src/mount.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/src/surface.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/src/synth.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/src/target.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/src/view.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-ios/swift/NativeShell.swift": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-linux/src/app.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-linux/src/surface.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-macos/src/app.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-macos/src/surface.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-web/src/encode.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-web/src/lib.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-web/web/nsh.js": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-web/web/nsh.test.mjs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-windows/src/app.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-platform-windows/src/surface.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/Cargo.toml": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/src/agent.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/src/headless/keyboard.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/src/headless/mod.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/src/lib.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/src/page_host.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/src/run.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/src/runner.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/tests/testing/host/App.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/tests/testing/host/Broken.test.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/tests/testing/host/Host.test.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/tests/testing/host/NoHost.test.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/tests/testing/host/host.js": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/tests/testing/host/none.js": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/tests/testing/host/nsh.json": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "crates/nsh-testing/tests/testing/main.rs": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
     },
     "docs/dev/compatibility.md": {
       "repository": "https://github.com/longbridge/nsh",
       "revision": "111b3a933319fadb0f1da9a59909ca526f256386"
     },
+    "docs/dev/elements.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "docs/dev/embedding.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "docs/dev/js-modules.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "docs/dev/macos.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
     "docs/dev/performance.md": {
       "repository": "https://github.com/longbridge/nsh",
-      "revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc"
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
     },
     "docs/dev/pitfalls.md": {
       "repository": "https://github.com/longbridge/nsh",
-      "revision": "44278a653d73b7f8626e964f7895b8a4c4a964dc"
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "docs/dev/sandbox.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "docs/dev/scrolling-and-lists.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "docs/dev/tooling.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "docs/dev/web.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/embed-android/README.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/embed-ios/README.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/embed-ios/main.swift": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/embedded/App.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/gallery/tests/fixtures/mention.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/gallery/tests/mention.test.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/host-echo/App.test.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/host-echo/App.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/host-echo/host.js": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/host-echo/nsh.json": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "examples/host-echo/tsconfig.json": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "tests/compat/cases/core/core-list.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "tests/compat/cases/core/script.txt": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "tests/compat/cases/modules/App.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "tests/compat/cases/modules/mod-host.tsx": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "tests/compat/cases/modules/script.txt": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
     },
     "website/DESIGN.md": {
       "repository": "https://github.com/longbridge/nsh",
@@ -1726,7 +2185,11 @@
     },
     "website/docs/android.md": {
       "repository": "https://github.com/longbridge/nsh",
-      "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/docs/cli.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
     },
     "website/docs/comparison.md": {
       "repository": "https://github.com/longbridge/nsh",
@@ -1738,12 +2201,56 @@
     },
     "website/docs/ios.md": {
       "repository": "https://github.com/longbridge/nsh",
-      "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/docs/linux.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/docs/macos.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
     },
     "website/docs/platforms.md": {
       "repository": "https://github.com/longbridge/nsh",
       "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
     },
+    "website/docs/project-structure.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/docs/sandbox-and-capabilities.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/docs/tailwind.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/docs/testing-and-trace.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/docs/web.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/docs/windows.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/modules/index.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/modules/nsh-host.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/modules/nsh-keymap.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
     "website/src/components/Home.astro": {
       "repository": "https://github.com/longbridge/nsh",
       "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
@@ -1778,7 +2285,11 @@
     },
     "website/zh-CN/docs/android.md": {
       "repository": "https://github.com/longbridge/nsh",
-      "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/zh-CN/docs/cli.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
     },
     "website/zh-CN/docs/comparison.md": {
       "repository": "https://github.com/longbridge/nsh",
@@ -1790,11 +2301,55 @@
     },
     "website/zh-CN/docs/ios.md": {
       "repository": "https://github.com/longbridge/nsh",
-      "revision": "de52bb3f9c236c48f0414f1bb36c17b92914ff91"
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/zh-CN/docs/linux.md": {
+      "repository": "https://github.com/longbridge/nsh",
+      "revision": "7ed244990097dbbec6b177e26dbc357cb0dba654"
+    },
+    "website/zh-CN/docs/macos.md": {
... 1045 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org