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 11:24:35 UTC

(maka) branch rust-preview updated: feat(nsh): sync motion, toast, and image-cache APIs

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 556df43be4 feat(nsh): sync motion, toast, and image-cache APIs
556df43be4 is described below

commit 556df43be44936a53df56c6983adfeafba51305f
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Fri Oct 23 20:24:19 2026 +0900

    feat(nsh): sync motion, toast, and image-cache APIs
    
    Import longbridge/nsh b5e6e18a at the vendor boundary. Preserve terminal packaging, indexed list fixtures, PlatformRenderer, animation adjustments, host asset sources, and reload ownership. Keep Maka engine cache defaults unchanged.
    
    Generated-by: OpenAI Codex
---
 Cargo.lock                                         |    2 +
 products/maka/cli/notices.json                     |  346 ++++---
 third-party/nsh/Cargo.lock                         |    2 +
 third-party/nsh/Cargo.toml                         |    3 +
 third-party/nsh/SOURCE.md                          |    2 +-
 third-party/nsh/crates/gpui/Cargo.lock             |    8 +
 .../nsh/crates/gpui/nsh-gpui-host/src/lib.rs       |   10 +-
 .../nsh/crates/gpui/nsh-platform-gpui/src/app.rs   |   13 +
 .../nsh/crates/gpui/nsh-platform-gpui/src/image.rs |  243 ++++-
 .../nsh/crates/gpui/nsh-platform-gpui/src/lib.rs   |    5 +-
 .../crates/gpui/nsh-platform-gpui/src/surface.rs   |    6 +
 third-party/nsh/crates/nsh-base/src/lib.rs         |   14 +-
 third-party/nsh/crates/nsh-base/src/toast/js.rs    |  216 ++++
 .../nsh/crates/nsh-base/src/toast/manager.rs       |  355 +++++++
 third-party/nsh/crates/nsh-base/src/toast/mod.rs   |   98 ++
 third-party/nsh/crates/nsh-base/src/toast/root.rs  |  246 +++++
 third-party/nsh/crates/nsh-base/src/toast/stack.rs |  345 +++++++
 third-party/nsh/crates/nsh-cli/src/android.rs      |    8 +-
 third-party/nsh/crates/nsh-cli/src/rust_app.rs     |   15 +
 third-party/nsh/crates/nsh-core/Cargo.toml         |    3 +
 .../nsh/crates/nsh-core/src/animation/driver.rs    |   34 +-
 .../nsh/crates/nsh-core/src/animation/mod.rs       |   10 +-
 .../nsh/crates/nsh-core/src/animation/motion.rs    | 1073 ++++++++++++++++++++
 .../nsh/crates/nsh-core/src/animation/ops.rs       |   76 ++
 third-party/nsh/crates/nsh-core/src/byte_size.rs   |   68 ++
 .../nsh/crates/nsh-core/src/core/components.rs     |    6 +-
 third-party/nsh/crates/nsh-core/src/core/mod.rs    |    1 +
 third-party/nsh/crates/nsh-core/src/core/tests.rs  |   93 ++
 third-party/nsh/crates/nsh-core/src/image.rs       |    8 +
 third-party/nsh/crates/nsh-core/src/image/cache.rs |  789 ++++++++++++++
 .../nsh/crates/nsh-core/src/image/cache/tests.rs   |  518 ++++++++++
 .../nsh/crates/nsh-core/src/js/animation.rs        |  283 +++++-
 third-party/nsh/crates/nsh-core/src/lib.rs         |   14 +-
 third-party/nsh/crates/nsh-core/src/mount.rs       |    4 +
 third-party/nsh/crates/nsh-core/src/ops.rs         |   33 +
 third-party/nsh/crates/nsh-core/src/ui/ambient.rs  |    7 +
 third-party/nsh/crates/nsh-core/src/ui/animated.rs |  198 +++-
 third-party/nsh/crates/nsh-core/src/ui/element.rs  |   22 +
 third-party/nsh/crates/nsh-core/src/ui/host.rs     |   13 +
 third-party/nsh/crates/nsh-core/src/ui/mod.rs      |   33 +-
 third-party/nsh/crates/nsh-core/unitless.txt       |   15 +
 third-party/nsh/crates/nsh-engine/src/cache.rs     |  138 +++
 third-party/nsh/crates/nsh-engine/src/lib.rs       |   18 +
 third-party/nsh/crates/nsh-engine/src/storage.rs   |   57 ++
 .../nsh/crates/nsh-engine/tests/engine/canvas.rs   |    1 +
 .../nsh/crates/nsh-engine/tests/engine/compiled.rs |    2 +
 .../crates/nsh-engine/tests/engine/components.rs   |   43 +
 .../nsh/crates/nsh-engine/tests/engine/counter.rs  |    1 +
 .../nsh/crates/nsh-engine/tests/engine/errors.rs   |    2 +
 .../nsh/crates/nsh-engine/tests/engine/fetch.rs    |    3 +
 .../nsh/crates/nsh-engine/tests/engine/fs.rs       |    2 +
 .../crates/nsh-engine/tests/engine/host_modules.rs |    1 +
 .../nsh/crates/nsh-engine/tests/engine/hot.rs      |    2 +
 .../nsh/crates/nsh-engine/tests/engine/hover.rs    |    1 +
 .../nsh/crates/nsh-engine/tests/engine/js_touch.rs |    1 +
 .../nsh/crates/nsh-engine/tests/engine/keymap.rs   |    1 +
 .../nsh/crates/nsh-engine/tests/engine/list.rs     |    1 +
 .../nsh/crates/nsh-engine/tests/engine/native.rs   |    1 +
 .../nsh/crates/nsh-engine/tests/engine/net.rs      |    1 +
 .../nsh/crates/nsh-engine/tests/engine/package.rs  |    1 +
 .../nsh/crates/nsh-engine/tests/engine/parity.rs   |    1 +
 .../nsh/crates/nsh-engine/tests/engine/platform.rs |    1 +
 .../nsh/crates/nsh-engine/tests/engine/process.rs  |    1 +
 .../nsh-engine/tests/engine/rust_animation.rs      |    1 +
 .../nsh/crates/nsh-engine/tests/engine/rust_app.rs |    8 +
 .../crates/nsh-engine/tests/engine/rust_classes.rs |    1 +
 .../crates/nsh-engine/tests/engine/rust_lists.rs   |    2 +
 .../nsh-engine/tests/engine/rust_platform.rs       |    1 +
 .../crates/nsh-engine/tests/engine/rust_popups.rs  |  118 ++-
 .../crates/nsh-engine/tests/engine/rust_styles.rs  |    1 +
 .../crates/nsh-engine/tests/engine/rust_touch.rs   |    1 +
 .../nsh/crates/nsh-engine/tests/engine/shutdown.rs |    1 +
 .../nsh/crates/nsh-engine/tests/engine/split.rs    |    1 +
 .../nsh/crates/nsh-engine/tests/engine/storage.rs  |    4 +
 .../nsh/crates/nsh-engine/tests/engine/system.rs   |    2 +
 .../nsh/crates/nsh-engine/tests/engine/theme.rs    |    3 +
 .../crates/nsh-engine/tests/engine/websocket.rs    |    1 +
 third-party/nsh/crates/nsh-ios-host/src/lib.rs     |    4 +
 third-party/nsh/crates/nsh-js/src/animated.rs      |  634 +++++++++++-
 third-party/nsh/crates/nsh-js/src/components.rs    |    9 +-
 third-party/nsh/crates/nsh-js/src/protocol.rs      |   30 +
 third-party/nsh/crates/nsh-linux-host/src/lib.rs   |   10 +-
 third-party/nsh/crates/nsh-macos-host/src/lib.rs   |   10 +-
 .../nsh/crates/nsh-platform-android/build.rs       |   11 +
 .../java/com/longbridge/nsh/DevActivity.java       |    3 +-
 .../java/com/longbridge/nsh/Images.java            |   63 +-
 .../java/com/longbridge/nsh/InputBox.java          |   11 +
 .../java/com/longbridge/nsh/Mounter.java           |   13 +
 .../java/com/longbridge/nsh/Native.java            |   26 +-
 .../java/com/longbridge/nsh/NshCacheOptions.java   |  129 +++
 .../java/com/longbridge/nsh/NshView.java           |   27 +-
 .../nsh/crates/nsh-platform-android/src/host.rs    |  174 +++-
 .../nsh/crates/nsh-platform-ios/include/nsh.h      |   41 +
 third-party/nsh/crates/nsh-platform-ios/src/app.rs |    4 +
 .../nsh/crates/nsh-platform-ios/src/embed.rs       |   92 ++
 .../nsh/crates/nsh-platform-ios/src/image.rs       |  149 ++-
 third-party/nsh/crates/nsh-platform-ios/src/lib.rs |    2 +-
 .../nsh/crates/nsh-platform-ios/src/mount.rs       |   57 +-
 .../nsh/crates/nsh-platform-ios/src/navigation.rs  |  119 ++-
 .../nsh/crates/nsh-platform-ios/src/surface.rs     |   29 +-
 .../nsh/crates/nsh-platform-ios/src/view.rs        |    9 +
 .../nsh-platform-ios/swift/NativeShell.swift       |   71 ++
 .../nsh/crates/nsh-platform-linux/src/app.rs       |   13 +
 .../nsh/crates/nsh-platform-linux/src/image.rs     |  152 ++-
 .../nsh/crates/nsh-platform-linux/src/lib.rs       |    4 +-
 .../nsh/crates/nsh-platform-linux/src/surface.rs   |    5 +
 .../nsh/crates/nsh-platform-macos/src/app.rs       |   13 +
 .../nsh/crates/nsh-platform-macos/src/image.rs     |  152 ++-
 .../nsh/crates/nsh-platform-macos/src/lib.rs       |    4 +-
 .../nsh/crates/nsh-platform-macos/src/surface.rs   |    5 +
 third-party/nsh/crates/nsh-platform-web/Cargo.toml |    5 +
 .../nsh/crates/nsh-platform-web/src/encode.rs      |   22 +-
 third-party/nsh/crates/nsh-platform-web/src/ffi.rs |   26 +
 third-party/nsh/crates/nsh-platform-web/src/lib.rs |    2 +
 .../nsh/crates/nsh-platform-web/src/text.rs        |  257 +++--
 .../nsh/crates/nsh-platform-web/src/text/css.rs    |  186 ++++
 .../nsh/crates/nsh-platform-web/src/text/wrap.rs   |  231 +++++
 .../crates/nsh-platform-web/tests/text/fonts.rs    |  147 +++
 .../crates/nsh-platform-web/tests/text/lines.rs    |  170 ++++
 .../nsh/crates/nsh-platform-web/tests/text/main.rs |   14 +
 third-party/nsh/crates/nsh-platform-web/web/nsh.js |   41 +
 .../nsh-platform-web/web/text-browser.test.mjs     |   66 ++
 .../nsh/crates/nsh-platform-windows/src/app.rs     |   13 +
 .../nsh/crates/nsh-platform-windows/src/image.rs   |  151 ++-
 .../nsh/crates/nsh-platform-windows/src/lib.rs     |    4 +-
 .../nsh/crates/nsh-platform-windows/src/surface.rs |    5 +
 third-party/nsh/crates/nsh-windows-host/src/lib.rs |   10 +-
 third-party/nsh/crates/xtask/src/android.rs        |   88 +-
 third-party/nsh/crates/xtask/src/main.rs           |    2 +-
 third-party/nsh/docs/dev/animation.md              |   71 ++
 third-party/nsh/docs/dev/components.md             |   47 +-
 third-party/nsh/docs/dev/embedding.md              |   17 +-
 third-party/nsh/docs/dev/images.md                 |   24 +
 third-party/nsh/docs/dev/pitfalls.md               |    9 +
 third-party/nsh/docs/dev/web.md                    |   11 +-
 third-party/nsh/examples/gallery/App.tsx           |    4 +-
 .../nsh/examples/gallery/demos/Animations.tsx      |  440 +++++++-
 .../nsh/examples/gallery/demos/Overlays.tsx        |   33 +-
 .../nsh/examples/gallery/tests/animations.test.tsx |   20 +
 .../nsh/examples/gallery/tests/toasts.test.tsx     |   88 ++
 third-party/nsh/examples/gallery/toast.tsx         |  159 +++
 third-party/nsh/rust-toolchain.toml                |    1 +
 third-party/nsh/tests/compat/cases/base/App.tsx    |    2 +
 .../nsh/tests/compat/cases/base/base-nav-page.tsx  |    1 +
 .../nsh/tests/compat/cases/base/base-toast.tsx     |   83 ++
 third-party/nsh/tests/compat/cases/base/script.txt |   71 ++
 third-party/nsh/tests/compat/cases/core/App.tsx    |    2 +
 .../nsh/tests/compat/cases/core/core-motion.tsx    |   43 +
 third-party/nsh/tests/compat/cases/core/script.txt |   13 +
 third-party/nsh/upstream.json                      |  880 +++++++++++-----
 third-party/nsh/website/demos/el-toast.tsx         |  109 ++
 third-party/nsh/website/docs/android.md            |   11 +-
 third-party/nsh/website/docs/animation.md          |  295 +++++-
 third-party/nsh/website/docs/gpui.md               |    4 +
 third-party/nsh/website/docs/images-and-assets.md  |   47 +
 third-party/nsh/website/docs/installation.md       |    2 +-
 third-party/nsh/website/docs/ios.md                |    3 +-
 third-party/nsh/website/docs/linux.md              |    4 +
 third-party/nsh/website/docs/macos.md              |    4 +
 third-party/nsh/website/docs/navigation.md         |    1 +
 third-party/nsh/website/docs/packaging.md          |    2 +-
 third-party/nsh/website/docs/platform-specific.md  |    1 +
 third-party/nsh/website/docs/platforms.md          |    2 +-
 third-party/nsh/website/docs/web.md                |    2 +-
 third-party/nsh/website/docs/windows.md            |    4 +
 third-party/nsh/website/elements/index.md          |    1 +
 third-party/nsh/website/elements/toast.md          |  202 ++++
 third-party/nsh/website/modules/nsh-animated.md    |  151 ++-
 third-party/nsh/website/modules/nsh-base.md        |    8 +-
 third-party/nsh/website/zh-CN/docs/android.md      |   11 +-
 third-party/nsh/website/zh-CN/docs/animation.md    |  295 +++++-
 third-party/nsh/website/zh-CN/docs/gpui.md         |    1 +
 .../nsh/website/zh-CN/docs/images-and-assets.md    |   38 +
 third-party/nsh/website/zh-CN/docs/installation.md |    2 +-
 third-party/nsh/website/zh-CN/docs/ios.md          |    3 +-
 third-party/nsh/website/zh-CN/docs/linux.md        |    2 +
 third-party/nsh/website/zh-CN/docs/macos.md        |    2 +
 third-party/nsh/website/zh-CN/docs/navigation.md   |    1 +
 third-party/nsh/website/zh-CN/docs/packaging.md    |    2 +-
 .../nsh/website/zh-CN/docs/platform-specific.md    |    1 +
 third-party/nsh/website/zh-CN/docs/platforms.md    |    2 +-
 third-party/nsh/website/zh-CN/docs/web.md          |    2 +-
 third-party/nsh/website/zh-CN/docs/windows.md      |    2 +
 third-party/nsh/website/zh-CN/elements/index.md    |    1 +
 third-party/nsh/website/zh-CN/elements/toast.md    |  182 ++++
 .../nsh/website/zh-CN/modules/nsh-animated.md      |  151 ++-
 third-party/nsh/website/zh-CN/modules/nsh-base.md  |    8 +-
 187 files changed, 11857 insertions(+), 899 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 515998b90e..338e671e56 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10533,8 +10533,10 @@ name = "nsh-core"
 version = "0.2.0"
 dependencies = [
  "cap-std",
+ "crc32fast",
  "flate2",
  "futures-util",
+ "httpdate",
  "imagesize 0.15.0",
  "libc",
  "log",
diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index 66c5f9f5c8..0c4bba27ba 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -1,6 +1,6 @@
 {
   "inputs": {
-    "Cargo.lock": "f13fea4c9bc64aac0965947e076d0e9a90c1cd16e976adf68a23f049b9fc17b0",
+    "Cargo.lock": "b086a57852675e8e984180b51c44d45e08ac1af013968fe5e0bd5fb64873bea1",
     "Cargo.toml": "d6c6ef09013bf2b8230132a0fd70223769cbeb96328c4f05e77e8da0a5405e7b",
     "package-lock.json": "2cd0e1423449277ccf0721dbfe554c6a6297d9242211f33e44b1a282a6d2b4b8",
     "package.json": "a6cf2b6e2d6c14ed98f1e09ba195f50260b7e1564a29a8e7477575654dc5d177",
@@ -1025,15 +1025,15 @@
     "third-party/nsh/AGENTS.md": "351c7db0a4f400ce2e469e1467813f98ccef5ef41525e57e65b48d2ec604b2eb",
     "third-party/nsh/CLAUDE.md": "336cc4fbf19beaada7ccf9986414fa91851a8d7a07dfb3ccbe800a69eed0ab49",
     "third-party/nsh/CONTRIBUTING.md": "fb27100fec01ba22dde6da1546f9cd761b8e23bec7d47eca87146c7991e85de2",
-    "third-party/nsh/Cargo.lock": "73551f97e4810c187933a5758a6c82c183173c03583caf302b1623568aaa2139",
-    "third-party/nsh/Cargo.toml": "88b5c3cd0841e9f0ab3ce8c2885fd4410dbed2e29cd3285a03ec61e2205914a2",
+    "third-party/nsh/Cargo.lock": "c2749a1c2e3d0d39badb7a1103385eb01e3d0230f064dcb9a73b89f701a12d8a",
+    "third-party/nsh/Cargo.toml": "5bb35f10535e311f73e162fca8a4d8ea96dd3204f5b276cba422639d67630ef4",
     "third-party/nsh/LICENSE-APACHE": "ec75427ad955e3c740f22e7dc3f6db6fc5b2cc1d9135c2af91cece8dd47a2d38",
     "third-party/nsh/README.md": "ff632ba97e2af5f8f0d69ed03250dd2a7d90f53da48b7a07976394d37f85aacb",
-    "third-party/nsh/SOURCE.md": "79b1d3d2bcac2aeeaedebfd284d7529108b526da88c198e77618f8d9b4ff01da",
-    "third-party/nsh/crates/gpui/Cargo.lock": "00f96e95d7d6c106c87e515b28ab01506a7f9688d6de8220fb3ae1fc1733d809",
+    "third-party/nsh/SOURCE.md": "cea11b9244c56aa2e90623c7c1b897a1839ce2c529aa9a7892cbd696b3af2356",
+    "third-party/nsh/crates/gpui/Cargo.lock": "0671274cbf72058fa84e826f2898bfa96ceb2744dce5656c01c7a7783e583a57",
     "third-party/nsh/crates/gpui/Cargo.toml": "1d53eec1242cd480700a9e2ffd5c013f21c8b0c3207cb9544733fd5ea375f0f1",
     "third-party/nsh/crates/gpui/nsh-gpui-host/Cargo.toml": "f36b982a17b38bd1d91760a2d8d9db06b42db949c227878cef294a5ff2100844",
-    "third-party/nsh/crates/gpui/nsh-gpui-host/src/lib.rs": "ac2976b5a3d01d49198d8868009ca2c6636bf4af627cd922e778ee2d7ec9a243",
+    "third-party/nsh/crates/gpui/nsh-gpui-host/src/lib.rs": "1e75ad74d84e0feaf5093065797914372cfb4ed0341badcf495e6be131150046",
     "third-party/nsh/crates/gpui/nsh-gpui-host/src/main.rs": "90cc675d4e2d70cf54f61b4cc5b28af0167d895e76242e93865fdebfd440d3ae",
     "third-party/nsh/crates/gpui/nsh-gpui-terminal/Cargo.toml": "0a1ff80203b0ff21fd0aa39d8f76ce1dcd4121532670dfaac52d6333bf7aaaf2",
     "third-party/nsh/crates/gpui/nsh-gpui-terminal/README.md": "3f91ef22670a72ce6e53202f6012a26ded711a710debac800f00a9c4a38704df",
@@ -1049,7 +1049,7 @@
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/access.rs": "9b0fec093b95d73254ff0b3301b4cb7266a65a7ac074384ebeff02902d6b4a59",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/agent.rs": "05bbc3ce172ccc45187ee13fc72cb0ada94466906b0d16c1f670797aa857e5f3",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/animation.rs": "5e849ee6e81ef77bfcd70e255228431f3ea2b67266530a10d962582541b78c29",
-    "third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs": "cd31c55106eff903037b09a67e633b0ca44167a05e2be65c709e5a8959e0f275",
+    "third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs": "d6bcbab49c126e2d8a10fd7da59b6f1fe79451212c5105c3663ba81c6868116b",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/canvas.rs": "fc25fa52fbe8fe59b16ebf971c81a1bddc68e78181df2181a5620db24abc6518",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/chrome.rs": "888d41336ef98d6f51664281de2b5eec613445f4ccf8f12735e792f8ace1133c",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/chrome/menus.rs": "43b4c8cef09fb4d314e6f908b8f57df821ab6eaa9d236e0087db6a0599ae3ab7",
@@ -1060,11 +1060,11 @@
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/events.rs": "3549709f811270e5d7495f6a2394926b8ab7dac9d2dff7fd9de66adf0568233a",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/focus_return.rs": "05270dbdaf2f5418fbbb6d065e4121d5099520166af9a00100f9694e2bbdce3e",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/geom.rs": "c3ec43a6f17f80371760c5f6cc2f8170fe0cc543bbda7094d683b3fbb287413e",
-    "third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs": "2f6fd485e03ec5aa6c4ecbb2d5019dbe19670dfb0c7c7b93b9cef548355a04e0",
+    "third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs": "fd0f95fcfa2dada529b5234a0f295217bcd1ff6764ccc1edf91b92b0d129d12b",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/input.rs": "048bd4ae26f4440ada3cf44988e895540e2917b3e51212be2af688be28dbade3",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/keymap.rs": "8bbd84f9cdebd3a7e3b2756085ee0233f3b3efb447ade999781ab6f2d4c8b56b",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/keys.rs": "588934210e4de9cfc849f0a66d48af29e3637a33f019a084db8961db0df46561",
-    "third-party/nsh/crates/gpui/nsh-platform-gpui/src/lib.rs": "db69c489a913fa62facbe592bb5089751753844486029ab7dab6f6e3ef534d49",
+    "third-party/nsh/crates/gpui/nsh-platform-gpui/src/lib.rs": "9f68a36188d46af9e8b7d2d0f54744963418318071f746930d5a076ac922b08a",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/mount.rs": "f0c99ac758e694b95c197f2f4137a8a100bb9e164746c793c3081c03d41ac04c",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/native.rs": "e539afdcfab410ce2d191bca7436a3dcfc61577622c47dc6dc62e66d3544c98a",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/native/video.rs": "e1a61eee03f49428c3860c2c732baa88709ac07fb8e837e0b09ba387f3473049",
@@ -1075,7 +1075,7 @@
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/scroll.rs": "dd042da62d2a20624442d8816a918eb69b39f5ea40f1a5a69fc1229d3f7d42bd",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/selection_area.rs": "ba1d8e91ca6bf8296d6744f567e822ed7bf5fcf001addb4bc016b1a61ee0d02f",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/stats.rs": "b05468754e418be8b4fb4506b6036cd2b96c9f6f484fa5607b0fab9db74ab44e",
-    "third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs": "24aa6b5d57947613075fcdc839da3e40c23db65c0a40aaae6ad3924bd10a777b",
+    "third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs": "d795cacddec9f88cc31e0f5581443ef151282779b79364c47f0cba3ad2fb9c29",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/synth.rs": "d6c43385fcf80d88679f55720befe2bb3e1b3cc0f8cae0d053db6f482955672e",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/system.rs": "69289d4aec9a83193dc85e68b93c4a997f32a5804df5ac4f8531057f581babd7",
     "third-party/nsh/crates/gpui/nsh-platform-gpui/src/tests.rs": "83072b503110d812ea399fff5aeb8c9db568ae5c5884ed72f44a7fd33708bab2",
@@ -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": "370824ed24d2607cf77da01350a78d0749739ba26ee71fb69d61c38c9eff81ae",
+    "third-party/nsh/crates/nsh-base/src/lib.rs": "bc97869e9d3ddf8f73c7d629e2fc5241363ab5fc055de4852ac977a7e899080c",
     "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",
@@ -1145,6 +1145,11 @@
     "third-party/nsh/crates/nsh-base/src/text_view/parts.rs": "1cc12fdfdb815ea5eb44ceaa37467849b9005d99353809150379708f4b7c1add",
     "third-party/nsh/crates/nsh-base/src/text_view/view.rs": "b6c75f30e3a46c569a9f5a9b482ad810618e9b4989f1bb8c846edb6911791e7c",
     "third-party/nsh/crates/nsh-base/src/theme.rs": "2eedb3df1738c45b6c295f18cd5a8a2f4959195c87d9c304fa2d8e5ecd8bb9c5",
+    "third-party/nsh/crates/nsh-base/src/toast/js.rs": "cfc20e75e05e457d208866ddba22c166c7b746ec275944a70a7aea9f6133b668",
+    "third-party/nsh/crates/nsh-base/src/toast/manager.rs": "8eb2b92e0fdc71d9b72417f305a9f4d03e6ee2f6a73a5e8702f8ae1abeb266e3",
+    "third-party/nsh/crates/nsh-base/src/toast/mod.rs": "394e32fe87fe70897140a8aba92203f7d853b3c43d1ec6206a2662df34a7ec40",
+    "third-party/nsh/crates/nsh-base/src/toast/root.rs": "0228e2376914bb34db7ff849f258b97c82dca7d6b9c8e962112e1be4ff1ffd35",
+    "third-party/nsh/crates/nsh-base/src/toast/stack.rs": "197e9b4252cc2bf659b8212a13226b6f574332adefadc153fbf18e558cc146c4",
     "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",
@@ -1165,7 +1170,7 @@
     "third-party/nsh/crates/nsh-bundler/src/types.rs": "6547e6859ac6c9ffe6639132fc874922c58760f2a31e0bed0cc95019362e1cc3",
     "third-party/nsh/crates/nsh-cli/Cargo.toml": "c80530c91c38340020d5b69c66d61947600ffe4ae4977b108ccb1392e237cd69",
     "third-party/nsh/crates/nsh-cli/build.rs": "617f5d3890c8b278c74b7a4620a148a0a8079fa7dba2e98b0726cac5d0fef494",
-    "third-party/nsh/crates/nsh-cli/src/android.rs": "ca5254b4b751e140a69a0ab30e23d57e0c673de0441c2491ad66cba0f2ea9792",
+    "third-party/nsh/crates/nsh-cli/src/android.rs": "715622bfcc2306a059c62d2326caaeab494acc35eb9f790685292ef5eeb24cf4",
     "third-party/nsh/crates/nsh-cli/src/bindgen.rs": "c0637c275b6fceb199ef7b1edc8f73305eabd8640e92760fa4f58ec84eba2aa8",
     "third-party/nsh/crates/nsh-cli/src/bindgen/wasm-bindgen-0.2.129.js": "5823060057ea5de1326121c1132d558d8f96d1fa8c23fb1270a132b07ac9af06",
     "third-party/nsh/crates/nsh-cli/src/build.rs": "930e9e61189b299f9b4121db7810c48a463f50d2e45d3dae2a88d820ab08d309",
@@ -1179,7 +1184,7 @@
     "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",
+    "third-party/nsh/crates/nsh-cli/src/rust_app.rs": "876e4fa8025bfb775611a29d1a6d405b240bde34d74b1c13ea996158b5621092",
     "third-party/nsh/crates/nsh-cli/src/sign.rs": "fc514e034f23cc868166368b7add5a8f8ead0437bddbdb61e6296fa7ecf55028",
     "third-party/nsh/crates/nsh-cli/src/templates/mixed/App.test.tsx": "8bfdffa4cc29537e750cb3c281fb815bcee41e457ab70cebd3cf18ae2a1bf40e",
     "third-party/nsh/crates/nsh-cli/src/templates/mixed/App.tsx": "659921fdaa9e38cf2a5154fa12eb2296f1fade4cbb224bfbd6838464c52e59b4",
@@ -1205,31 +1210,33 @@
     "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": "a71972d28e3a83a5b5390f6880ded0e75862217ea537cf2cb3268af47dd9ba4e",
+    "third-party/nsh/crates/nsh-core/Cargo.toml": "f336638d283d569d56a0477c0ab89c69217e671ba6ffedd2c90fd5780bcb9dc4",
     "third-party/nsh/crates/nsh-core/src/access.rs": "a85ee4367eb53c22113b4d0fc1f6fd581591c5b1f3a2290634024f82313dba88",
     "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/driver.rs": "cf3d2b4e7d5900193d0d9aad828cae54191090452cedc9dcf2a3aed73a9c0d48",
     "third-party/nsh/crates/nsh-core/src/animation/graph.rs": "a4fe8ca39d20fffce8f2114ee8d834c82044053ad5ef34c17e3bfa037c7e1fe2",
-    "third-party/nsh/crates/nsh-core/src/animation/mod.rs": "34bcf5a813cadcf903304308ccfe9e6cf58bc59e2fe36707fac918221c392d1f",
-    "third-party/nsh/crates/nsh-core/src/animation/ops.rs": "45d17baa5d67d2197027d26fa1313044c8cdc102e5c7e9aa8ea2a20cef924931",
+    "third-party/nsh/crates/nsh-core/src/animation/mod.rs": "4177bffccc5962982e0022488316eb52fff3682a63651906217e4c6b4cfbb56d",
+    "third-party/nsh/crates/nsh-core/src/animation/motion.rs": "d7d26626aaa2cad06a4d2b5bc365cd24e7e1e9706e181b0ad550c6388d3d9fea",
+    "third-party/nsh/crates/nsh-core/src/animation/ops.rs": "9302567c4ee1403243b553f6742f965439e2d245ffaf9aa5578c82d8526f4c59",
+    "third-party/nsh/crates/nsh-core/src/byte_size.rs": "9cbaa27c69a16eadbc40b898a0ed683362a7c1c1bce39b7f2fabf8b26e46db43",
     "third-party/nsh/crates/nsh-core/src/canvas.rs": "89aca709ed1cf7f6e6f483bfe3b393bdc914bb1c676ac78905d8805a9a2b9b47",
     "third-party/nsh/crates/nsh-core/src/canvas/damage.rs": "5b7ec6c92ea78ccf55d691dffc9e9621454de07c773d8263f4ed3ff2b51a61e7",
     "third-party/nsh/crates/nsh-core/src/canvas/hit.rs": "2e3528e7f911e5d4ab735b19e8f79a54a037748598edc88dd9309195106b8e36",
     "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": "8a2648ad7d595e87d6e5b204b8308780c722517191b2177aa43a840861608048",
+    "third-party/nsh/crates/nsh-core/src/core/components.rs": "4e5ac80da03f6aeb3427f798ac55b0648703913f1193447fce3609be559d1a3d",
     "third-party/nsh/crates/nsh-core/src/core/layout_tree.rs": "d28e3c5657e308c9cc254529457a025b057735d743e529bcf5e11734b8b91109",
     "third-party/nsh/crates/nsh-core/src/core/list.rs": "614c677986d4c958dabc94173f2fbc6f960541945c85cee6be1591bd0d6cc129",
     "third-party/nsh/crates/nsh-core/src/core/measure.rs": "c6692abd3002576afef5b6d777d7694e28e4754bf7ada0adf8ed1c92419289c1",
-    "third-party/nsh/crates/nsh-core/src/core/mod.rs": "29c450c560f14b3b549f01b85a09758911d4842fad956ae2b46d56ef9c7f830d",
+    "third-party/nsh/crates/nsh-core/src/core/mod.rs": "ed2c4a529d7949dc0fbd7d52ba23c5a3903f920112327183e105f8c3a03a6fa2",
     "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": "627e809d5552629b0c4488ef05f80c66ac608586077594f8bae21226076fa6e7",
+    "third-party/nsh/crates/nsh-core/src/core/tests.rs": "0f0911700d416db714e65121cfdb854f34ea70406873f4e9213eabf52ca889b5",
     "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",
@@ -1249,9 +1256,11 @@
     "third-party/nsh/crates/nsh-core/src/geometry/units.rs": "8e061b96ca9860bfd97c4aca55d220736a757658d1f3a0a2b3cbfb8abc03b416",
     "third-party/nsh/crates/nsh-core/src/grid.rs": "4572921a4b6c13a59a64b6e0e6e3bb4f2049f060b6c12194e0fea6c38c5e553c",
     "third-party/nsh/crates/nsh-core/src/host_channel.rs": "bbe43f98dead381ad9b25c9eb4236135d6ba244ec03c072d68e059db4c7515ff",
-    "third-party/nsh/crates/nsh-core/src/image.rs": "0d753d1ac13158df61130c78538c3814614cbccae96a602416b9b4ff251ec5db",
+    "third-party/nsh/crates/nsh-core/src/image.rs": "941ba214aa42755a10aa92d975c39bacbe7a8d857529ab17a621714e5e73f34d",
+    "third-party/nsh/crates/nsh-core/src/image/cache.rs": "7db3a77091226235917176cf6320784862b451cafb3d791ee2f19225537af59e",
+    "third-party/nsh/crates/nsh-core/src/image/cache/tests.rs": "71d10a0bc2240f975e14cf1d2d2b2168a6cb3778622766cce14c8c38b60f4f8e",
     "third-party/nsh/crates/nsh-core/src/input.rs": "e70eb4865992604f43e5f5c2dd62194ee04a19392d17e00d302b93b37a577d12",
-    "third-party/nsh/crates/nsh-core/src/js/animation.rs": "e2bb991a9e414e81f1d4e9449b4c017d42a5a54f529f4713651e1c5badca32d8",
+    "third-party/nsh/crates/nsh-core/src/js/animation.rs": "6a4e82a2c3d1ee5bb3ff3cf5aaa9949effdded461d8553057357127c8fa4ff3a",
     "third-party/nsh/crates/nsh-core/src/js/canvas.rs": "348900df6c3d12e2061e09d7d7c1d50fc409cb1f42fe4da2e1400e89fab93679",
     "third-party/nsh/crates/nsh-core/src/js/host.rs": "79d8b9ffd655b32185ad9a2e0182ea91bdb07fae825b4e30eb6fb063912a46de",
     "third-party/nsh/crates/nsh-core/src/js/host_values.rs": "f8da8a7af5cf78c4e514357c7d15b5cec5d32b60dfdfd103a28a32bbc89dac09",
@@ -1261,11 +1270,11 @@
     "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": "826371d8465de044dae0cf0cd7fba4f0e6928e38e228089813d010c87eefeaa6",
-    "third-party/nsh/crates/nsh-core/src/lib.rs": "b9320ac1d3df80c36514aa99daec07a5a6355f21a8aef263425c38a61d893df9",
-    "third-party/nsh/crates/nsh-core/src/mount.rs": "e5a60998159441e81bfa61baa0fde757860d928ad5496cad73792b1c3b87ef3b",
+    "third-party/nsh/crates/nsh-core/src/lib.rs": "2260aea6a44a2c0fd2a676a8986663ebb28b3c50bab5ead6bf006e8d03e75364",
+    "third-party/nsh/crates/nsh-core/src/mount.rs": "bf52b6fa807d4ceda30fe97c9561b4e43ce5bd19679eedd98d8fbdbba9d8cca9",
     "third-party/nsh/crates/nsh-core/src/navigation.rs": "d79dd1d10a04157cbc5a383dc7b01aee820e6a33a16c682d3e43dc5d4909f5ea",
     "third-party/nsh/crates/nsh-core/src/open_url.rs": "2bab59a91a500b4710320995d6a74dabc4488f55a47f970ef08e14ff7ec096c7",
-    "third-party/nsh/crates/nsh-core/src/ops.rs": "aafe0cd6a9e7964a514ebc6b23c884252491b92b5363628f579f2f96016274c9",
+    "third-party/nsh/crates/nsh-core/src/ops.rs": "536fb159483fe63da53a27a8500d87c1c6b2d8b6eaade472d2b33c4ccc5addd5",
     "third-party/nsh/crates/nsh-core/src/overlay.rs": "e3029e8e8827a4365a4ac91162c77baa2a8e76fcbf22ded7c46cafac8cc33b13",
     "third-party/nsh/crates/nsh-core/src/picker.rs": "1067e7ed94f274a5ea7f0b768075e09a5c9dcebf19cb388b54beffb7e7ea53c9",
     "third-party/nsh/crates/nsh-core/src/platform.rs": "8a0a76a5d9d94d1a991a4a72b60b2bec90265d8dff3ba6b3e5b6240ad90472b5",
@@ -1313,8 +1322,8 @@
     "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": "bfe74f7f3d58e45c9f9c1e594cc5c33123659449d64902c637e6d775c45868a3",
-    "third-party/nsh/crates/nsh-core/src/ui/animated.rs": "b5e802d1f29181f663e375cbf6d6f09cb1706e4141ebf0b9289f25cc48579344",
+    "third-party/nsh/crates/nsh-core/src/ui/ambient.rs": "ec6d3ad017df6ce2b26b92a7121689e75ba94a5272041297b7a3a05ccadc0526",
+    "third-party/nsh/crates/nsh-core/src/ui/animated.rs": "d4c27d3a382cc2b238ead16e136a3913b283d1807c97e68ae8cbc919c7b9df4e",
     "third-party/nsh/crates/nsh-core/src/ui/asset.rs": "ecc4b40dbe52c239f69de438eab9444768bb5d7e4069aa15a51c3adc7477702e",
     "third-party/nsh/crates/nsh-core/src/ui/back.rs": "ef98d58af2314de48ec9fef54677a69d663a6fbd6fcbe90ba5f80f8c253bbce7",
     "third-party/nsh/crates/nsh-core/src/ui/call.rs": "28dca6e5f92875ed1d413d27fa83401aba3c759880a75866af964affca4d72d6",
@@ -1323,17 +1332,17 @@
     "third-party/nsh/crates/nsh-core/src/ui/component.rs": "8f19ed1b78d3eeb6434f4dd58748d937f5b45a9e583c316b1c979443b07c9358",
     "third-party/nsh/crates/nsh-core/src/ui/description.rs": "b45848d583fe72fe79efb18aac23c88aadb30c14e6a5b33558243eb0f81367a2",
     "third-party/nsh/crates/nsh-core/src/ui/desktop.rs": "13b68e6510e3e920b882e83617fef21380da29627007f0b8501200e904dc79d7",
-    "third-party/nsh/crates/nsh-core/src/ui/element.rs": "0caf17597339f476132700eb31fba3a992ab4e43818b83e51b10113801daa535",
+    "third-party/nsh/crates/nsh-core/src/ui/element.rs": "e59451d412c316fd4313c735d20bcde18eed07de6cf6f51660bc918312c856fc",
     "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": "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": "0cb8dbd0bbd222f900f65abbb7a7bb71772af5b73ede2f91557419792fb4215f",
+    "third-party/nsh/crates/nsh-core/src/ui/host.rs": "30f5089e6ec4342d6b8b5f6da1b8154754bf9a59d0f7d933097e2f6fbbab35ea",
     "third-party/nsh/crates/nsh-core/src/ui/js.rs": "6a24391d44d9ad77211788d5d1838c5ce34db247af92cca6d1adb93b90598f9a",
     "third-party/nsh/crates/nsh-core/src/ui/json_host.rs": "19bb29fd8e68537ca2166a3072082ef3de95afc0a7a99bd2070d98d9e8150772",
     "third-party/nsh/crates/nsh-core/src/ui/keys.rs": "cea9dfabc574c0000bf6ef45383b9f77052dbd700bd694b5ce1107a11e2cef0d",
-    "third-party/nsh/crates/nsh-core/src/ui/mod.rs": "c828bcbea949ae522b01d95ac30dd10cb30993be6fd2afb9588ec74fd15484c4",
+    "third-party/nsh/crates/nsh-core/src/ui/mod.rs": "46262257856c58af760af69bc6be9785adfd70ea8a60db7aeaf8a5615b2c3f86",
     "third-party/nsh/crates/nsh-core/src/ui/module.rs": "a2aba178d7f33e35ec9a2b40b6517701f83eee236bae1f6e184cff7ded1d05da",
     "third-party/nsh/crates/nsh-core/src/ui/once.rs": "43c3c781c2b6d2622a569825e35b09ce9075af9b2fd5bc974dce6b5f4d303345",
     "third-party/nsh/crates/nsh-core/src/ui/palette.rs": "56a631d4e5bb3ffe02241618a7f16f999049946017de61a29606c12ca6b1c325",
@@ -1355,7 +1364,7 @@
     "third-party/nsh/crates/nsh-core/src/ui/virtual_list.rs": "2fbc3d0d04f66d71438959f57f54ac23647b5df5cdbf38b810c0368e3cb4a1e1",
     "third-party/nsh/crates/nsh-core/src/websocket.rs": "8438f7eb89366e83af5fb5795ae69010b0c1a9714531ebdade6284e4a5c146a8",
     "third-party/nsh/crates/nsh-core/tests/attachment_compat.rs": "4bf699c142cfa3940a1a9845c644097a642e8b563cf33a18e447c6f200a09dd8",
-    "third-party/nsh/crates/nsh-core/unitless.txt": "c564f7d6660ef8cd5d4047a43269d2dac4548c70b575708fda54b97d19dfaad9",
+    "third-party/nsh/crates/nsh-core/unitless.txt": "b23a2a6a3087019d40303c297a72b0b930fbf82248511bfee09346541d1693b4",
     "third-party/nsh/crates/nsh-core/untyped.txt": "2dbe1d080c08c33debbf4d4c27ae0a08996efa87dd072494b7d0ce712109d2d1",
     "third-party/nsh/crates/nsh-dev/Cargo.toml": "f6f8b7604f4e2bc8212779f46c74f568247f8d9f853eaef4417418ea8a6f4ab2",
     "third-party/nsh/crates/nsh-dev/src/client.rs": "541352e633968e5ff9dc3fa6d5f3ace8fef5cfb42465d623a2d19331b71d1edb",
@@ -1367,81 +1376,82 @@
     "third-party/nsh/crates/nsh-engine/Cargo.toml": "9a8d4579a6a19b201f1de8493ceeff18fe4a4d32161911366957380600db4279",
     "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/cache.rs": "f18f8a5658d02c55a8aa528302292ac39ee3192d372615a65f87fc300c3b4148",
     "third-party/nsh/crates/nsh-engine/src/error.rs": "54bce4d455c05a1c37b3e0ce8e46271492369855482283ffae3851605b44bc51",
     "third-party/nsh/crates/nsh-engine/src/host_channel.rs": "54f17128331fbbc10999074db8044f9b4945bb0b4199192f9a4213b0c104840d",
-    "third-party/nsh/crates/nsh-engine/src/lib.rs": "35822e7178ec410ee956b6747b89845236da6a9d73aa25ed6311add01516dc5d",
+    "third-party/nsh/crates/nsh-engine/src/lib.rs": "06d6cc2f2f19d166d738cc8d1f641e4d9a63b1789bee3fb9ed1922afcf925b78",
     "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",
     "third-party/nsh/crates/nsh-engine/src/script.rs": "2bb22b41d3dbb47996bd42a8b2ae2da75d46e09a5f47399145f64c9c68eb6af6",
-    "third-party/nsh/crates/nsh-engine/src/storage.rs": "b18cfe10046ef5e3486a3ba1840578862195968e06ed842b7c1f285f610e8221",
+    "third-party/nsh/crates/nsh-engine/src/storage.rs": "9c5935156c2da64545503929115253fac2e7db65466749c443851b2bc85c1a59",
     "third-party/nsh/crates/nsh-engine/src/symbolicate.rs": "974a80ff547e60e682ce2691e83346f02123c549b5f92899e84dc41b29fdd094",
-    "third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs": "ea7d74e9a2d7f51d92d8e3135651cf1cd87ad7832cc2b905e9b4dead0ea285e8",
+    "third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs": "241608186de2b82db534cf27da3f3b154e31681afbdd9f61357dc4beadc67f5c",
     "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": "36a55ab32903d71d5f7514c63e50ec0bbe9e83183e059c7686d6ecaaeee86e7b",
+    "third-party/nsh/crates/nsh-engine/tests/engine/compiled.rs": "b402e33efae877fb40e607141d17ffeda3cc580ee38c82c74f399d8311bd0661",
     "third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs": "18b53b2ebb2459d48f799c7b1a1850527f97381fbd97f05748d347db945293f2",
-    "third-party/nsh/crates/nsh-engine/tests/engine/components.rs": "96ada16b227ad0d92734b5ab7c2f1ae0eede7eb00ee3cfcb8fef3073b2f73abb",
-    "third-party/nsh/crates/nsh-engine/tests/engine/counter.rs": "e452c2356450c4272e1750aec5cb2670c01752182431e5aac23c9f818a6f0321",
-    "third-party/nsh/crates/nsh-engine/tests/engine/errors.rs": "9799761e38293f18e93861d7336a657e53aeb77e9f368d55a98ee71fcc856c48",
+    "third-party/nsh/crates/nsh-engine/tests/engine/components.rs": "b86ec2025d42af2da9ec6efa5979119abfe3438b134a80bff527144761c041bb",
+    "third-party/nsh/crates/nsh-engine/tests/engine/counter.rs": "25e9484d0e2f7b9f10a1c03807a4d7548f0179433c2ba4f7245917ceb66f0e8e",
+    "third-party/nsh/crates/nsh-engine/tests/engine/errors.rs": "f7da9e49c11ed13f641a5defbd37931242ae750c2127812b3174140012f730e8",
     "third-party/nsh/crates/nsh-engine/tests/engine/external_wake.rs": "4c114be3b7489e607e9826c8264bbf9bdd161479fbda8f598da6595661ab09ff",
-    "third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs": "9228b8d4e8f34672c2330b82d9559d0d89a76b43e522d645867624fc49aec27d",
-    "third-party/nsh/crates/nsh-engine/tests/engine/fs.rs": "b09a476766f9c5a3cd10ab34e35b860ff7c0c0abc95d0a4f60f2c0f30c66fb5b",
+    "third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs": "98a5d73f422cbadec312b74c94e207e05efd2a96538abe01af6463fabfb04be6",
+    "third-party/nsh/crates/nsh-engine/tests/engine/fs.rs": "5f8d70ce41e2f3c5ef4077a879eeac0fb1a496e721b7476d0cfe12a54943cc93",
     "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_channel.rs": "bcba827247aaf45337a88cff4feb158f72ae018cac80a76ce5c6748c07847510",
-    "third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs": "ce08b9b6bb0fc0b15a6b44a84fa9d2a906eb20488d820eeeb80ced68ca1dc633",
-    "third-party/nsh/crates/nsh-engine/tests/engine/hot.rs": "e6170f939c0c08fafc562710929225c5a24c0b373f6175a735c10e118e599f31",
-    "third-party/nsh/crates/nsh-engine/tests/engine/hover.rs": "9db1f2e432ce20cd14a360dc787ffce9eab8094cef913191397b09720be827da",
+    "third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs": "79dde05603998f09ef791b2068f69b09790ff6a14ef9d55bfb3ef0285545a17b",
+    "third-party/nsh/crates/nsh-engine/tests/engine/hot.rs": "660a75159bc72b72c17233028f7456d25809cc6b18f95f5436df4ac31897412d",
+    "third-party/nsh/crates/nsh-engine/tests/engine/hover.rs": "735233ab867cb2f6429d15e5117bfd4446c3fa1c3536d8f14ac4066bf78edeb0",
     "third-party/nsh/crates/nsh-engine/tests/engine/images.rs": "775dad08ac5ca247b8cb5388e40c9a70b3583636bcc1bf8bc4d09551aacad372",
-    "third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs": "8dc05d187f0e49172b2e8efcf4b4ceb0fecac7132707a0f190c31117a076956e",
-    "third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs": "6f472fae26f615c15f8186450e3b562efafba719cd1c85ad023a9ae83f82f506",
-    "third-party/nsh/crates/nsh-engine/tests/engine/list.rs": "686102d6ac7db16ba43ddb5cf46113df77240dafdbb9d7b56651f00a098c28e3",
+    "third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs": "db95310bcd5bfc6b5b6300d1dad56565c44c123308b733731867f0dfbed2432f",
+    "third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs": "c44cb05bf0a861cf707f6746d58aa3ca8f64183b231dccbd51950bbb20816bac",
+    "third-party/nsh/crates/nsh-engine/tests/engine/list.rs": "8a24dd659ea0604472303348c57e7368560ca50f63faa6647c0f73d8c91dd4c8",
     "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": "59e5a7ac4cb018bb9047cacbd9ccd71fab4165ebe700caf0c23119f326c7133b",
-    "third-party/nsh/crates/nsh-engine/tests/engine/native.rs": "a7bfd10d7fc36685f3c61beac9697121d054086dba5ffdc52d013efc32dc4c08",
-    "third-party/nsh/crates/nsh-engine/tests/engine/net.rs": "7c784fbb30fd4717be28784fd23c1f733979b4bf0d70776f3e9ad07c30335b38",
+    "third-party/nsh/crates/nsh-engine/tests/engine/native.rs": "76d0189959719a0bf7c4c70022a7e875556946b89cd38cdbf3d22cf4219f0291",
+    "third-party/nsh/crates/nsh-engine/tests/engine/net.rs": "c0fd525857092155df85c16647daecdc2b4d6a81224322e821c08f007efd277a",
     "third-party/nsh/crates/nsh-engine/tests/engine/open_url.rs": "9916e9ca24e19126d7c1aa3ad97276025913e333e731c8f182c4b32705cfd65a",
-    "third-party/nsh/crates/nsh-engine/tests/engine/package.rs": "d78c651b57becb078cf0d3c69c6369871db2d252f6a07f91d2e57cd5bbb81c42",
-    "third-party/nsh/crates/nsh-engine/tests/engine/parity.rs": "344bf199bc6dd968e8833ccc5a9cf8b8fbd02672f0376fce04070d05d9965908",
-    "third-party/nsh/crates/nsh-engine/tests/engine/platform.rs": "a4ece1d261fdd610315f1b428028e49db8f717980b0b16a600544d9c4036bdb3",
-    "third-party/nsh/crates/nsh-engine/tests/engine/process.rs": "f14c53d2bf691ad9d355db2fe31349fde393f44b07c561894c827860d965f1c6",
-    "third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs": "b6102c8497819a28dbb44530252f6fede4941f23b7dfb3eb686f1723c3913dc0",
-    "third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs": "cf5949b20f9b22ebd59fbeb577851c1eb92bfb5c1bc0a74e54222ab8a01f88b1",
-    "third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs": "5282d5c185861ed5d6fe845e3abb30afea38f09ddd05294f06a3df2ae6abab40",
+    "third-party/nsh/crates/nsh-engine/tests/engine/package.rs": "59056cd5f7077aad42db3cadf33c2fd972037b2581bf292234be285adea54358",
+    "third-party/nsh/crates/nsh-engine/tests/engine/parity.rs": "d0a7b3e1079001be5a80ce82915d7905298fc28d3bb6319a32b57763d8eda47d",
+    "third-party/nsh/crates/nsh-engine/tests/engine/platform.rs": "e05f2706a1f5a4f4f730dd83c80453a3bf6067f6ad5ab875b5e74b592e337428",
+    "third-party/nsh/crates/nsh-engine/tests/engine/process.rs": "0f4b844084f1f7d20e53a034735d2ca6914ba19bca3dd93821aca662f6dbbcaf",
+    "third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs": "07b8a897d9ea789bbb3ec4f53f87ccef35a70e70f7684e8999ee524ed2bcbc29",
+    "third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs": "79ca1cf332aa782c5dcb416c99837c92af3294b99fb6345af279722b30305a5f",
+    "third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs": "9952a2e188da29019ad81baba278081f4511486309abb438024bb45c22007f9f",
     "third-party/nsh/crates/nsh-engine/tests/engine/rust_inputs.rs": "db48fb968ddf364ab84746f8b5eeee339138c2806c9f285f5ba0e19c487dcab2",
-    "third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs": "6d27782b4a86c7072b6a58c3fc3638add12a7deafde7d8f0294e346c376c44f8",
+    "third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs": "7d225317f978177e372dc8c6880d11b30c34a025f13d1b6a10226de0efb04c00",
     "third-party/nsh/crates/nsh-engine/tests/engine/rust_media.rs": "6ebb80a10ce2fcc42408af7bfb7de2500553be7e76489f1f7f2adb23a275d883",
-    "third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs": "8b68586fd60d21c837b71a2c618a6d121ad7e71581956d71e21c682bd931c3f0",
-    "third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs": "dd7964b981a7ea2630cbfc2825bcb0d3ad9565255b63ec77f08272f302310f62",
+    "third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs": "4c87932b33dba77d9f4e20a5fa84722ebab29da9ecff4f5db22d3264c5753c6c",
+    "third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs": "5b62581a6dbf82824fabcc3f4973895b8d5778a5b7d2026e93ea07f63d62816c",
     "third-party/nsh/crates/nsh-engine/tests/engine/rust_renderers.rs": "3e274abc6222c2cb1a57939ace396713edbf234d30e7d01983fd8550d2830807",
     "third-party/nsh/crates/nsh-engine/tests/engine/rust_scrolling.rs": "10323264aaea9ebd2cf9750daf468cc044e9dc57f46ad43db315deab9c2c406d",
-    "third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs": "87ece88b1ed7f9d09a24777947126d62ed06aa15df42a8c0978fb5776ebf282e",
-    "third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs": "dec980377fa2f0fca3246315108a2feba7b7025b14b2f8b34b4643d6f6c33e36",
-    "third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs": "4ac8b9b22db6f3436b58164da26c7b6f8e16fb5b14e11602117196f57fe4e155",
-    "third-party/nsh/crates/nsh-engine/tests/engine/split.rs": "298922689d2ffae122c63351d7bdffe920c69a2e67d7563b654ae13f81d686e8",
-    "third-party/nsh/crates/nsh-engine/tests/engine/storage.rs": "34bd0e21576a9269ee03bd053f60887e460f5f31220f133ae80eff3c3a952a10",
-    "third-party/nsh/crates/nsh-engine/tests/engine/system.rs": "f8b59d824689fca46f5279ad6426238dbda0ff91bcc3b7b211eef4d3b62f895a",
+    "third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs": "20dfcd65d3f8a3580c36d909908a4f5174aa3b45c853f6743c4f2b77ef825de4",
+    "third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs": "2e31eef3947b8db24f02a33d73dcf0bc831579e42324759f9e91d1dc92d27402",
+    "third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs": "51c8dde674cab5be90c2977b951f80e77c02b7adebbb5c9d26d3c839b465cc16",
+    "third-party/nsh/crates/nsh-engine/tests/engine/split.rs": "a1eb26fbee1a736f1fdd7a454b1ff44dba25c4e525c40d3268d8053027b18089",
+    "third-party/nsh/crates/nsh-engine/tests/engine/storage.rs": "17e5b0504b861a9c3027bce7634ed7e616fc889f9f8ff5504700fa187c291709",
+    "third-party/nsh/crates/nsh-engine/tests/engine/system.rs": "0115bbc12a55cb7eba170bd17ed85685fc8120a5aa1e228c11098456d66e5bbd",
     "third-party/nsh/crates/nsh-engine/tests/engine/test_ui.rs": "e5e6f8b9acd4124668deec4fc8860ab1849c1f3be82c1244330ace6e4599b5fb",
-    "third-party/nsh/crates/nsh-engine/tests/engine/theme.rs": "f9b7b01feb24baa86a9938ed462c1430e9260e9ad78259a2af1cafc3d62b1dcf",
-    "third-party/nsh/crates/nsh-engine/tests/engine/websocket.rs": "bdce4b4e55d39035846ab8c8db69e142f8b03e3ba426a5d4c394598db4904180",
+    "third-party/nsh/crates/nsh-engine/tests/engine/theme.rs": "ee2b8abf10519ec5030067f3b10dacebb7d04980884f3d9826a690c0010e66c4",
+    "third-party/nsh/crates/nsh-engine/tests/engine/websocket.rs": "9d89fac073b55ad167f72f8456a5457c3140596a2203402672b4c9814c483530",
     "third-party/nsh/crates/nsh-engine/unitless.txt": "608551ec4de1da091099bad8e64d961a352681da134c4af9b58b9a29a9e43018",
     "third-party/nsh/crates/nsh-engine/untyped.txt": "0e94c66fb47844aa29a7b5deab131001f3ab89ea46e79c1d75679bf618788c10",
     "third-party/nsh/crates/nsh-ios-app/Cargo.toml": "c1e3ad9e6398953e8fa7df9996304913906fee198d5f9a6574d8c0a01623fc54",
     "third-party/nsh/crates/nsh-ios-app/src/lib.rs": "d66a265a1c697cade9cc1ad6363b7f8531e8d961eb1c7ef4c6336a1d9219a1eb",
     "third-party/nsh/crates/nsh-ios-app/src/main.rs": "73e2a7485cf689656d70603ccb31981d01c419a84ba4ae09698567c9e03a29f2",
     "third-party/nsh/crates/nsh-ios-host/Cargo.toml": "c88a580e48ab783aa69177f821243ac159be6e709331b2d30cd9095a3449ed16",
-    "third-party/nsh/crates/nsh-ios-host/src/lib.rs": "63593399008033d85ab4de3030e104f93785edfbe33983db526b874d1e03e489",
+    "third-party/nsh/crates/nsh-ios-host/src/lib.rs": "92542d4a671cf558b5c66e87ee6a25e7a78907e8d0b577553fa787d5dfc28261",
     "third-party/nsh/crates/nsh-ios-host/src/main.rs": "5fea860f710b7bd5e515eed8b29a8c14a9280670afdf999f16a31ed03857d9fa",
     "third-party/nsh/crates/nsh-js/Cargo.toml": "0629dcd21dc444ce8081c9bd8be3451d04957bc69f6eb22a35577cdc4c10da37",
-    "third-party/nsh/crates/nsh-js/src/animated.rs": "c5a039fb4c72825bb44664eb94ca4d765a46c29e1f90767d59a3e25007bb85a9",
+    "third-party/nsh/crates/nsh-js/src/animated.rs": "8644582b5576d8065daaa5b01ebaf43e38cffdb1862d42f2a3fa81ce36e81cce",
     "third-party/nsh/crates/nsh-js/src/bytecode.rs": "59721147154608fb29fc583adcc9f6b8c3c1c9bf0cf1a0499d89173f36f6ebf9",
     "third-party/nsh/crates/nsh-js/src/canvas.rs": "64582fc7c63bcc552b1a46341f41a9b2975fa62d0f76942fb873497ff4d513e8",
     "third-party/nsh/crates/nsh-js/src/chunks.rs": "b3f7aa20c22eac8515ab0fff427e70725334beed9d4deb4361ae154da316e89f",
     "third-party/nsh/crates/nsh-js/src/classes.rs": "47efb43317816c817f7be0bb07a17f949457eea1c75b00a83dd8c56d6bd4f72a",
     "third-party/nsh/crates/nsh-js/src/clock.rs": "06c083117a5eaa2d2deddcb41e52ab9e1a9f48efa72d6e9a0f27135bd237b161",
-    "third-party/nsh/crates/nsh-js/src/components.rs": "3cd5438936ad8521bd78e83698c74c8ae228019959de336956dbee61ea7e378f",
+    "third-party/nsh/crates/nsh-js/src/components.rs": "587af76b9246cd557d2128230db7afe6fd3d3d3d508d2dd0d7d1441ee9bd2c5d",
     "third-party/nsh/crates/nsh-js/src/console.rs": "2bb26f43b5326680cbaf9ab1fcc58fa0ab049c349feff6dafd39c99d4240f6a3",
     "third-party/nsh/crates/nsh-js/src/declarations.rs": "6bd9b8e7a7f52d76bb2da76c6f5ea63f5d8ef5820d2a2fd494001346ab7a2f93",
     "third-party/nsh/crates/nsh-js/src/desktop.rs": "bb5d9d831761b706ebc231009f089db9408a7584d2fdcada3935860ec8215d3a",
@@ -1459,7 +1469,7 @@
     "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": "52500c1f61cc4850c83ec80369075544074eb0d2b974fe0d5ac58a30003b1bb1",
-    "third-party/nsh/crates/nsh-js/src/protocol.rs": "058fa87197c16aa0bbc76345244eddef44f0fb7a8c669ff974486889648ac4dd",
+    "third-party/nsh/crates/nsh-js/src/protocol.rs": "2213a02257947f679de026396cf577a55a6cbcd188519c871b360b0afbb71275",
     "third-party/nsh/crates/nsh-js/src/reactive.rs": "36773de3b3e8e1368cb9960fe3925d7d15e746dc85df03c659c55527d7683e67",
     "third-party/nsh/crates/nsh-js/src/rejections.rs": "7873bcd5daa9b755951b0ded491e72ba9c9a82f77526dfc41df829a998fda7da",
     "third-party/nsh/crates/nsh-js/src/resource.rs": "69be11ea4bb8d06d9d0f3a7b4bc74e80d67d3efe62772276613455510f9b30d6",
@@ -1528,13 +1538,13 @@
     "third-party/nsh/crates/nsh-linux-app/src/lib.rs": "5dcf92576d3b3bcf653f8f5872f780cda7305d84b3e90f0cbc6f2992b15086b0",
     "third-party/nsh/crates/nsh-linux-app/src/main.rs": "bfa789f9a165831066b8d48dadea6d228cc733717b939d4d632ceccebc4a863e",
     "third-party/nsh/crates/nsh-linux-host/Cargo.toml": "3005ebfa91edf228adfb8f1b3eb02d91afbb1a44e45108c6fb1e157c48091846",
-    "third-party/nsh/crates/nsh-linux-host/src/lib.rs": "03bc4869f2770f7c3c6bfe4a66009d4e9228d48bf3b4a2c376c9c458d41dc345",
+    "third-party/nsh/crates/nsh-linux-host/src/lib.rs": "c6b14d325129b3bcb24964ae1f421fd7180c4f1afebc14e6941b5def3d1fd079",
     "third-party/nsh/crates/nsh-linux-host/src/main.rs": "e3697feb14547186e5962b50df09d53017b8f7d5ec3294a254033919d1592b98",
     "third-party/nsh/crates/nsh-macos-app/Cargo.toml": "47a7be9e9438f3bc4f4273fd1c6cf09def939c3f24b4fc4ea7d14e488ae54aad",
     "third-party/nsh/crates/nsh-macos-app/src/lib.rs": "8dd3ca8157841648860d0a827dc3bae0cb8b739d403fd12eafe12f3e258b9923",
     "third-party/nsh/crates/nsh-macos-app/src/main.rs": "e28b5f9cf85dab098348a037eb38055091eb8c1a29aebb922e1461e661a07807",
     "third-party/nsh/crates/nsh-macos-host/Cargo.toml": "613ba75342e264f10b6f931da22ca6092b9ce41e85b26c7e6f9219318ca65923",
-    "third-party/nsh/crates/nsh-macos-host/src/lib.rs": "fcfe91af65f5cbb2afb16e47b7545fbff01bf05b7bfa933e93aa849a9b4a5430",
+    "third-party/nsh/crates/nsh-macos-host/src/lib.rs": "0ed0d239d5d78fa3a512f2678e7732688607e29e02298a4b3e2e1929edc6c947",
     "third-party/nsh/crates/nsh-macos-host/src/main.rs": "81be01e39606c6ffed203ce44e4e5fc2f8a19f320736109b394894272b204363",
     "third-party/nsh/crates/nsh-macros/Cargo.toml": "a61e435297cb0a1f9948564f43989fed0d04cb1909e73914f6381508a17e3551",
     "third-party/nsh/crates/nsh-macros/src/assets.rs": "3bdbea71df2971a110619ae01340b665f078a6595157f16517d3911b6a2ab5e8",
@@ -1550,6 +1560,7 @@
     "third-party/nsh/crates/nsh-package/src/trust.rs": "47257a1d9b5d81a77eaa2093f1abb0cfe9c54df2e26f557002326b386047a355",
     "third-party/nsh/crates/nsh-package/tests/trust.rs": "48097e716736cb2726e36c1d6913953bdd6a9b6613f39f061e352f3263423356",
     "third-party/nsh/crates/nsh-platform-android/Cargo.toml": "13c8aabee1f34bae9dba9ca80c6fb7602547c8b7f685d279729129258a2654fd",
+    "third-party/nsh/crates/nsh-platform-android/build.rs": "0a4329aeb34f85d71ecf149c3b7f6e68c01d9e58186ac73b9bc5f989c3f767d1",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/A11y.java": "9889c8d013a46caf26a2156d226dfcb50d6aa65bd5e7a5386e460be7dd6eb982",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/AppActivity.java": "e017ccf0f5ffd3c781239be6b61161231c22db761dcf8bc9dba833f4f19bc0f8",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/BackCallbacks.java": "ec97d83a492e6c191e28bf890624b291e835ebbdfaf1f5c6ac071e5c56363384",
@@ -1559,23 +1570,24 @@
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/CanvasBox.java": "82764b294511a7cea7248414202ba12688439430a645f8a74c5486057b84865e",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ComponentBox.java": "f733afa5c86998cb40f26a9f2b7e96a2edd2b4c4235092fdacad71f716ef3515",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Decor.java": "289ac6a3ea0ab8bb906959d6c602e5ed22209f6098c70498deaa3dd41febbcf4",
-    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/DevActivity.java": "cd05c7f60dca93f8a18d9f1f92276662822aa3cd351246b773f301b7171007d2",
+    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/DevActivity.java": "7f105b937fd29a68e52b8a3bd7f44404aed7958b5ad63ab6e28a3f640c01b14b",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Effects.java": "cfbfe53cbef6c9feaa71d208b93dc350343fad127da26921b312f8fdb0fbd9b2",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ImageBox.java": "65f575393de7ed59327eb9d1b516972451f74167346d69475924fb6561548f71",
-    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Images.java": "db1260cb373f03c467d6f6d6ebf27a30530f6efa7525dc01c1c375e1db4d91c9",
-    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/InputBox.java": "9f2dd15c42b727e5b2fe5da72b12b822a94e42f5acd60912e8bba36aedc5aa5c",
+    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Images.java": "c5ad4e2e34b4a20a3080724a978d8c0304dc012f70aaa9880a1c36f430035834",
+    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/InputBox.java": "d115dc4dcb4acecac59efcd2b055475501adeaab5c604bb6d2c2f026c68bccde",
     "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": "30c60b6ddc4aacb3ab17892e6b8dda29ba342382a94c34aea96fe73eb14176b1",
-    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "ea65bddadf6b5162b4a111e89bedd8837f76efe2514eaa30b8d9b5cb0b9311d7",
+    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java": "c898debcdbc03de5d279cb8e3ecf34c0f4a73aae6ecb50caa570c0f91c097f65",
+    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "6679b9bde342d0d8bd0e0b57bdf519b9ce2e188f72d5bf140c19f7a0dbeb08f3",
     "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/NshCacheOptions.java": "a374e1c32c99856495535e28acecdc2c3030a3b7e6686416c07fda11b7b25146",
     "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/NshHostReply.java": "ed49a5422537c291f19c9fd65aa0a18523cc5939f7158c7c3e08d0fed6886bcb",
     "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": "dea29f3e53ef1f8778a75e867179679209b6fc073e78bfce16f48bbbc15f1dbe",
+    "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": "ec51255b90119d0096d4ba5feee90d7aa4a0f3c4a2c1b85f295833ef8ca0afae",
     "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Picker.java": "e327b5fa67b14a354bb9eb09188071f3cfbf03cbbbecd6f289155566f1b403b4",
     "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",
@@ -1594,7 +1606,7 @@
     "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": "75cde482016380b41b0b4c8ad737977129a1e9f90c15507cb4a8d03702c72f71",
-    "third-party/nsh/crates/nsh-platform-android/src/host.rs": "7a1a52a3baef87f6e8b53e6e7991a013136ffdda42294831610571f231306738",
+    "third-party/nsh/crates/nsh-platform-android/src/host.rs": "2346f586480d8945ff3e3bced8f543092aed839c657f86423fd45c3c568aa5b6",
     "third-party/nsh/crates/nsh-platform-android/src/java.rs": "9da52588bf11bc52f92258dd0dee7d35513cf05c3ea5a2689a4a8bbc4f65bb70",
     "third-party/nsh/crates/nsh-platform-android/src/keymap.rs": "7710df8e27d117d3902ab7788a425406cc6b2b2226f3a42abbc60f16239b4f6d",
     "third-party/nsh/crates/nsh-platform-android/src/lib.rs": "10282c14a4d56f60291ae8bb6c07593275592ad7ce034b63ec91c96d5d41660c",
@@ -1606,29 +1618,29 @@
     "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": "bb1aa1e972c15c3bd875273cf737e1d556ddab5fc36e865c442dc51fac99151a",
+    "third-party/nsh/crates/nsh-platform-ios/include/nsh.h": "a5af071b5c4bf05c1f2dea567a9c1ca46e99e5b1e49deecee835d4ca43003fa3",
     "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/app.rs": "557d3cc805fbe60cfe02084d9828f4da0ebc2ae15f462f8ab2a7e9d14391e9ca",
     "third-party/nsh/crates/nsh-platform-ios/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1",
     "third-party/nsh/crates/nsh-platform-ios/src/canvas.rs": "ec7d01bc7ad24798e382b092d41423f45ceb1a09f509d97db492e4d4d1e64c5a",
     "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": "76732896213aaa409cd3d2f8e74fb73615cdede1410fc1fc022425c9de3ac1bf",
-    "third-party/nsh/crates/nsh-platform-ios/src/image.rs": "9be4bfd0cb641c5a27bb2a30064f3cb69e053f3a8f8a8df54e46067b515df674",
+    "third-party/nsh/crates/nsh-platform-ios/src/embed.rs": "6e424aba3063b593556104f53b8e2e2718155b1acc27ada0a161d46002769090",
+    "third-party/nsh/crates/nsh-platform-ios/src/image.rs": "220ba49e6ae7284572e28679deaf841a54cb072228c682c10d62d73f8f557368",
     "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/keymap.rs": "578fb8e85781ffa08f53ff1aaf65baa1ea80a08dc6892f4d6f180d649aaf54d3",
-    "third-party/nsh/crates/nsh-platform-ios/src/lib.rs": "1f5125ad6ca844997606bb77a43780fc3315b2a20c0bf599d6c117be8d3d5477",
-    "third-party/nsh/crates/nsh-platform-ios/src/mount.rs": "f87600fbd1d2aecc165e1653e21de3c8c60e762edde658160358650595364d78",
+    "third-party/nsh/crates/nsh-platform-ios/src/lib.rs": "423ff7cedfa82aba32e64e03fcf00fdd5709ba559ebe8624840a769d7f887bfc",
+    "third-party/nsh/crates/nsh-platform-ios/src/mount.rs": "d13ecdc6b0c0f10637513aa968bebd2e807cd7d8ca065a5141563a5339d84d3f",
     "third-party/nsh/crates/nsh-platform-ios/src/native_view.rs": "e73867cf00a330e184a6be6830d7b25894db62c1149e85fbe97b316f374831e6",
-    "third-party/nsh/crates/nsh-platform-ios/src/navigation.rs": "0104e3e81db055012b6b756be1dcf704bb281d287d4ec97552943ed2bdd50c98",
+    "third-party/nsh/crates/nsh-platform-ios/src/navigation.rs": "c678117f2057de8bb3039d6891bcddba2eccfc32652c66591fa0743c4ffd648a",
     "third-party/nsh/crates/nsh-platform-ios/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760",
     "third-party/nsh/crates/nsh-platform-ios/src/picker.rs": "a4c80e7301cd053571ca90318b29d3b6c0defac8520c252f97030d6a5a210eda",
     "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": "86380e91d47890484ab669b5f8e104627415053128ae51ed2813f458b1333814",
+    "third-party/nsh/crates/nsh-platform-ios/src/surface.rs": "b9e251a38ace16df17ee78157aa23cc0f38ebe513f1bc50c7ddeb5bbc2052cb8",
     "third-party/nsh/crates/nsh-platform-ios/src/synth.rs": "5ed5e9e78a632820a05ae9114d275283a5b7bccb782d1b1f80031ca013c69a52",
     "third-party/nsh/crates/nsh-platform-ios/src/system.rs": "9628fc69b1b68468a71cd3cbfc96ab8f85fe355c7c5e5b76a45a41124d3cf8ef",
     "third-party/nsh/crates/nsh-platform-ios/src/target.rs": "6dae6b295faeb46f663b167c0b45c70dcd418c4e2013a9a3058cc6a602fa8c59",
@@ -1637,32 +1649,32 @@
     "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": "799decbcd4fd456706aae937cbf77fbcf2e1a6ba86cdf2b88f40bb47c2d3653c",
-    "third-party/nsh/crates/nsh-platform-ios/src/view.rs": "c6538a6128d44ccdfaacdb7437767ebd839de6c84cac60edba9fbdb87f6c662a",
+    "third-party/nsh/crates/nsh-platform-ios/src/view.rs": "cab49bf66209306b4b3a8c9111283f816463fdf151eeb0015de220066a220bc5",
     "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": "df6933f0e134f542d313359317db020f386194ff72659aa3c6c3476cb1fe7053",
+    "third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift": "9985c4dc6fd0e31514567d7f257dd1498f7dc6c69c089e0920cedc135d325cc8",
     "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": "37f6ecdeef1af1f5cb6aed21967cdfa2c3f4e2b88b999a02fad8c23a487eea77",
+    "third-party/nsh/crates/nsh-platform-linux/src/app.rs": "fff347f494bdb348548345fc6231a595c2271cab0656ae12e37cfcc5d42266a5",
     "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",
     "third-party/nsh/crates/nsh-platform-linux/src/dev.rs": "2fdda144529216c24ac96b3c9357c03dc8802315dd4e198d59458ff571d074be",
     "third-party/nsh/crates/nsh-platform-linux/src/file_drop.rs": "b42601b46176f5669390b3155bab51ec3433636d5c687d21dab47422bbd2d56d",
-    "third-party/nsh/crates/nsh-platform-linux/src/image.rs": "c76d28c097503213c69573184a6c9e9beff7f7b377232e8020d89370d780f3fe",
+    "third-party/nsh/crates/nsh-platform-linux/src/image.rs": "6bb6492b86c528caeaa07d2e6123e7c5a07fb8bfde85f4db5a278bfa87ce675e",
     "third-party/nsh/crates/nsh-platform-linux/src/input.rs": "0f4a1404a3ab32f3a993b9d77a517159c6412754495868c8eda1130c36ff83d4",
     "third-party/nsh/crates/nsh-platform-linux/src/keymap.rs": "f3719f92fd9c9241fd2e327fdc90cc4de0bba59ae2c26e6b77f451fa2b913b95",
-    "third-party/nsh/crates/nsh-platform-linux/src/lib.rs": "1aaa32827f67a6972a7ca427e5b786fbeca3be2334150ed64f16b32dbc04d73c",
+    "third-party/nsh/crates/nsh-platform-linux/src/lib.rs": "d4909ea40e6eb8d6641351bb3b96fb7727003aeffa16eda444dbd17e56bb8b07",
     "third-party/nsh/crates/nsh-platform-linux/src/mount.rs": "e710109b19b0dcf28f538a6e6ccc78606f237e0eb1519e32757b06adb387dc6a",
     "third-party/nsh/crates/nsh-platform-linux/src/native.rs": "64d57fc40d20b47e70c2b210771a1b3067a51d3985c362b0678a64ab724f65c2",
     "third-party/nsh/crates/nsh-platform-linux/src/native/web.rs": "0a2002679796e48aaf90ffb0d562e190c98bb015dbc62b9199017e98e77c33b7",
     "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": "f2087a9d1df07a1f5a94a49037fa0acd3409a873c334b66a858cff67161a617c",
+    "third-party/nsh/crates/nsh-platform-linux/src/surface.rs": "9560d6e1f25ccf183c531bad73d481b7948892bfe2b336e279d7fbfac34c46cc",
     "third-party/nsh/crates/nsh-platform-linux/src/synth.rs": "ca932e93904d5b3b734a874be5337c2f6eeeea72c35e4c5bf57c04d864c30df2",
     "third-party/nsh/crates/nsh-platform-linux/src/system.rs": "32818a3182969aa7155f558194c7d092cc4bdd77f383a019676c1f0a3732d5d8",
     "third-party/nsh/crates/nsh-platform-linux/src/text.rs": "87d1b461b411da98a59e120e13290cc41d665bbd9c7eeb65d62db58c81fcc6ff",
@@ -1673,7 +1685,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": "36d87c474433475c8151b6852621908f89fcf3ab0cb7d83cf3b71c1f1f350e1f",
+    "third-party/nsh/crates/nsh-platform-macos/src/app.rs": "9e749a007b70419b317c5151ebab4fc085f04b48bc6dbaa750f02e4915e611c5",
     "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",
@@ -1682,10 +1694,10 @@
     "third-party/nsh/crates/nsh-platform-macos/src/file_drop.rs": "8cba8f09ee79b5902baa8c1b8b1a7f24d48604ba1eee477421656bffdfd65834",
     "third-party/nsh/crates/nsh-platform-macos/src/fps.rs": "4fd29f7f2b855d942e65725afb9ecbc5cc4748c133467ab75af02442d59ef914",
     "third-party/nsh/crates/nsh-platform-macos/src/gpu.rs": "46e103515db651c57316c351d7345dbc812f513c28d5d0e0489aaf2d8c59d599",
-    "third-party/nsh/crates/nsh-platform-macos/src/image.rs": "004fde7a234448d949a7e6ccd8eebb6eec6b32d7f045eadbcd895a43e0b01779",
+    "third-party/nsh/crates/nsh-platform-macos/src/image.rs": "36fed54f01b790ed81081375901933752aeb8087cde67e41a0660d356020defc",
     "third-party/nsh/crates/nsh-platform-macos/src/input.rs": "cf60e299c8d2f1ede22cf97c4cdc9b544e1e39729133972bb10fdd8ec36c93dc",
     "third-party/nsh/crates/nsh-platform-macos/src/keymap.rs": "2f3e4e6d04186726682fc2f2e28072aa87a3f5bacfd6f3b401a557bbd0a08e02",
-    "third-party/nsh/crates/nsh-platform-macos/src/lib.rs": "35aff1f192acedf2093b66d489b826626c4e82a7141a08003fa818a3554a385c",
+    "third-party/nsh/crates/nsh-platform-macos/src/lib.rs": "9b21c7b8eff9330c4223abf984f9b3b2114a05d9a6dfab985fbeb63bd28515b1",
     "third-party/nsh/crates/nsh-platform-macos/src/mount.rs": "a01d8fa6137192a5d49a5ead055d2aa45f945128ddbbf66e8ad3fb0130d76542",
     "third-party/nsh/crates/nsh-platform-macos/src/native.rs": "fcd93dea237d95f3defe9bea87744cfd491b19b880de3888f970b7cf73e88f2e",
     "third-party/nsh/crates/nsh-platform-macos/src/navigation.rs": "318cf562cc15dd88ffccfe854e4baa8aa91d61619092b85c6bf133b83a738f0a",
@@ -1693,7 +1705,7 @@
     "third-party/nsh/crates/nsh-platform-macos/src/picker.rs": "06da9eab5dbced591556f61121b535da1fa47091670a4b67e031db6bffcb31db",
     "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": "52cb73ba0201bcb9fef631535a07144b9ca32f87bad03265070732404e75d054",
+    "third-party/nsh/crates/nsh-platform-macos/src/surface.rs": "e0c9f5ef1bbed85c31036222c3be56ba99cc4dae46df26870ad1ae7eb5f5b524",
     "third-party/nsh/crates/nsh-platform-macos/src/synth.rs": "c6510dcd66cb600e382e74d2d86352196188303bd86a85f12afb21213cf51776",
     "third-party/nsh/crates/nsh-platform-macos/src/system.rs": "6093ef4b9d07e546f7e730307cff1b2e4980651c2f9ac9839a63b7400fdc31ef",
     "third-party/nsh/crates/nsh-platform-macos/src/text.rs": "ff5de5d8db0099f156a8dd643f7d85a804325f78bb9d3b7ea965e36c97fbaabf",
@@ -1728,33 +1740,39 @@
     "third-party/nsh/crates/nsh-platform-tui/src/surface.rs": "5f646e972018459dcfb9c682b03129fb620a23bb4b753f2a83007bf8cc0d5194",
     "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/Cargo.toml": "0ee63d361f41feb3fc435e886926f963f2621cfafbd8d6f7f389ef79bbc8e708",
     "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": "0566dfda6417cf965c3ec320358eb14110b2b07131fd96aee66b0d15e6af286f",
-    "third-party/nsh/crates/nsh-platform-web/src/ffi.rs": "1ef25ae33cc562695438e574d7fead4a8b9d1536ce9ed78e8619d73c4c616d23",
+    "third-party/nsh/crates/nsh-platform-web/src/encode.rs": "91f4cb8d98220dd823801c7c46d23d97e771cf836427b63c87fc2d1f2d74196b",
+    "third-party/nsh/crates/nsh-platform-web/src/ffi.rs": "9b14e2122360a6ba3eae57fe3a1108dadef7b48ba58e47ed0883f3ad766b81a3",
     "third-party/nsh/crates/nsh-platform-web/src/filesystem.rs": "34cb25848b20fe11967437368e5f5feda3f1297982232eb54aa27ab2611cfccb",
-    "third-party/nsh/crates/nsh-platform-web/src/lib.rs": "ed47ca2d38645607139ceec3f5845ed5b0914089f196f4d0e7e333c0c75de581",
+    "third-party/nsh/crates/nsh-platform-web/src/lib.rs": "d04efd66d46a3449b1df17cccef1a550f3058ad4dee1256fd43a92e10b595ce2",
     "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",
-    "third-party/nsh/crates/nsh-platform-web/src/text.rs": "611942f9a7265460339fa298dbe798e25fc1bfe424ead8190be8ab2e5950f39a",
+    "third-party/nsh/crates/nsh-platform-web/src/text.rs": "eb71e7f12f6b17cc7122f99dfe30271197876d3556f26ffde770531d0c2a8389",
+    "third-party/nsh/crates/nsh-platform-web/src/text/css.rs": "633b8d08b527dbb6f63e6e021922d6378f29de173167809bf193783e8de5843a",
+    "third-party/nsh/crates/nsh-platform-web/src/text/wrap.rs": "0646debcea68c12f9b13ec4a50c16dfb2f3484eb897c6ccf80a72d36a199ade1",
     "third-party/nsh/crates/nsh-platform-web/src/websocket.rs": "4dcbc4ae65c9d8d8833eca7e2117be425241f28ff17cd2e15dba32b7f6a0fdbc",
+    "third-party/nsh/crates/nsh-platform-web/tests/text/fonts.rs": "52ae86601484f6f18496b754aa470bd379dc49e94148ec558a08fdd6227dd03d",
+    "third-party/nsh/crates/nsh-platform-web/tests/text/lines.rs": "d3fb891b46210453a64bf970800d1680920b9a1684bfc99f7e2d9efde5134b41",
+    "third-party/nsh/crates/nsh-platform-web/tests/text/main.rs": "c0957690fa0232b439a35fefe4d5515f8194c2b6343ac6df9ae5f9c8c0986c8c",
     "third-party/nsh/crates/nsh-platform-web/web/index.html": "314dfa65fd6ac02b15f22a22ec00432dd350953f97c1cd1b568aaa966313ffbb",
     "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": "20c03e004d44a8ec967c0abac6e9b2f0f38d8f02b5e1a68067146d134c861c8e",
+    "third-party/nsh/crates/nsh-platform-web/web/nsh.js": "382862c28d5aa409fd5a861081bcf15b820c9da24f092900fb12e34f9bccd4fa",
     "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-web/web/text-browser.test.mjs": "09203d5c706c3ea662cec518457410c894e82e94715c2399703a10966a19d605",
     "third-party/nsh/crates/nsh-platform-windows/Cargo.toml": "ca891951f7e5d38889c956e0f00aacffa11da045ed4bb849065c781950c75d9f",
     "third-party/nsh/crates/nsh-platform-windows/src/access.rs": "47b9fa41f47293270dfe1100c4d3c796473abc9e032eda1940ff3d7a2609c57e",
     "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": "05ead1cd20c621250a254c0dd7b2d6cffa3aaca40a09fbf04503b6c4e710e561",
+    "third-party/nsh/crates/nsh-platform-windows/src/app.rs": "7797359a21b86af14efeb9ab95fd08276001793e0e914a22dcd1b54c029c3b87",
     "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",
@@ -1766,10 +1784,10 @@
     "third-party/nsh/crates/nsh-platform-windows/src/file_drop.rs": "264fea2b33dc2e0e0a5b6228c521fec8bfc32f93b00d4ea4fe0cb1ee1c5c4aa7",
     "third-party/nsh/crates/nsh-platform-windows/src/geom.rs": "f545bac8767818486dced6f12fbb4cd2fee00114dbbdf9bd77c610268cc9d4a1",
     "third-party/nsh/crates/nsh-platform-windows/src/gfx.rs": "ef97b47e7ddc28932707c085373295e8400415cb6cff2cab6562934cc080be66",
-    "third-party/nsh/crates/nsh-platform-windows/src/image.rs": "ba1f4b58cf03eef269d8fcabd426ae5f15d0455ba4fe35630612316b3c811cad",
+    "third-party/nsh/crates/nsh-platform-windows/src/image.rs": "fb7779326d58c746d4818b1ad5489db301c6c834cfffbba11b6373dd1501c7ea",
     "third-party/nsh/crates/nsh-platform-windows/src/input.rs": "55c705f219fc6b8d19bbf2049f8b8d8eaf8439a235c23589bdeeabaa0d6e0e97",
     "third-party/nsh/crates/nsh-platform-windows/src/keymap.rs": "2dc3b8fa2ba316dfb5c884d9b3a017af6cc1ae23edd31da019323a2ad80e1132",
-    "third-party/nsh/crates/nsh-platform-windows/src/lib.rs": "1a3a6cf6cc035517b683faf267c12a8eebf1029a8177ec9ae880391044cbbf6c",
+    "third-party/nsh/crates/nsh-platform-windows/src/lib.rs": "c1a76c70fb8fec11eeb1477a2f3690a6f2eefc5454eeee952d5eb7dc4ad09845",
     "third-party/nsh/crates/nsh-platform-windows/src/mount.rs": "1d2b45a793290c26318a28a51655d977c19ed6629445531721e730898b5f7909",
     "third-party/nsh/crates/nsh-platform-windows/src/native.rs": "6f00f6e07d740759ce3657fa33f5d7159cd11075e1309e7ae1577f30f025b316",
     "third-party/nsh/crates/nsh-platform-windows/src/native/video.rs": "74838037baaf60bc06bb299e6a1efd8117562d2d153a1c48288b331b1be3fd8e",
@@ -1779,7 +1797,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": "2477b05ae6b5711c8614c240251f04b7d5b7799aa012267341026714b7107808",
+    "third-party/nsh/crates/nsh-platform-windows/src/surface.rs": "a6e7237c221225857b5dc1bc7312d7d6723a26b577be523f422a2d2d5ae7df05",
     "third-party/nsh/crates/nsh-platform-windows/src/synth.rs": "27fc28c8d4d0b54dec35abfaeaa283f39bc3fc33eeb9d5b1cd9c280a6511966a",
     "third-party/nsh/crates/nsh-platform-windows/src/system.rs": "f4064018602a0f9b8d7edb41b83f8c4af945343467580d519a5197e16b9a4cb3",
     "third-party/nsh/crates/nsh-platform-windows/src/text.rs": "da82b3aba6af2b92c2615dbd27bc40ac7cdda0009027a7190faa349a8d2070e7",
@@ -1847,39 +1865,39 @@
     "third-party/nsh/crates/nsh-windows-app/src/lib.rs": "ae8f7c9986f93c5ce668664385d65e05d16b768f4107b6b8786996641ebcfa34",
     "third-party/nsh/crates/nsh-windows-app/src/main.rs": "49ec1b65ea1f5d009c263a7f757a043d6085e548ee894c019d9a50f8631d98bf",
     "third-party/nsh/crates/nsh-windows-host/Cargo.toml": "701f8021a291aed0edf57fe87a09e506eba67c917795581829d5216e52b7139c",
-    "third-party/nsh/crates/nsh-windows-host/src/lib.rs": "d04c6b7abdf139102a038c247c76d7b7a9cd50c7fa3432e9cf870ffe6369653d",
+    "third-party/nsh/crates/nsh-windows-host/src/lib.rs": "2b6fed8f6e672dd2575346f94864bc442c3c7c1b4618c5f4689379712839528a",
     "third-party/nsh/crates/nsh-windows-host/src/main.rs": "cde1e857a9ebade549af80b1b56e1adb57fd0038dac6b15c16edce6541cfc16b",
     "third-party/nsh/crates/xtask/Cargo.toml": "c1b1877a437ab353cff91daf283a5bf6877cf74e2b6397fb95f2d230aad7e8af",
-    "third-party/nsh/crates/xtask/src/android.rs": "d1dbd5bd6c603bd3a7de20a778d5d22fb77e666011d9a84d0a38040dfdd3e916",
+    "third-party/nsh/crates/xtask/src/android.rs": "dd2262f34e60ef926906778410ca55cf1d6b5c0b9983fa7a40f6d43c43187f1d",
     "third-party/nsh/crates/xtask/src/compat.rs": "5511e1d357f6e94978fb455f4f9874c2b62fccbfa602bb48d882e6f152d958b0",
     "third-party/nsh/crates/xtask/src/dist.rs": "ebc6af21d7e322bdf90aa81d0c3fc814d1dde9bb7f639de5b3140af9048a1c1f",
     "third-party/nsh/crates/xtask/src/ios.rs": "5343c250feb010350f7a5374b90a94003dbe86df1d38f95a7551300be5d94cde",
-    "third-party/nsh/crates/xtask/src/main.rs": "e02d818006a77c1fbb37c3d28b3e6862b71b0231731a752bc0697e33908fb2cb",
+    "third-party/nsh/crates/xtask/src/main.rs": "0c55e8bc293e998ab5506789a609258852c1b0f3ac3d160137b83ded969678b4",
     "third-party/nsh/crates/xtask/src/run.rs": "0862c5a701ed6bf204d26808c4851cb393f81ad409610368730a035be7b0c21b",
     "third-party/nsh/crates/xtask/src/term.rs": "c37f86f12a081dce01cac4ba3d48443b9c0b13700706d40959138edb97c774e9",
     "third-party/nsh/crates/xtask/src/web.rs": "aa219e750031d0fffbee998617f2d6554279f6d56288eca79996113706ef1545",
     "third-party/nsh/docs/README.md": "921081813d08e3298ac20fa01f6bf375aaa79073659cf3b4703d0b184d6113ea",
     "third-party/nsh/docs/dev/README.md": "f6c199e9444747648f80e342ace30a586596c68abd9e7662daafae0b20f2565d",
-    "third-party/nsh/docs/dev/animation.md": "f56e07178875bc9d7528ae471a8c017cc80b86b3501b9c2c38b549e42191fb46",
+    "third-party/nsh/docs/dev/animation.md": "a3eb30d4d48a71e22b1a329e4c551e4d681eeef9f3d94a5a39d633b9d8da995c",
     "third-party/nsh/docs/dev/api-naming.md": "f22ab18abca37aba55ce4bc808b9485e8490e42155ebb9e33c2d10cef900fb29",
     "third-party/nsh/docs/dev/architecture.md": "dd96d2a3ea5d26d23d17e0db36a1b280486be933eb83452f8fbb5d4c87023951",
     "third-party/nsh/docs/dev/canvas.md": "e6c644b1198801f9242aac91339dc04b5c480d876dbcbe378c58b4953f3970c2",
     "third-party/nsh/docs/dev/compatibility.md": "26336d6349cbedd0a70990dd5a4d2e7e48c604242974ac164e68bbeb4ad1cddd",
-    "third-party/nsh/docs/dev/components.md": "9f4a41c405a016275002fc9d850a222dcf941eb47f96201a1a384ba3f8a34914",
+    "third-party/nsh/docs/dev/components.md": "9c8b9637ee6f339b971c538f6577434aea751af6b6d385ecf2d3609a7eff20f7",
     "third-party/nsh/docs/dev/dock.md": "51527992aeec0387fe7ebdb82b7d24ed4ab6f0dc19b3332e23236cb7a3ad1041",
     "third-party/nsh/docs/dev/elements.md": "e0e23205ae83d711e9a67e5203e421af27ddd2d0d992dc2f87d54a4d8e828f45",
-    "third-party/nsh/docs/dev/embedding.md": "bc59a12954a29e1c4d2a2ac00b2e223f125b6469e15691de521ffcd8d2ffaa18",
+    "third-party/nsh/docs/dev/embedding.md": "ca48879fc9aedb076c8ca6d9d734be9964c5f02838055c500d665cfee117daca",
     "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": "b0b8d29e40901e2354953e26755251f99bf5e88c7866e9e9819ee759eff3d87c",
+    "third-party/nsh/docs/dev/images.md": "78713c4ea3b733b790770fb201f78559588f62f1f740cdb70cdb87cf8bc9434f",
     "third-party/nsh/docs/dev/js-modules.md": "312ab700bb94b259d4d47bc7a378017ebcac0bd2bc5b58d9f8c98b4e21878a43",
     "third-party/nsh/docs/dev/layerfs.md": "df9b16b2f5f1e61f9693f63b61438a51461d05b535a0046d50ac9ba1ade12b8b",
     "third-party/nsh/docs/dev/linux.md": "4441ec64b6edc5c03084bd203aa5114c066f9e2c0541ee320be8f17b6f82c3fe",
     "third-party/nsh/docs/dev/macos.md": "c2b332f4a6e72b60520ebf34d8875a6776662ddc9d1ffa62963744a62961c988",
     "third-party/nsh/docs/dev/native-pipeline.md": "07f803365af6ac90d5247c70d2e85ce9e03393f0e9e122c46690f85e381a0c55",
     "third-party/nsh/docs/dev/performance.md": "4a49973e31848b26dbb8a8e238bf8417bd7015a6b323f75e2551645078651f76",
-    "third-party/nsh/docs/dev/pitfalls.md": "b0312b25abef0cb3ef4d90ededa337811e8ecc3ddebe1b5ad55e854ff3c0ca95",
+    "third-party/nsh/docs/dev/pitfalls.md": "95a3767e9cce9f9aa1ebbf58e4426a50e2fe0d1233d933b00395b9796d9bcf1a",
     "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",
@@ -1891,7 +1909,7 @@
     "third-party/nsh/docs/dev/text.md": "7a4f09386899d5e6a5c3dd7c886063db73fcb5b288b47d0ff37cbfc8b4911dfd",
     "third-party/nsh/docs/dev/tooling.md": "2c8d76831eee8ab5197b58a02139d783fde610352512003be6da39014de76df7",
     "third-party/nsh/docs/dev/tui.md": "47b56095f90ba9caa3efc65acc10bb3e255b674d9cf05d71fd5a30f7a43b1770",
-    "third-party/nsh/docs/dev/web.md": "fd9039c76d0f8259cfdcb894add27d2cbcd04eda67d06c57d614b9f0ce6c1ade",
+    "third-party/nsh/docs/dev/web.md": "29ca00c10aadf5e89add3b86ebd50fdb3a3aea9c209aaeb8b320d5399a5bde49",
     "third-party/nsh/docs/dev/windows.md": "099245b28187723d0ce93f9b6f0b4775bb19f25c625e8b3db558fe175dd0bb6e",
     "third-party/nsh/examples/bench/GridTs.tsx": "e1388190424c85d044232f9dfdc897acc69fc4464b23b57db149f9276336e3b4",
     "third-party/nsh/examples/bench/README.md": "75d32dc5f73955e42cd171baaeb7df39b19cbdd25e67e661df7976a887ca3506",
@@ -2007,12 +2025,12 @@
     "third-party/nsh/examples/gallery-rs/system.test.tsx": "7631fb02f1dff502323cf0574d61683e48c2a109500b1a909cfe8d962235bca9",
     "third-party/nsh/examples/gallery-rs/text.test.tsx": "65b3649a460488742786c96231694c67f5e02eff8dc1f798a32e5d539ab0295e",
     "third-party/nsh/examples/gallery-rs/theme.json": "7c54621c9c3b0141f1462c689dbeb9e4523463b9c40350735bfcd37151b7a28d",
-    "third-party/nsh/examples/gallery/App.tsx": "23abdc7932398d05aec445e6ed9069be679e1ba6dce1c4b5329e5ed773a9c525",
+    "third-party/nsh/examples/gallery/App.tsx": "6fef905f193d3a55c1aa2ffac614ea2905e181fd4632d9af1f8489d060a12f85",
     "third-party/nsh/examples/gallery/Bench.tsx": "be900be7d344e643d2af34a072411034d51c88e458b9b219553d88f3949b0ce0",
     "third-party/nsh/examples/gallery/README.md": "3405f56b12e92872b39c2535f8824c042f9e0fb3432e4e922d630e5c5e589165",
     "third-party/nsh/examples/gallery/TitleBar.tsx": "6d69ad1fdd30af882cb2fb41cdb5fbb6c52e14887c698ad7e734c09f7f0f8abc",
     "third-party/nsh/examples/gallery/bounce.gif": "205cd6ffaf6d4575d52ae4eadd01c1a6c689ecb60b3a350f1902a8d63ca3b1ad",
-    "third-party/nsh/examples/gallery/demos/Animations.tsx": "0dc3e53d5bbae41aedf9d809e1588ae0f765348814e984fd37f95f05c09c8cfb",
+    "third-party/nsh/examples/gallery/demos/Animations.tsx": "32a78bd79228fdd8600ce580006ca843bd154bdf4f5e2aed1ca4f8a7bd392d5d",
     "third-party/nsh/examples/gallery/demos/CanvasDemo.tsx": "32c67d2f9e0fb912b9de43cb894cb2eded2925709b377275e18fcfda5f3eda30",
     "third-party/nsh/examples/gallery/demos/Chat.tsx": "0dedb07ac099fe54bc84e68418ee80c6b9568c3b13d35fb2d0e2272e3c5ffcf3",
     "third-party/nsh/examples/gallery/demos/Components.tsx": "b624d75b8624a26190207a23930977facc3d08af9ff486f5e4358cf1c3355d9d",
@@ -2026,7 +2044,7 @@
     "third-party/nsh/examples/gallery/demos/Media.tsx": "fdfddaa2b0407224a54a8b39d32a2d9705c8659917e8bccafe83ee02e6492ff8",
     "third-party/nsh/examples/gallery/demos/Menus.tsx": "d2799fa49942ec4f7db0890efa8fe8a16f697996854cf6f22a82097122faba2c",
     "third-party/nsh/examples/gallery/demos/Navigation.tsx": "1a534a80026c9802ec530b9c6b7298fa0e384f40ba96eba27bfd388ca51fbb7f",
-    "third-party/nsh/examples/gallery/demos/Overlays.tsx": "0a8c389b4adbbcf370f86532343b0fe853825656fa47e173169270974552a0f1",
+    "third-party/nsh/examples/gallery/demos/Overlays.tsx": "3e110d7ab256776b1d7273980a01ee02f1ffb9377b991177a1244664114d1fc1",
     "third-party/nsh/examples/gallery/demos/Photos.tsx": "e067ab01360afae1e1ca0764a18a0316b72480418d57db691cc90198e78b0e79",
     "third-party/nsh/examples/gallery/demos/Physics.tsx": "b35eb1f513c7cb4d97b699950791184ca08ee8f6137ee66e6a5067d51a3525ef",
     "third-party/nsh/examples/gallery/demos/Resizable.tsx": "0e2ddea81bd16cdefc9693798073c0e320a976f825048858777852ed849b4b42",
@@ -2065,6 +2083,7 @@
     "third-party/nsh/examples/gallery/nsh.json": "f1b72da09dd76c33fc78b45df5c3739ba6ddae8865fd25fe4492554ef2d4e51d",
     "third-party/nsh/examples/gallery/spinner.webp": "43b19bf2baa2704f8387c7b28b02f9349814d1030f5679ac9825fedc3495047b",
     "third-party/nsh/examples/gallery/star.svg": "1d3a1718dc70730c937a7f4aaffc857279360ba0cb46364aa94629e49ea5c6ed",
+    "third-party/nsh/examples/gallery/tests/animations.test.tsx": "dd968f4bb9685d86fb6b42a58ea4c7e078f98893b670a89efdeb66433e7feed1",
     "third-party/nsh/examples/gallery/tests/context-menu-press.test.tsx": "568760ff602e34f561652a33fdbabe187f4e40c04cbf3054faccd0690213df99",
     "third-party/nsh/examples/gallery/tests/context-menu.test.tsx": "9e79c3bec515230f44e161c0f794a1a1a6ef80545ca864bf0f4d22389fba12f7",
     "third-party/nsh/examples/gallery/tests/controls.test.tsx": "09175e91a6c688e0a9e536fe288edc6bbfc122a38272428f1d70eeb20990b65e",
@@ -2095,7 +2114,9 @@
     "third-party/nsh/examples/gallery/tests/storage.test.tsx": "70580dd3e0165d4e7e5dc971d633ddd2ce58cc80b504e0a45fd52c9f97ff6712",
     "third-party/nsh/examples/gallery/tests/text-view.test.tsx": "b14aa31a954217c7aac1ad5022c55082a607fc8010d52ec470bcdc74900d69cf",
     "third-party/nsh/examples/gallery/tests/timers.test.tsx": "2e89d1097247ef6bed7ce0dc92ba6ac06ed5504cf75ed10cadd2993149f2f855",
+    "third-party/nsh/examples/gallery/tests/toasts.test.tsx": "ecc108d5d1390e79a54716853ff90e56010d264c124552d9edca733280775d18",
     "third-party/nsh/examples/gallery/theme.json": "0a8a68062681820514e2cd00fe11b8dc37e39d654a9e5b2dbadd873b81857342",
+    "third-party/nsh/examples/gallery/toast.tsx": "4bb930150257668e9d80e79d2f3943a3d643acd58548fa384e4df2bcafe3e83a",
     "third-party/nsh/examples/gallery/tools/echo.py": "4d39f83c3c71d9ba114d4bdf68ab34b145a728763098f6757c7a351697969953",
     "third-party/nsh/examples/gallery/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "third-party/nsh/examples/gallery/ui.tsx": "f53027f23a14db45280d65587f1a087879ffc948c5d35bdf1ccd8ac31a4c75c3",
@@ -2142,7 +2163,7 @@
     "third-party/nsh/examples/terminal-workbench/theme.json": "011311ae936946054c7348c8a2e566a27e662141dc085ed58ffec79f75a1a1f2",
     "third-party/nsh/examples/terminal-workbench/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea",
     "third-party/nsh/examples/terminal-workbench/ui.tsx": "8a83d61ab55f4a3678cd6d09ef71fbfe28827b3a5bb283b0e3a198875a381779",
-    "third-party/nsh/rust-toolchain.toml": "2621c6abe1f37e10afdec13bb974ae920a5de538367504725d2e20792ea79029",
+    "third-party/nsh/rust-toolchain.toml": "fb9781152dbb601c53ec070f53142e569c946e4aa1571ac4800d6def4d7a4f1f",
     "third-party/nsh/scripts/layerfs": "be28fd34d5316cb799d0f2ea042395b478ebc5c3da6dd300c63907efa56ab273",
     "third-party/nsh/tests/compat/cases/android/App.tsx": "4955fb9e19e6a54908266f59c8d12c30b6a29e745bd2b11479044d86f68858cf",
     "third-party/nsh/tests/compat/cases/android/base-dialog-android.tsx": "1e5c87c083856c8691bd4e95d0209d150c56f11b486a95e95d8d6896866412d8",
@@ -2150,7 +2171,7 @@
     "third-party/nsh/tests/compat/cases/android/props-platform-android.tsx": "673ad7ff955f51a56dba48d12d0a8430582129907f2cd531f1681bb806072667",
     "third-party/nsh/tests/compat/cases/android/props-view-android.tsx": "a14407c8d93dd26d709c420f0a58a820697943af1d0c4c9a5516b5516a45a557",
     "third-party/nsh/tests/compat/cases/android/script.txt": "32fd798589800e9d1515885da38dd4052d74ccc623789cb5e1475c87faf4f147",
-    "third-party/nsh/tests/compat/cases/base/App.tsx": "4dc9afa59e0486f59af881da594a96c3517cea2c5d2af2f1b073d63fab3ab232",
+    "third-party/nsh/tests/compat/cases/base/App.tsx": "be2ce7f5a97fb54b92d03ab3b84749b86bd669972bf47a0066e4efd8fc6c7d3e",
     "third-party/nsh/tests/compat/cases/base/base-alert-dialog.tsx": "0c7123c3ff9cfa130edb8c327b8dabf3834ba29366adc38cfb6e2e8f0c46d18a",
     "third-party/nsh/tests/compat/cases/base/base-button.tsx": "686514fc9f01b558d0cb673ab4fadbfda56a60c727870c7856155c0eee13817d",
     "third-party/nsh/tests/compat/cases/base/base-checkbox.tsx": "3999f9b6ac962d616be733b8d729e79b14ac853467b965dfbc0ebfc53ce8f01e",
@@ -2158,7 +2179,7 @@
     "third-party/nsh/tests/compat/cases/base/base-dialog-backdrop.tsx": "331cc62be8aa96b87bcb74755ee3271b730746501e67f6d7bf064b4fcccd673f",
     "third-party/nsh/tests/compat/cases/base/base-dialog.tsx": "d81698c49ce4fee67b514c950424d456ba8efa0c1f84eb2f4346aacc748640ac",
     "third-party/nsh/tests/compat/cases/base/base-dock.tsx": "c5c7a4d9bfb77994f1cad20a21c7f2be7937a5d899db6c0792b4d570d043aeec",
-    "third-party/nsh/tests/compat/cases/base/base-nav-page.tsx": "abeb2bec820acacf03f2e36a1d0c92cff9151b2df75d37e762e3e17de8979d4b",
+    "third-party/nsh/tests/compat/cases/base/base-nav-page.tsx": "2e478b1af084833c2e7e54b5d0f4644f79ccf7350e40ee0efbc65880a3fa9709",
     "third-party/nsh/tests/compat/cases/base/base-nav-stack.tsx": "8896001549d81fc426ad065e339516cca65461987423b4455378ef1080925a4a",
     "third-party/nsh/tests/compat/cases/base/base-popups.tsx": "913c32eb06277965eb336940988e1a73d5bec24be7f23bdaba5033f75c54a5eb",
     "third-party/nsh/tests/compat/cases/base/base-progress.tsx": "b8e3871f8413e94a5a678e3686cdb24b157480d18b8918562f04962f184635f5",
@@ -2170,9 +2191,10 @@
     "third-party/nsh/tests/compat/cases/base/base-switch.tsx": "a9231f5d8d6fc6ecadd3314a8e7b55319688bbcefab593f4e82c447f8d1f5fe4",
     "third-party/nsh/tests/compat/cases/base/base-tabs.tsx": "4bfd3ad3e8ec7010281c45a9a2ea12f82de615d101af96acdd4b47f3991bcc76",
     "third-party/nsh/tests/compat/cases/base/base-text-view.tsx": "86464dbbb402a61d0bd34ea5376b3b17928bfe8938370eb10237cfb39e99f616",
+    "third-party/nsh/tests/compat/cases/base/base-toast.tsx": "761b3701ecd53f4d6127d22607fa4dd92c63b8187f236e2249ff1ba28326e5a8",
     "third-party/nsh/tests/compat/cases/base/base-token.tsx": "37abe78636fd087039b3eec86850ebacc4e91ea264bf3f20428d97e1fc5950b2",
     "third-party/nsh/tests/compat/cases/base/nsh.json": "4252ae25e6e83b77c786ff8ff5286ed99522a48fb687ddde878c25bd7008ca48",
-    "third-party/nsh/tests/compat/cases/base/script.txt": "cb01f6afd308a9a3d7c32404e17b13a425b0ef775043ff3e6e7f55aa8a01aced",
+    "third-party/nsh/tests/compat/cases/base/script.txt": "dec4dd98490b1df674902ec06f0818278e2ba78586cf42ea5ea463c441a8f651",
     "third-party/nsh/tests/compat/cases/browser/App.tsx": "72b4f42309816d782c54a9a47cd5449f666f3a0ab1395a6eb86eef80b855c6dc",
     "third-party/nsh/tests/compat/cases/browser/base-popups-desktop.tsx": "c8a3f5b9fe085cec13ec3597ab3ba217adf6a388acec272c3ba57a8019be49fa",
     "third-party/nsh/tests/compat/cases/browser/nsh.json": "cf16c9baa65e26e70482a5940097af2ea41afc3aced88748ca68dc765bd7c5f0",
@@ -2180,7 +2202,7 @@
     "third-party/nsh/tests/compat/cases/browser/props-platform-web.tsx": "12c78651a7af29d5ea404b2a82428d7117bb26ae1a699d1da9c2a292dec81dc0",
     "third-party/nsh/tests/compat/cases/browser/props-text-scaling.tsx": "3b2b2e6acb31e2558c87db0eef35b32b0f1f3912cd3139e165624ab6fb515d86",
     "third-party/nsh/tests/compat/cases/browser/script.txt": "205c72d1805faf19c9812ab3b932b2ffdde3aa5e9ebcb42ae3b65b6448b1a9c5",
-    "third-party/nsh/tests/compat/cases/core/App.tsx": "3a2c8a4bcc21ff3e0b5ca249040c26d6f3098ce3969f35b69bdea5ea065d0ca2",
+    "third-party/nsh/tests/compat/cases/core/App.tsx": "1852c40650d59eae5193470ac70e8e89f1b11db6cb3fcf13be0098001a49cc50",
     "third-party/nsh/tests/compat/cases/core/context.tsx": "fa753baa976168d9a7cb9714c5627c599ed40990eed28cf98661b08a3639345e",
     "third-party/nsh/tests/compat/cases/core/core-animated.tsx": "3ca4583291070c4d65423c5678ae87d73551886f4e23c06e2bf3254574d53456",
     "third-party/nsh/tests/compat/cases/core/core-canvas.tsx": "844774320ca0950753e5e519b15a20324aecbb1366b86efc21c3a6f9f217cd48",
@@ -2189,6 +2211,7 @@
     "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": "360ac05ea133de4ba541e4147b220e350d6c081eeccf8b15c2a10b290ad5b626",
+    "third-party/nsh/tests/compat/cases/core/core-motion.tsx": "cbb3be05ca64f196fb3785ffd27f815f7accf6e492d3ba567ecee38ec957dcbf",
     "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",
@@ -2196,7 +2219,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": "819fa77036fdaeb5fbaf17e1d2d47317e88c7196396031212486c8e49d20f873",
+    "third-party/nsh/tests/compat/cases/core/script.txt": "83a9a83cc7bccbeb09e168959b1c0895754dae665f62153556317dc69db259ca",
     "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",
@@ -2347,7 +2370,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": "3195028b8a8decdad49c1ceb19edab558635e216a7b9b8b178607bb8e6c53076",
+    "third-party/nsh/upstream.json": "78d17befb3086a9b0641e28583f0d3d135c34fb0e83176c9a0205458d19bb35d",
     "third-party/nsh/website/DESIGN.md": "484ab5b78233c1c57b0d46e631d2341031b2b34a74740abbb88a275b4c8134b7",
     "third-party/nsh/website/README.md": "50de9faed8cd3b0751991d6168ac6e33a6437dfd36aa15dc30d35b13dfb94284",
     "third-party/nsh/website/astro.config.mjs": "0cfae54c7791bd915865dc535669b311e4f76001efed09fdb2772d332812924f",
@@ -2377,6 +2400,7 @@
     "third-party/nsh/website/demos/el-switch.tsx": "02f078fa568f23d5a265c04171c130b32b7fc86e9cd26d4b7f987f05134fb0d7",
     "third-party/nsh/website/demos/el-tabs.tsx": "536a69f41f69ebdec40a4e50ceeff435f174f929ec5f1eecefa89223ad7ca52a",
     "third-party/nsh/website/demos/el-text.tsx": "b9e97520688e15d7cfafcbc0586a54605adc261533d43dd745d7e542069c1d09",
+    "third-party/nsh/website/demos/el-toast.tsx": "e404dd662f13d87264ea7169bfdcc1e2e9c800942e43dda9c8cb194a6238bdcb",
     "third-party/nsh/website/demos/el-tooltip.tsx": "3ff60d93f50c091afa394a8b79d0fd0ea531c76d15a81622ebea9d4113ebb795",
     "third-party/nsh/website/demos/el-video.tsx": "2305cc5fddc56d82bebce77df2ce2f262cf7fad98b2d6527a82bde454ab179c6",
     "third-party/nsh/website/demos/el-view.tsx": "d20d6c05b4664290c0bdde004fc821a0ff0b7a45ec2d6bb0311ff8c06f83cd8e",
@@ -2413,8 +2437,8 @@
     "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": "187b016125345e765f1ec94bf16596c3cda09c80f816ee49da9c6ab33dafba7a",
-    "third-party/nsh/website/docs/animation.md": "74859043103a0e12b3e2cf22f5b29ecf3cca7dd9e4009801b38c9d5d5aa2ceed",
+    "third-party/nsh/website/docs/android.md": "b70f3a3debcb795838c2f4d0b18a855a3063570588b3230a9724e461f5ada2df",
+    "third-party/nsh/website/docs/animation.md": "7b62f827adbf0aa5e0092ec9fa34bd52372c47f83c2ae2e9490282d86cbd34fd",
     "third-party/nsh/website/docs/architecture.md": "1a7f3fa3704a5e1c5f4dd6c2df4986c07cdaa247977f7b1e3a13464bfd82a8e5",
     "third-party/nsh/website/docs/canvas.md": "5589e8b7ceae1856e21081b30d3dc424dc977064fcbb711331f25165c962b4e7",
     "third-party/nsh/website/docs/cli.md": "9fca8064b0726580a9a357044ec9a5955b93ffd028eeb2a3ec5fe15b915ee2ee",
@@ -2427,24 +2451,24 @@
     "third-party/nsh/website/docs/fonts.md": "b0ae53f3e01e979463db256702b931a32cc6a7f426f9b1f3f24eb565048e6bb8",
     "third-party/nsh/website/docs/forms-and-keyboard.md": "a3a3a439fe04a70ab3d999463eda2fce0aec43331a42cdaa829ef55275ac82b2",
     "third-party/nsh/website/docs/getting-started.md": "2a8d1e38fcc84969f0284d606606afde0d0894f6ac4c714b8435d259a33b9601",
-    "third-party/nsh/website/docs/gpui.md": "bc13d047218bec8557764e03a225f1d2786187ba08d5bcb3bc7b7d6154ac4bbb",
-    "third-party/nsh/website/docs/images-and-assets.md": "25b3f1455863969f1ce2dcb8961a30783c425c3d8015fe51a4ef5a134cfc2aa6",
+    "third-party/nsh/website/docs/gpui.md": "2fb35a713de6f419af90394d1fff362fe548d93e976226083b47e3b646630e61",
+    "third-party/nsh/website/docs/images-and-assets.md": "5c99242d40574c0f51a82bb3f3591cb06a14667fbdd635f1c4def2a3aa1acb59",
     "third-party/nsh/website/docs/index.md": "31a039507f14aaf881b9fb351b18a837c5a53650ee8449b14afd91ba1fc22281",
-    "third-party/nsh/website/docs/installation.md": "a7b1a6804f151719d35b8b62eb5b3861712a24cd90175730266ee5fb0874027d",
-    "third-party/nsh/website/docs/ios.md": "29ed839dc1440dc387f30f722f6ff4a06c741c79c984300d2eab9b1530635f65",
+    "third-party/nsh/website/docs/installation.md": "d91b209306a7d06946d08bf0e79c6623bd90d09f5eeaf5b3275023289838c495",
+    "third-party/nsh/website/docs/ios.md": "a7935e7fc31d46be6bf999949c98882c9b1ee6bf288f2bdd6b47622161927516",
     "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": "c5432744bf1ebf18d8afa74075ccb0581e98f5e98255a07cb9c34ab00153b917",
+    "third-party/nsh/website/docs/linux.md": "39eff100b31d2a824cc3a7492791392b8d8d01f3b6ae312ca94bde9e816e9de1",
     "third-party/nsh/website/docs/lists.md": "2130392b035cc3dc895afdeeb98c6a5ceec64534544d8fc8358d937589227536",
-    "third-party/nsh/website/docs/macos.md": "4b36f58b9662268707a5868a0443eaa55b44ad59778a25b71e421c970143412f",
+    "third-party/nsh/website/docs/macos.md": "009aacc391f63461ca036c3b05e8665eceac5fbcd63a19b4cd013f82338578d4",
     "third-party/nsh/website/docs/native-components.md": "cd8272bfb271dece1f395009077a762c972c282bbd9246265ee9f6a4ced0e656",
-    "third-party/nsh/website/docs/navigation.md": "8a4a9ac5d9a44ce8e9a30680b4ab9c0a4d6d4bfd556b8c7347074eefb3ba6357",
+    "third-party/nsh/website/docs/navigation.md": "02e244b6163a0377b6e1c2147fceea1d67c97bee753b0e2632bce49171ee70b6",
     "third-party/nsh/website/docs/networking.md": "266b3e68ab44b1509afb2b43d21874be4c44d0a69fe7b4bee9adf3f86be83ad2",
-    "third-party/nsh/website/docs/packaging.md": "74a12258a9d6bba2369a5260b957c7a11213e15849b689e9c54fa0c9208d6f7b",
+    "third-party/nsh/website/docs/packaging.md": "cc60237216a83db69ba34aca73bcc5539adcc9473119eae2b8a0726039e87965",
     "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/platform-specific.md": "dec07bc80933d3c01ea84e8038af3d0c26d1129fd6115e96c8563064c5307b3b",
+    "third-party/nsh/website/docs/platforms.md": "ef8e899ef549cc47d4694826954d910825ad1df47000d7364ff3aa162aaa426d",
     "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",
@@ -2458,8 +2482,8 @@
     "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": "73e74007aae291bab262fb1372f41c8f61eb723005780b911555e2a45be0723b",
-    "third-party/nsh/website/docs/windows.md": "5c52b9a5e1c03a11a8d13a1686b27a082c21c9663f88050375b44f8f4b55ac03",
+    "third-party/nsh/website/docs/web.md": "6855797eb0424dda52f16deb1d04bcd4d3bd981f2bf699950f4d34c14493e4ff",
+    "third-party/nsh/website/docs/windows.md": "eb8255d7d7c0addd4128fa7ef96284f03d89b0d3ba8ef9151cf24a80995d684c",
     "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",
@@ -2469,7 +2493,7 @@
     "third-party/nsh/website/elements/dialog.md": "3d620dbb978c555a88c110cd6672cd09104a547278751bbb7f6dab527dcd6b09",
     "third-party/nsh/website/elements/dock.md": "86a57856dac72b4c423e35d23b819c3874a37ca9ccf50929616da23ddbbb5a17",
     "third-party/nsh/website/elements/image.md": "c6b783f5be1f157da336afe43934277df4f6658816b5bbad94bff9ef037babb1",
-    "third-party/nsh/website/elements/index.md": "9ebb2752c3639eaf5af7bc18b7702db72fc4d73eddc466b292fff2062bdef5fb",
+    "third-party/nsh/website/elements/index.md": "3961de097fef8e801689bb5a935b394f7e12009576b8ecf80614e05582cc3e17",
     "third-party/nsh/website/elements/input.md": "e092f63b3c610e2255717c0d9608743095ec6ec266c8928ca2eecfcd44fa797f",
     "third-party/nsh/website/elements/keyboard-avoiding-view.md": "dfeb1baa54bdb1607e1d1fbbb6baa27d7aa082219ee89d7382c6e85fa4a75797",
     "third-party/nsh/website/elements/list.md": "8d57faeea3127a8a3b477ee67a88f9dfc9a58ae0f0ead6c4a1fc32cae0ffd0d6",
@@ -2488,6 +2512,7 @@
     "third-party/nsh/website/elements/tabs.md": "bbafc520328fb659853b92c4891ac008ec587844b5095de3317b35b08bc0e72f",
     "third-party/nsh/website/elements/text-view.md": "6930615ac2149576e3fa946b470e4ff4073ef29601309abd6e6f0e25dfa99e39",
     "third-party/nsh/website/elements/text.md": "2ee592d12927f93e7b2947b907e40f42b8bd2d0e3adb0dec96c337beac6e1970",
+    "third-party/nsh/website/elements/toast.md": "7de41f1757f86316c6309458dc0596bda032f7e2e87a17c9f44c274cb7627471",
     "third-party/nsh/website/elements/tooltip.md": "af7d620b741a08eb9a616e9a0d3f7624b73d5eaa26fca13667142a5e23a3ef56",
     "third-party/nsh/website/elements/video.md": "695cbd156797e5a39d2417fa556ad8fd806dbf471d3db031d2c4adf64b4b63c4",
     "third-party/nsh/website/elements/view.md": "a532351633e057acff510fa3dc45c6380724fb166a5784ed15b928b77a13dc37",
@@ -2496,8 +2521,8 @@
     "third-party/nsh/website/modules/index.md": "79d598c388790aa4acb9c033912cb4fa11a6fe14ea985ca34cf26c4151cc64e8",
     "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",
-    "third-party/nsh/website/modules/nsh-base.md": "360834ec0f50ee88b97f1f8414b70187b6f0faf63a1bcebd43e572c48c385af5",
+    "third-party/nsh/website/modules/nsh-animated.md": "1a1ac7fcef657af0d2d5a80b78139d2b8847567acbd0bf6ffde8c638cf78698e",
+    "third-party/nsh/website/modules/nsh-base.md": "daf8c7bb9fb90da256449beda848115dff9dc008bbf9d5d7c2d205cb5ea3f4e7",
     "third-party/nsh/website/modules/nsh-canvas.md": "0e800bfc10e2d8d7c2533f1d6959f3d29eebf960c2a3f1047cb61a21137980a5",
     "third-party/nsh/website/modules/nsh-clipboard.md": "41bb55db93e55c6e12809b90d1bf8582a76d1efa596f24daebf1a6bc722d6a78",
     "third-party/nsh/website/modules/nsh-desktop.md": "74d5b00ac1d1e27da1e439aed901745108146f9495bed44ef114696e1abb5c85",
@@ -2602,8 +2627,8 @@
     "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": "1cb774693d6274df2b64c4cb8317860cc8a0efdda134144a9eef88709ef81786",
-    "third-party/nsh/website/zh-CN/docs/animation.md": "a6d266e73b250cb3274784b48dfcc99ad791897ffdc5d6788e80a11db4fdebd2",
+    "third-party/nsh/website/zh-CN/docs/android.md": "839456e1547da96a6502d9a69118ee9e60b66f4227f6956862c739d29f868dfd",
+    "third-party/nsh/website/zh-CN/docs/animation.md": "6b032c83f675ef9fa67f75492a0ce24936f54e5d8d82bdbc7eba4b68c00e6d78",
     "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": "d61a977ebbad616432c8fdede8049d7b0ea1d44f54319356fc58ffd05eb79c58",
@@ -2616,24 +2641,24 @@
     "third-party/nsh/website/zh-CN/docs/fonts.md": "8a325ea54bf8a91772f30597a5a1cf2437014d63ac4102c5c9ebb92cde5b50cf",
     "third-party/nsh/website/zh-CN/docs/forms-and-keyboard.md": "3ecf7fab3df7e7ae6b0008ea9f5f8934c7dd163120497e632f98861905383ebd",
     "third-party/nsh/website/zh-CN/docs/getting-started.md": "30dae5a17460e27792dbd18a5888d1aa7fbb2c0371e8bf7a2ce37d28f2f51466",
-    "third-party/nsh/website/zh-CN/docs/gpui.md": "3fb43d37af2cb9ed28aa88e55e2986c598393143ab0693c7ab690535bdb6c0eb",
-    "third-party/nsh/website/zh-CN/docs/images-and-assets.md": "de6564e02b94897bfa6865353acfa8d007b331f0dde1abe572db12281c654d63",
+    "third-party/nsh/website/zh-CN/docs/gpui.md": "e9230f4a5648d7d3b736c02b18693d6c1dee0c3eaf4950cc7e3e605335a8e30d",
+    "third-party/nsh/website/zh-CN/docs/images-and-assets.md": "4d6047f82dc4022bcdb022f30f3633cd3487858467bc99dbe0954799be6bd81a",
     "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": "267368568539e4f6e88b40c3402e97a9b5abafd8ace0e51de0a2e51a47a8d723",
+    "third-party/nsh/website/zh-CN/docs/installation.md": "1f8e335646367b6c8cf9fd12f58771b45950033def7bbefb677667670713c9fe",
+    "third-party/nsh/website/zh-CN/docs/ios.md": "42a8d174bda8e7ef541047840bbd93c46f9150d43d7a18854302d1b3b8ddf46a",
     "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": "04a113952bfbc8666cfaab4a12ce971d09d5699118b169115b0bb78896e4ffd5",
+    "third-party/nsh/website/zh-CN/docs/linux.md": "fafbf0b59fc48aa366c7abcc74e729e468e0e769e6aa91b7528e6331522d508e",
     "third-party/nsh/website/zh-CN/docs/lists.md": "6f08a19672781a9c1301650baacce198396f634e39b7fbcf033855ae7e2c4611",
-    "third-party/nsh/website/zh-CN/docs/macos.md": "7defc3eca134507e1c50b4593f14a121924913df0f22fa2ad645fa6926e28c1b",
+    "third-party/nsh/website/zh-CN/docs/macos.md": "a0acbf19efad3df7b75e56961f74ea84bf724e42233bf7a08a63d1923fa004b5",
     "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/navigation.md": "4fad62c6523ecca3fb6efc28fd0f1d517b8c0111a1937fc474f9162c6a06f5c6",
     "third-party/nsh/website/zh-CN/docs/networking.md": "a5af32ffb01efda4db9c0c551677694098b085a308abe7faf9a39c7efa7797f4",
-    "third-party/nsh/website/zh-CN/docs/packaging.md": "8b50f913799aa252cd230b96ee50f5b73a6bd4bc06e6da35b483ac03fe8368b7",
+    "third-party/nsh/website/zh-CN/docs/packaging.md": "1d2d8eda879b6019b62c224a0091c3ad8724beb4ed8922ee62287c822d86ea99",
     "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/platform-specific.md": "35b1c209a7c34724dde9021216d2cf4ca56da2c57cafab3c1a1e58c07a3729d7",
+    "third-party/nsh/website/zh-CN/docs/platforms.md": "cc34dd52465202802d1a1587a6e9e075faec7d568055109798b57171cc752f58",
     "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",
@@ -2647,8 +2672,8 @@
     "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": "d22060420052d788555373fecb5297f242542e62a388bc696da88cfb9abf1331",
-    "third-party/nsh/website/zh-CN/docs/windows.md": "2bc90fb626da5f2c2d65fc39714b21e9c38fefd32154cd16f81693f804ff34a5",
+    "third-party/nsh/website/zh-CN/docs/web.md": "56467bd00610482bb467b4b2ec7e398bb102ce7ad595e78c846d6b88ad150e24",
+    "third-party/nsh/website/zh-CN/docs/windows.md": "aeb600a501b162f019793930981b4d640e90fe18a60da0e48c3881d99ebc82a7",
     "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",
@@ -2658,7 +2683,7 @@
     "third-party/nsh/website/zh-CN/elements/dialog.md": "cfd0ded3f400392463710f5d2a48ee669b50fe712665e376bf0dc773dda161d2",
     "third-party/nsh/website/zh-CN/elements/dock.md": "dff254c70d301888d6e79ecfde8a6596285cba3f295f76003160d6d88344ef34",
     "third-party/nsh/website/zh-CN/elements/image.md": "0956a9de221d73e22ba15d3ebc90fcbc3a39423106d3c0ce065654917f3ff6af",
-    "third-party/nsh/website/zh-CN/elements/index.md": "4bfd2c24c29a217c8682c8ecc5fe5bd01925db233317f144a2f199eb5427baac",
+    "third-party/nsh/website/zh-CN/elements/index.md": "9470a776cf3dec2a6f59deb84281d5a58d9a85b2bf5f788309243a67d46cd416",
     "third-party/nsh/website/zh-CN/elements/input.md": "d5c3a7845a7cbf58492052a5546ee80a9291d8b91d266e4a61451f3253773c12",
     "third-party/nsh/website/zh-CN/elements/keyboard-avoiding-view.md": "0d54462bbc46162047b82489a7269221beab0e788c805e8c79f53e6a6a7bf01b",
     "third-party/nsh/website/zh-CN/elements/list.md": "9ae1ccb5151b06e630ed31e7a549877dd7813d341bfbe2e8f409e9f2b88c338e",
@@ -2677,6 +2702,7 @@
     "third-party/nsh/website/zh-CN/elements/tabs.md": "60db8475affb175043a3e62ecc822c25a759a896b2b8f5136fe35d263614c90f",
     "third-party/nsh/website/zh-CN/elements/text-view.md": "062f914c189f564715f32a65fd81554f8ec15d815949aee8b96b34bc633d0f65",
     "third-party/nsh/website/zh-CN/elements/text.md": "1780f26d52e5f1459a7cc0805d769c5b1ccaec88b2fd488e5e0974aec9436840",
+    "third-party/nsh/website/zh-CN/elements/toast.md": "9f0f472d6b68be1692a840a83f62a0903ff54543b63c46aef6d41b08c73bb6db",
     "third-party/nsh/website/zh-CN/elements/tooltip.md": "2956b483da412f49022aa632cad43b3fca50967b87f4d0cd6e94297b892614ed",
     "third-party/nsh/website/zh-CN/elements/video.md": "955ed5636a36deda11f609c75672b775290ebac5f676ea94a85c044a6cbd09fd",
     "third-party/nsh/website/zh-CN/elements/view.md": "07ceb854787d0ccda316a08c2f0ad34523f28f85a0b74c4adef29be612d898a2",
@@ -2685,8 +2711,8 @@
     "third-party/nsh/website/zh-CN/modules/index.md": "b945d450ff2018b0efe54eca57d1981abd66219c91b49965e9894a04bf61f6a8",
     "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",
-    "third-party/nsh/website/zh-CN/modules/nsh-base.md": "e9885ced674cf151ad4752c0588f50e287b3faed3773b08a68618438c1ce594b",
+    "third-party/nsh/website/zh-CN/modules/nsh-animated.md": "e573ac01cb5e51d1ef6196a2f6f0d7d931b8c37a7df176b01222cc8c4710c8fd",
+    "third-party/nsh/website/zh-CN/modules/nsh-base.md": "afd13adb49546b89d88a06e566fc8731ccda9f8ede74446f2331a7576559756d",
     "third-party/nsh/website/zh-CN/modules/nsh-canvas.md": "7737fe15ddb7da1cdc16c372c4ef01facac49a314c7cdd538521516963d63f3c",
     "third-party/nsh/website/zh-CN/modules/nsh-clipboard.md": "935d8f48b3b441e950a310fa2f354af479307bdce3476763bb6e19928ccafac9",
     "third-party/nsh/website/zh-CN/modules/nsh-desktop.md": "d69cebeb28d6d77643af44d53c01f2d5f135ea096b8381b0b631691fb74e05a9",
diff --git a/third-party/nsh/Cargo.lock b/third-party/nsh/Cargo.lock
index dc920dcccb..142c054766 100644
--- a/third-party/nsh/Cargo.lock
+++ b/third-party/nsh/Cargo.lock
@@ -3218,8 +3218,10 @@ name = "nsh-core"
 version = "0.2.0"
 dependencies = [
  "cap-std",
+ "crc32fast",
  "flate2",
  "futures-util",
+ "httpdate",
  "imagesize 0.15.0",
  "libc",
  "log",
diff --git a/third-party/nsh/Cargo.toml b/third-party/nsh/Cargo.toml
index 40f66ad49d..ffb28a569a 100644
--- a/third-party/nsh/Cargo.toml
+++ b/third-party/nsh/Cargo.toml
@@ -80,6 +80,9 @@ quote = "1"
 syn = { version = "2", features = ["full"] }
 ed25519-dalek = { version = "2.1", default-features = false, features = ["std", "fast", "zeroize"] }
 sha2 = "0.10"
+# The image disk cache (`nsh_core::image::DiskCache`): HTTP dates and entry checksums.
+httpdate = "1"
+crc32fast = "1"
 flate2 = "1"
 # Test payloads for the zlib module, at the versions LLRT's compression uses.
 brotli = { version = "9", default-features = false, features = ["std"] }
diff --git a/third-party/nsh/SOURCE.md b/third-party/nsh/SOURCE.md
index 401ef2d83f..d0d5e27ac3 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:
-`da048e750c87d12d58d07edc645618a370775edb`. The first upstream synchronization
+`b5e6e18a44a3009d757791d64f19df41bb3efe63`. The first upstream synchronization
 starts from `6bb0b7908043132067456e4a21dd57c90a0bdce5`, already included in the
 initial fork import.
 
diff --git a/third-party/nsh/crates/gpui/Cargo.lock b/third-party/nsh/crates/gpui/Cargo.lock
index a7feda2087..34692182eb 100644
--- a/third-party/nsh/crates/gpui/Cargo.lock
+++ b/third-party/nsh/crates/gpui/Cargo.lock
@@ -4057,6 +4057,12 @@ version = "1.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
 
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
 [[package]]
 name = "hybrid-array"
 version = "0.4.15"
@@ -5210,8 +5216,10 @@ name = "nsh-core"
 version = "0.2.0"
 dependencies = [
  "cap-std",
+ "crc32fast",
  "flate2",
  "futures-util",
+ "httpdate",
  "imagesize 0.15.0",
  "libc",
  "log",
diff --git a/third-party/nsh/crates/gpui/nsh-gpui-host/src/lib.rs b/third-party/nsh/crates/gpui/nsh-gpui-host/src/lib.rs
index 99fc2af2d4..0f7e394636 100644
--- a/third-party/nsh/crates/gpui/nsh-gpui-host/src/lib.rs
+++ b/third-party/nsh/crates/gpui/nsh-gpui-host/src/lib.rs
@@ -62,7 +62,15 @@ pub fn main() -> std::process::ExitCode {
             "--fps" => options.fps = true,
             // Where apps keep their data (`nsh test --target` gives each app
             // a fresh one); the system's data directory otherwise.
-            "--data-dir" => options.data_dir = args.next().map(PathBuf::from),
+            "--data-dir" => {
+                options.data_dir = args.next().map(PathBuf::from);
+                // Its caches too (downloaded images): a test's app starts with none.
+                if let Some(dir) = &options.data_dir {
+                    options.cache = nsh_engine::CacheOptions::at(nsh_engine::CacheLocation::Dir(
+                        dir.join("cache"),
+                    ));
+                }
+            }
             "--window-size" => {
                 if let Some((w, h)) = args.next().as_deref().and_then(|s| s.split_once('x')) {
                     options.size = (w.parse().unwrap_or(1200), h.parse().unwrap_or(800));
diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs
index 0b8273afa0..f9a2ab6f7c 100644
--- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs
+++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs
@@ -149,6 +149,10 @@ pub struct AppOptions {
     /// system's data directory ([`nsh_engine::data_dir`]); an app that
     /// embeds nsh gives its own (its data directory + `/nsh`, say).
     pub data_dir: Option<PathBuf>,
+    /// Where the apps it runs keep their caches (the images they
+    /// downloaded) and how much each keeps: by default the system's cache
+    /// directory, each app's in `<dir>/<app id>`. See [`AppView::set_cache`].
+    pub cache: nsh_engine::CacheOptions,
     /// The apps' storage (`nsh/storage`), overriding the `storage.json` in
     /// their directory of [`data_dir`](Self::data_dir).
     pub storage: Option<Arc<dyn nsh_engine::StorageBackend>>,
@@ -178,6 +182,7 @@ impl Default for AppOptions {
             fps: false,
             limits: Limits::default(),
             data_dir: None,
+            cache: Default::default(),
             storage: None,
             context: Default::default(),
             host: nsh_engine::HostChannel::default(),
@@ -332,6 +337,7 @@ impl AppView {
             ui_sets: ui_sets(),
             runtime: None,
             data_dir: options.data_dir.clone().or_else(nsh_engine::data_dir),
+            cache: options.cache.clone(),
             storage: options.storage.clone(),
             clock: Default::default(),
             context: options.context.clone(),
@@ -411,6 +417,13 @@ impl AppView {
         self.options.borrow_mut().limits = limits;
     }
 
+    /// Where the apps loaded from now on keep their caches (the images they
+    /// downloaded) and how much each keeps. The running app keeps the
+    /// caches it was loaded with.
+    pub fn set_cache(&self, cache: nsh_engine::CacheOptions) {
+        self.options.borrow_mut().cache = cache;
+    }
+
     /// Answers the picks of the apps loaded from now on
     /// (`Picker.pickImages`) with `picker`, the embedding app's own picker,
     /// instead of showing GPUI's open dialog; `None`: the dialog again.
diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs
index a7cb519f93..2a29739611 100644
--- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs
+++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs
@@ -73,6 +73,7 @@ use nsh_core::{
     ObjectPosition, PayloadValue, ResizeMode,
     access::Access,
     geometry::{DevicePixels, Points, Size},
+    image::{CacheHeaders, DiskCache, Fetched},
 };
 use rustc_hash::FxHashMap;
 
@@ -155,7 +156,12 @@ impl nsh_engine::Assets for AssetReader {
             return Err(format!("not an asset URI: {uri}"));
         }
         match resolve(uri, self.0.as_ref()) {
-            Some(location) => read(&location, &Access::default(), &mut |_, _| {}),
+            Some(Location::File(path)) => {
+                std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))
+            }
+            Some(location) => {
+                read(&location, &Access::default(), None, &mut |_, _| {}).map(|b| b.to_vec())
+            }
             None => Err(format!("{uri}: no asset location configured")),
         }
     }
@@ -180,20 +186,40 @@ enum LoadSource {
 }
 
 impl LoadSource {
-    fn read(&self, access: &Access, progress: &mut dyn FnMut(u64, u64)) -> Result<Vec<u8>, String> {
+    fn read(
+        &self,
+        access: &Access,
+        cache: Option<&DiskCache>,
+        progress: &mut dyn FnMut(u64, u64),
+    ) -> Result<ImageData, String> {
         match self {
-            Self::Location(location) => read(location, access, progress),
+            Self::Location(location) => read(location, access, cache, progress),
             Self::Asset { uri, reader } => {
                 let bytes = reader.read(uri)?;
                 if bytes.len() as u64 > MAX_BYTES {
                     return Err(format!("{uri}: image exceeds the encoded-byte limit"));
                 }
-                Ok(bytes)
+                Ok(bytes.into())
             }
         }
     }
 }
 
+/// The disk cache of what this surface's images download (`None`: none).
+pub(crate) fn set_disk_cache(surface: &Surface, cache: Option<DiskCache>) {
+    *surface.image_cache.borrow_mut() = cache;
+}
+
+/// The disk cache a load of `uri` goes through: the surface's, for remote
+/// images (`asset:/` URIs, served by the dev server during development,
+/// change as the app does: never kept).
+fn disk_cache(surface: &Surface, uri: &str) -> Option<DiskCache> {
+    let remote = uri.starts_with("http://") || uri.starts_with("https://");
+    remote
+        .then(|| surface.image_cache.borrow().clone())
+        .flatten()
+}
+
 /// Where this surface's images resolve `asset:/` URIs.
 pub(crate) fn set_asset_base(surface: &Rc<Surface>, base: Option<AssetBase>) {
     *surface.asset_base.borrow_mut() = base;
@@ -341,20 +367,22 @@ fn agent() -> &'static ureq::Agent {
     })
 }
 
-/// GETs `url`, following redirects one hop at a time: a hop the app is not
-/// granted is never requested.
-fn get(url: &str, access: &Access) -> Result<ureq::http::Response<ureq::Body>, String> {
+/// GETs `url` with `headers`, following redirects one hop at a time: a hop
+/// the app is not granted is never requested.
+fn get(
+    url: &str,
+    access: &Access,
+    headers: &[(&'static str, String)],
+) -> Result<ureq::http::Response<ureq::Body>, String> {
     let mut url = url.to_owned();
     for _ in 0..=MAX_REDIRECTS {
-        let response = agent()
-            .get(&url)
-            .call()
-            .map_err(|e| format!("{url}: {e}"))?;
+        let mut request = agent().get(&url);
+        for (name, value) in headers {
+            request = request.header(*name, value);
+        }
+        let response = request.call().map_err(|e| e.to_string())?;
         let status = response.status().as_u16();
         if !matches!(status, 301 | 302 | 303 | 307 | 308) {
-            if status >= 400 {
-                return Err(format!("{url}: HTTP {status}"));
-            }
             return Ok(response);
         }
         let location = response
@@ -364,36 +392,75 @@ fn get(url: &str, access: &Access) -> Result<ureq::http::Response<ureq::Body>, S
             .ok_or_else(|| format!("{url}: redirect without a Location"))?;
         url = access.media_redirect(&url, location)?;
     }
-    Err(format!("{url}: too many redirects"))
+    Err("too many redirects".to_owned())
+}
+
+/// GETs `url` with `headers` (a revalidation's conditions), following
+/// redirects one hop at a time (a hop the app is not granted is never
+/// requested), and reads the body as it arrives (`progress(loaded,
+/// total)`, total 0: unknown). Every status is a response: the cache
+/// decides what a `304` or a `5xx` means.
+fn download(
+    url: &str,
+    access: &Access,
+    headers: &[(&'static str, String)],
+    progress: &mut dyn FnMut(u64, u64),
+) -> Result<Fetched, String> {
+    let mut response = get(url, access, headers)?;
+    let status = response.status().as_u16();
+    let cache = CacheHeaders::from_lookup(|name| {
+        let values: Vec<&str> = response
+            .headers()
+            .get_all(name)
+            .iter()
+            .filter_map(|v| v.to_str().ok())
+            .collect();
+        (!values.is_empty()).then(|| values.join(", "))
+    });
+    if status >= 300 {
+        return Ok(Fetched::new(status, cache, Vec::new()));
+    }
+    let total = response.body().content_length().unwrap_or(0);
+    let mut reader = response.body_mut().with_config().limit(MAX_BYTES).reader();
+    let mut bytes = Vec::with_capacity(total.min(MAX_BYTES) as usize);
+    let mut chunk = [0u8; 16 << 10];
+    loop {
+        let n = reader.read(&mut chunk).map_err(|e| e.to_string())?;
+        if n == 0 {
+            break;
+        }
+        bytes.extend_from_slice(&chunk[..n]);
+        progress(bytes.len() as u64, total);
+    }
+    Ok(Fetched::new(status, cache, bytes))
 }
 
 /// Reads the bytes at `location` (blocking), calling `progress(loaded,
-/// total)` (total 0: unknown) as a download goes.
+/// total)` (total 0: unknown) as a download goes; a download through
+/// `cache` (the surface's disk cache, for `http(s)://` sources) when
+/// there is one.
 fn read(
     location: &Location,
     access: &Access,
+    cache: Option<&DiskCache>,
     progress: &mut dyn FnMut(u64, u64),
-) -> Result<Vec<u8>, String> {
+) -> Result<ImageData, String> {
     match location {
-        Location::File(path) => std::fs::read(path).map_err(|e| format!("{}: {e}", path.display())),
-        Location::Data(uri) => data_uri(uri),
-        Location::Memory(_, data) => Ok(data.to_vec()),
-        Location::Url(url) => {
-            let mut response = get(url, access)?;
-            let total = response.body().content_length().unwrap_or(0);
-            let mut reader = response.body_mut().with_config().limit(MAX_BYTES).reader();
-            let mut bytes = Vec::with_capacity(total.min(MAX_BYTES) as usize);
-            let mut chunk = [0u8; 16 << 10];
-            loop {
-                let n = reader.read(&mut chunk).map_err(|e| format!("{url}: {e}"))?;
-                if n == 0 {
-                    break;
-                }
-                bytes.extend_from_slice(&chunk[..n]);
-                progress(bytes.len() as u64, total);
+        Location::File(path) => std::fs::read(path)
+            .map(ImageData::from)
+            .map_err(|e| format!("{}: {e}", path.display())),
+        Location::Data(uri) => data_uri(uri).map(ImageData::from),
+        Location::Memory(_, data) => Ok(data.clone()),
+        Location::Url(url) => match cache {
+            Some(cache) => cache.load(url, |headers| download(url, access, headers, progress)),
+            None => {
+                download(url, access, &[], progress).and_then(|response| match response.status {
+                    200..=299 => Ok(response.body.into()),
+                    status => Err(format!("HTTP {status}")),
+                })
             }
-            Ok(bytes)
         }
+        .map_err(|e| format!("{url}: {e}")),
     }
 }
 
@@ -601,18 +668,19 @@ fn decode_svg(bytes: &[u8]) -> Result<Decoded, String> {
 }
 
 /// Decodes an encoded image (a worker): every frame of animations.
-fn decode(bytes: Vec<u8>) -> Result<Decoded, String> {
-    if nsh_core::svg::sniff(&bytes) {
-        return decode_svg(&bytes);
+fn decode(bytes: impl AsRef<[u8]>) -> Result<Decoded, String> {
+    let bytes = bytes.as_ref();
+    if nsh_core::svg::sniff(bytes) {
+        return decode_svg(bytes);
     }
-    if let Some(format) = animated_format(&bytes) {
-        match animation(&bytes, format) {
+    if let Some(format) = animated_format(bytes) {
+        match animation(bytes, format) {
             Ok(Some(decoded)) => return Ok(decoded),
             Ok(None) => {}
             Err(e) => log::debug!("image: not decoded as an animation: {e}"),
         }
     }
-    decode_still(&bytes)
+    decode_still(bytes)
 }
 
 /// Queues `job` on the worker pool (started on first use).
@@ -639,8 +707,14 @@ fn spawn(job: impl FnOnce() + Send + 'static) {
     let _ = tx.send(Box::new(job));
 }
 
-/// Loads `location` on a worker and hands the result to the main thread.
-fn work(key: String, source: LoadSource, access: Arc<Access>, progress: bool) {
+/// Loads `source` on a worker and hands the result to the main thread.
+fn work(
+    key: String,
+    source: LoadSource,
+    access: Arc<Access>,
+    cache: Option<DiskCache>,
+    progress: bool,
+) {
     let mut last: Option<Instant> = None;
     let mut report = |loaded: u64, total: u64| {
         let now = Instant::now();
@@ -654,7 +728,9 @@ fn work(key: String, source: LoadSource, access: Arc<Access>, progress: bool) {
             LOADER.with(|l| l.progress(&key, loaded, total));
         });
     };
-    let result = source.read(&access, &mut report).and_then(decode);
+    let result = source
+        .read(&access, cache.as_ref(), &mut report)
+        .and_then(decode);
     rt::invoke(move || finish(key, result.map(Image::shared)));
 }
 
@@ -1309,7 +1385,13 @@ fn source(surface: &Surface, uri: &str) -> Result<(String, LoadSource), String>
 /// Loads `uri` for `surface`; `done` runs on the main thread. Returns
 /// whether it already ran (cached, or a source that cannot load).
 fn fetch(surface: &Surface, uri: &str, progress: Option<Progress>, done: Done) -> bool {
-    fetch_at(surface, source(surface, uri), progress, done)
+    fetch_at(
+        surface,
+        source(surface, uri),
+        disk_cache(surface, uri),
+        progress,
+        done,
+    )
 }
 
 /// Loads `source` for `surface` as [`fetch`] does: from its bytes, when it
@@ -1326,6 +1408,7 @@ fn fetch_source(
             fetch_at(
                 surface,
                 Ok((location.key(), LoadSource::Location(location))),
+                None,
                 progress,
                 done,
             )
@@ -1339,6 +1422,7 @@ fn fetch_source(
 fn fetch_at(
     surface: &Surface,
     source: Result<(String, LoadSource), String>,
+    cache: Option<DiskCache>,
     progress: Option<Progress>,
     done: Done,
 ) -> bool {
@@ -1379,7 +1463,7 @@ fn fetch_at(
     });
     if start {
         let access = surface.access.borrow().clone();
-        spawn(move || work(key, source, access, listens));
+        spawn(move || work(key, source, access, cache, listens));
     }
     false
 }
@@ -2103,8 +2187,12 @@ mod tests {
             data_uri(&source.uri).is_err(),
             "the key is not a data URI payload"
         );
-        let bytes = read(&location, &Access::default(), &mut |_, _| {}).unwrap();
-        assert_eq!(bytes, SVG_ICON);
+        let bytes = read(&location, &Access::default(), None, &mut |_, _| {}).unwrap();
+        assert_eq!(bytes.as_ref(), SVG_ICON);
+        let Location::Memory(_, original) = &location else {
+            unreachable!()
+        };
+        assert_eq!(&bytes, original, "inline reads keep shared original bytes");
         assert_eq!(decode(bytes).unwrap().size, (24, 24));
     }
 
@@ -2128,12 +2216,57 @@ mod tests {
             assert!(loader.cached(&raw.key()).is_none());
             assert!(!loader.inflight.borrow().contains_key(&raw.key()));
             assert_eq!(
-                read(&memory, &Access::default(), &mut |_, _| {}).unwrap(),
+                read(&memory, &Access::default(), None, &mut |_, _| {})
+                    .unwrap()
+                    .as_ref(),
                 SVG_ICON
             );
         }
     }
 
+    #[test]
+    fn disk_cache_is_remote_only_and_cleared_with_load() {
+        let surface = Surface::new(nsh_engine::Environment::default());
+        set_asset_base(
+            &surface,
+            Some(AssetBase::Http("https://dev.test/assets/".into())),
+        );
+        let cache = DiskCache::open("first-engine-cache", nsh_core::ByteSize::mib(1)).unwrap();
+        set_disk_cache(&surface, Some(cache));
+        let retained = disk_cache(&surface, "https://example.test/image.png").unwrap();
+        assert_eq!(retained.dir(), std::path::Path::new("first-engine-cache"));
+        assert!(disk_cache(&surface, "http://example.test/image.png").is_some());
+        assert!(matches!(
+            resolve("asset:/image.png", surface.asset_base.borrow().as_ref()),
+            Some(Location::Url(_))
+        ));
+        for uri in [
+            "asset:/image.png",
+            "data:image/png;base64,AA==",
+            "blob:local",
+            "file:/image.png",
+        ] {
+            assert!(
+                disk_cache(&surface, uri).is_none(),
+                "{uri} is never disk cached"
+            );
+        }
+        surface.stop();
+        assert!(disk_cache(&surface, "https://example.test/image.png").is_none());
+        // A running worker retains its original handle across a replacement load.
+        set_disk_cache(
+            &surface,
+            DiskCache::open("second-engine-cache", nsh_core::ByteSize::mib(1)),
+        );
+        assert_eq!(retained.dir(), std::path::Path::new("first-engine-cache"));
+        assert_eq!(
+            disk_cache(&surface, "https://example.test/image.png")
+                .unwrap()
+                .dir(),
+            std::path::Path::new("second-engine-cache")
+        );
+    }
+
     #[test]
     fn inline_bytes_bypass_asset_resolution_and_keep_load_guards() {
         let surface = Surface::new(nsh_engine::Environment::default());
@@ -2393,7 +2526,7 @@ mod tests {
 
     #[test]
     fn svg_decodes_oversampled() {
-        let logo = decode(SVG_LOGO.to_vec()).unwrap();
+        let logo = decode(SVG_LOGO).unwrap();
         assert_eq!(logo.points, Some(Size::new(Points(48.0), Points(24.0))));
         assert_eq!(logo.size, (48, 24), "intrinsic size in points");
         let pixels = logo.image.size(0);
@@ -2409,11 +2542,11 @@ mod tests {
         assert!((127..=128).contains(&a), "alpha {a}");
         // Capped: a large SVG draws at most 2048 pixels a side.
         let big = br#"<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="250"/>"#;
-        let big = decode(big.to_vec()).unwrap();
+        let big = decode(big).unwrap();
         let pixels = big.image.size(0);
         assert_eq!((pixels.width.0, pixels.height.0), (2048, 512));
         assert_eq!(big.size, (1000, 250));
-        assert!(decode(b"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect".to_vec()).is_err());
+        assert!(decode(b"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect").is_err());
     }
 
     #[test]
@@ -2424,7 +2557,7 @@ mod tests {
             scales: nsh_core::ImageScales::NONE,
             data: None,
         };
-        let logo = decode(SVG_LOGO.to_vec()).unwrap();
+        let logo = decode(SVG_LOGO).unwrap();
         assert_eq!(natural(logo.size, logo.points, Some(&source)), (48.0, 24.0));
         // The picture keeps it for `onLoad` and `Image.getSize`.
         let picture = Image::shared(logo);
@@ -2433,7 +2566,7 @@ mod tests {
         assert_eq!(picture.pixels, (192, 96));
         // Fractions lay out as they are; reports round them.
         let odd = br#"<svg xmlns="http://www.w3.org/2000/svg" width="10.5" height="10"/>"#;
-        let odd = decode(odd.to_vec()).unwrap();
+        let odd = decode(odd).unwrap();
         assert_eq!(odd.size, (11, 10));
         assert_eq!(natural(odd.size, odd.points, None), (10.5, 10.0));
     }
@@ -2444,7 +2577,7 @@ mod tests {
         // still draws neither (no fonts loaded, the resolver resolves
         // nothing).
         let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><text x="0" y="9" font-size="10" fill="#f00">NSH</text><image width="20" height="10" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="/></svg>"##;
-        let decoded = decode(svg.to_vec()).unwrap();
+        let decoded = decode(svg).unwrap();
         assert_eq!(decoded.size, (20, 10));
         let bytes = decoded.image.as_bytes(0).unwrap();
         assert!(bytes.iter().all(|&b| b == 0), "nothing drawn");
@@ -2453,7 +2586,7 @@ mod tests {
     #[test]
     fn svg_tints_through_alpha() {
         // A variant tinted red: the SVG's shape in red, the rest transparent.
-        let icon = decode(SVG_ICON.to_vec()).unwrap();
+        let icon = decode(SVG_ICON).unwrap();
         let size = icon.image.size(0);
         let size = (size.width.0 as u32, size.height.0 as u32);
         let ops = Ops {
diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/lib.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/lib.rs
index cdddf615cc..83ae6810ca 100644
--- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/lib.rs
+++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/lib.rs
@@ -69,7 +69,10 @@ pub use nsh_core::{
     platform::{Appearance, Locale},
     ui::{AdaptiveColor, Color, Hsla, Rgba},
 };
-pub use nsh_engine::{App, AppError, AppErrorKind, Bundle, Limits, PackageInfo, Root};
+pub use nsh_engine::{
+    App, AppError, AppErrorKind, Bundle, ByteSize, CacheLocation, CacheOptions, CachePolicy,
+    Limits, PackageInfo, Root,
+};
 /// Signed packages: verifying, unpacking and accepting them
 /// (`docs/dev/signed-packages.md`).
 #[cfg(feature = "js")]
diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs
index ef57b9d612..e67cb7008b 100644
--- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs
+++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs
@@ -178,6 +178,8 @@ pub(crate) struct Surface {
     pub(crate) asset_base: RefCell<Option<crate::image::AssetBase>>,
     /// The current load's source for image `asset:/` URIs.
     pub(crate) image_assets: RefCell<crate::image::AssetSource>,
+    /// The disk cache of downloaded images ([`crate::image::set_disk_cache`]).
+    pub(crate) image_cache: RefCell<Option<nsh_core::image::DiskCache>>,
     /// What the running app may reach (checked for image redirects).
     pub(crate) access: RefCell<Arc<nsh_core::access::Access>>,
     /// Videos, web views and registered native components.
@@ -249,6 +251,7 @@ impl Surface {
             generation: Cell::new(0),
             asset_base: RefCell::new(None),
             image_assets: RefCell::default(),
+            image_cache: RefCell::new(None),
             access: RefCell::new(Arc::default()),
             native: Default::default(),
             keymap: Default::default(),
@@ -338,6 +341,8 @@ impl Surface {
             },
         ) {
             Ok(engine) => {
+                // The app's downloaded images go through its disk cache.
+                crate::image::set_disk_cache(self, engine.image_cache());
                 // The test moves its clock through the engine.
                 #[cfg(feature = "dev")]
                 if let Some(model) = &*self.test_model.borrow() {
@@ -359,6 +364,7 @@ impl Surface {
     pub(crate) fn stop(&self) {
         self.generation.set(self.generation.get() + 1);
         *self.image_assets.borrow_mut() = crate::image::AssetSource::Base;
+        *self.image_cache.borrow_mut() = None;
         // Dropping the engine joins its thread.
         self.control.borrow_mut().take();
         self.engine.borrow_mut().take();
diff --git a/third-party/nsh/crates/nsh-base/src/lib.rs b/third-party/nsh/crates/nsh-base/src/lib.rs
index bd7fd6947c..40a44e25f2 100644
--- a/third-party/nsh/crates/nsh-base/src/lib.rs
+++ b/third-party/nsh/crates/nsh-base/src/lib.rs
@@ -2,7 +2,8 @@
 //! written in Rust, with no look of its own (as `gpui-base` is GPUI Kit's):
 //! controls (`Button`, `Checkbox`, `Radio` in a `RadioGroup`, `Switch`,
 //! `Tab` in `Tabs`), a progress bar (`Progress`), disclosure (`Collapsible`), navigation (stacks and pages), dialogs, popups on the core's overlays
-//! (`Popover`, `Menu`, `ContextMenu`, `Tooltip`, `Sheet`), safe areas,
+//! (`Popover`, `Menu`, `ContextMenu`, `Tooltip`, `Sheet`), toasts (`Toast`,
+//! `ToastStack`, `ToastManager`), safe areas,
 //! selection areas,
 //! the system's dialogs and the software keyboard, and the theme's semantic
 //! tokens (`Theme`, as `gpui-base`'s).
@@ -33,6 +34,7 @@ mod switch;
 mod tabs;
 pub mod text_view;
 mod theme;
+mod toast;
 mod tooltip;
 
 use std::sync::Arc;
@@ -86,6 +88,11 @@ pub use crate::{
         TextViewParts, TextViewProps, TextViewStyles, text_view,
     },
     theme::{DEFAULT_THEME, Theme, ThemeError, theme, theme_overrides, tokens_of},
+    toast::{
+        Anchor, SwipeDirection, Toast, ToastAdvance, ToastElement, ToastEvent, ToastManager,
+        ToastMotion, ToastOptions, ToastProps, ToastStack, ToastStackElement, ToastStackEvent,
+        ToastStackProps, ToastStackState, ToastTransitionStatus, toast, toast_stack,
+    },
     tooltip::{Tooltip, TooltipElement, TooltipEvent, TooltipProps, tooltip},
 };
 
@@ -120,6 +127,9 @@ pub fn registry() -> Registry {
         .register::<Tab>()
         .register::<Tabs>()
         .register::<TextView>()
+        .register::<Toast>()
+        .register::<ToastStack>()
+        .class::<toast::ToastManagerClass>()
         .module::<AlertDialog>()
         .module::<Keyboard>()
         .module::<TextView>()
@@ -172,6 +182,8 @@ mod tests {
                 "Tab",
                 "Tabs",
                 "TextView",
+                "Toast",
+                "ToastStack",
                 "Tooltip"
             ]
         );
diff --git a/third-party/nsh/crates/nsh-base/src/toast/js.rs b/third-party/nsh/crates/nsh-base/src/toast/js.rs
new file mode 100644
index 0000000000..41626ecc76
--- /dev/null
+++ b/third-party/nsh/crates/nsh-base/src/toast/js.rs
@@ -0,0 +1,216 @@
+//! The TS face of the toast primitives: `ToastMotion` and `ToastOptions` as
+//! plain objects (milliseconds and points), and `ToastManager` as a class
+//! over `ToastManager<string, unknown>` whose clock is the engine's.
+
+use std::time::Duration;
+
+use nsh_core::{
+    JsonValue, Points,
+    time::now,
+    ui::{JsHost, JsIn, JsOwned, JsValue, PropError, TsTypes, prelude::*},
+};
+
+use super::{ToastMotion as Motion, ToastOptions as Options, ToastTransitionStatus};
+
+/// Milliseconds as a duration (none for a negative or not finite number).
+fn ms(n: f64) -> Option<Duration> {
+    (n.is_finite() && n >= 0.0).then(|| Duration::from_secs_f64(n / 1000.0))
+}
+
+/// Motion tokens of a toast stack and its manager (`ToastMotion.sonner()`
+/// by default; omitted fields keep Sonner's).
+#[derive(Debug, Clone, Default, PartialEq, JsObject)]
+pub(crate) struct ToastMotion {
+    /// Milliseconds: the time scale of the stack's motion (the manager's
+    /// deadline for a toast to be present, the stack's spring response).
+    duration: Option<f64>,
+    /// Milliseconds an ending toast stays mounted.
+    exit_duration: Option<f64>,
+    /// The visible distance between collapsed layers.
+    collapsed_peek: Option<Points>,
+    /// The distance between expanded items.
+    expanded_gap: Option<Points>,
+    /// The fraction of the width each collapsed layer loses.
+    collapsed_scale_step: Option<f64>,
+    /// How many layers show while the stack is collapsed.
+    collapsed_visible: Option<usize>,
+}
+
+impl From<ToastMotion> for Motion {
+    fn from(m: ToastMotion) -> Self {
+        let d = Motion::sonner();
+        Motion {
+            duration: m.duration.and_then(ms).unwrap_or(d.duration),
+            exit_duration: m.exit_duration.and_then(ms).unwrap_or(d.exit_duration),
+            collapsed_peek: m.collapsed_peek.unwrap_or(d.collapsed_peek),
+            expanded_gap: m.expanded_gap.unwrap_or(d.expanded_gap),
+            collapsed_scale_step: m
+                .collapsed_scale_step
+                .map_or(d.collapsed_scale_step, |s| s as f32),
+            collapsed_visible: m.collapsed_visible.unwrap_or(d.collapsed_visible),
+        }
+    }
+}
+
+impl From<Motion> for ToastMotion {
+    fn from(m: Motion) -> Self {
+        Self {
+            duration: Some(m.duration.as_secs_f64() * 1000.0),
+            exit_duration: Some(m.exit_duration.as_secs_f64() * 1000.0),
+            collapsed_peek: Some(m.collapsed_peek),
+            expanded_gap: Some(m.expanded_gap),
+            collapsed_scale_step: Some(f64::from(m.collapsed_scale_step)),
+            collapsed_visible: Some(m.collapsed_visible),
+        }
+    }
+}
+
+/// TS: a `ToastMotion` object.
+impl JsValue for Motion {
+    fn ts_type(types: &mut TsTypes) -> String {
+        ToastMotion::ts_type(types)
+    }
+
+    fn from_js(value: JsIn<'_>) -> Result<Self, PropError> {
+        if value.is_nullish() {
+            return Ok(Motion::sonner());
+        }
+        ToastMotion::from_js(value).map(Into::into)
+    }
+
+    fn to_js<'a>(&self, host: &'a dyn JsHost) -> Result<JsOwned<'a>, PropError> {
+        ToastMotion::from(*self).to_js(host)
+    }
+}
+
+/// Options of a toast entering a `ToastManager`.
+#[derive(Debug, Clone, Default, PartialEq, JsObject)]
+pub(crate) struct ToastOptions {
+    /// Milliseconds it is present before it is dismissed by itself;
+    /// `null`, `Infinity` or left out: never.
+    timeout: Option<f64>,
+}
+
+/// A mounted toast of a `ToastManager`.
+#[derive(Debug, Clone, Default, PartialEq, JsObject)]
+pub(crate) struct ToastEntry {
+    /// Its id.
+    id: String,
+    /// The app's value.
+    value: JsonValue,
+    /// Its lifecycle phase.
+    status: ToastTransitionStatus,
+}
+
+/// What a `ToastManager`'s `advance` changed.
+#[derive(Debug, Clone, Default, PartialEq, JsObject)]
+pub(crate) struct ToastAdvance {
+    /// A mounted toast changed phase or membership.
+    changed: bool,
+    /// The toasts whose enter transition completed.
+    presented: Vec<String>,
+    /// The toasts that began their exit transition (their timeout ran out).
+    ending: Vec<String>,
+    /// The toasts removed after their exit transition.
+    removed: Vec<ToastEntry>,
+}
+
+/// Ordered toast storage, lifecycle, auto-hide, limits and exit
+/// coordination, headless, as `gpui-base`'s `ToastManager`: the app keeps
+/// its toasts here, renders `visible(limit)` as `Toast`s in a
+/// `ToastStack` with their `status`, and calls `advance` on a timer while
+/// any is mounted (`paused` while the stack is expanded). Its clock is the
+/// engine's.
+#[class]
+#[derive(Debug)]
+pub(crate) struct ToastManager {
+    inner: super::ToastManager<String, JsonValue>,
+}
+
+fn entry((id, value, status): (&String, &JsonValue, ToastTransitionStatus)) -> ToastEntry {
+    ToastEntry {
+        id: id.clone(),
+        value: value.clone(),
+        status,
+    }
+}
+
+#[class]
+impl ToastManager {
+    /// A manager whose enter and exit take `motion`'s durations (Sonner's
+    /// without one).
+    pub(crate) fn new(motion: Option<Motion>) -> Self {
+        Self {
+            inner: super::ToastManager::new(motion.unwrap_or_default()),
+        }
+    }
+
+    /// Adds the newest toast, replacing one with the same `id`, whose value
+    /// it returns.
+    pub(crate) fn push(
+        &mut self,
+        id: String,
+        value: JsonValue,
+        options: Option<ToastOptions>,
+    ) -> Option<JsonValue> {
+        let timeout = options.and_then(|o| o.timeout).and_then(ms);
+        self.inner.push(id, value, Options::timeout(timeout), now())
+    }
+
+    /// Begins a toast's exit transition; whether its state changed.
+    pub(crate) fn dismiss(&mut self, id: String) -> bool {
+        self.inner.dismiss(&id, now())
+    }
+
+    /// Begins the exit transition of every toast that is not ending; their
+    /// ids.
+    pub(crate) fn dismiss_all(&mut self) -> Vec<String> {
+        self.inner.dismiss_all(now())
+    }
+
+    /// Advances the lifecycle to now; present toasts' timeouts stand still
+    /// while `paused`.
+    pub(crate) fn advance(&mut self, paused: Option<bool>) -> ToastAdvance {
+        let a = self.inner.advance(now(), paused.unwrap_or(false));
+        ToastAdvance {
+            changed: a.changed,
+            presented: a.presented,
+            ending: a.ending,
+            removed: a
+                .removed
+                .into_iter()
+                .map(|(id, value)| ToastEntry {
+                    id,
+                    value,
+                    status: ToastTransitionStatus::Ending,
+                })
+                .collect(),
+        }
+    }
+
+    /// The mounted toasts, oldest first.
+    pub(crate) fn entries(&self) -> Vec<ToastEntry> {
+        self.inner.iter().map(entry).collect()
+    }
+
+    /// The newest `limit` toasts that are not ending, and the ending ones,
+    /// oldest first.
+    pub(crate) fn visible(&self, limit: usize) -> Vec<ToastEntry> {
+        self.inner.visible(limit).map(entry).collect()
+    }
+
+    /// A mounted toast's value.
+    pub(crate) fn get(&self, id: String) -> Option<JsonValue> {
+        self.inner.get(&id).cloned()
+    }
+
+    /// The number of mounted toasts, ending ones included.
+    pub(crate) fn len(&self) -> usize {
+        self.inner.len()
+    }
+
+    /// No toast is mounted.
+    pub(crate) fn is_empty(&self) -> bool {
+        self.inner.is_empty()
+    }
+}
diff --git a/third-party/nsh/crates/nsh-base/src/toast/manager.rs b/third-party/nsh/crates/nsh-base/src/toast/manager.rs
new file mode 100644
index 0000000000..5d2a66d880
--- /dev/null
+++ b/third-party/nsh/crates/nsh-base/src/toast/manager.rs
@@ -0,0 +1,355 @@
+//! `ToastManager`: ordered toast storage and lifecycle, headless, as
+//! `gpui-base`'s: a toast enters `Starting`, is `Present` once the motion's
+//! duration elapsed, counts its timeout down while present and not paused,
+//! and is unmounted `exit_duration` after it began `Ending`.
+
+use std::{collections::VecDeque, time::Duration};
+
+use nsh_core::{Points, time::Instant};
+
+/// The lifecycle phase of a toast, which the app's presentation follows
+/// (a [`super::Toast`]'s `transitionStatus`).
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, nsh_core::ui::PropEnum)]
+pub enum ToastTransitionStatus {
+    /// Just added: it may run its enter transition.
+    #[default]
+    Starting,
+    /// Fully present.
+    Present,
+    /// Closing: it stays mounted until its exit transition completes.
+    Ending,
+}
+
+/// Motion tokens of an unstyled toast stack ([`super::ToastStack`]) and
+/// its [`ToastManager`].
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct ToastMotion {
+    /// The time scale of the stack's motion: the manager's deadline for a
+    /// toast to be present, the stack's spring response for its reflow.
+    pub duration: Duration,
+    /// How long an ending toast stays mounted.
+    pub exit_duration: Duration,
+    /// The visible distance between collapsed layers.
+    pub collapsed_peek: Points,
+    /// The distance between expanded items.
+    pub expanded_gap: Points,
+    /// The fraction of the width each collapsed layer loses.
+    pub collapsed_scale_step: f32,
+    /// How many layers show while the stack is collapsed.
+    pub collapsed_visible: usize,
+}
+
+impl ToastMotion {
+    /// The motion of the shadcn / Sonner toaster.
+    pub fn sonner() -> Self {
+        Self {
+            duration: Duration::from_millis(400),
+            exit_duration: Duration::from_millis(200),
+            collapsed_peek: Points(14.0),
+            expanded_gap: Points(14.0),
+            collapsed_scale_step: 0.05,
+            collapsed_visible: 3,
+        }
+    }
+}
+
+impl Default for ToastMotion {
+    fn default() -> Self {
+        Self::sonner()
+    }
+}
+
+/// Options of a toast entering a [`ToastManager`].
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+#[non_exhaustive]
+pub struct ToastOptions {
+    /// Present time before it is dismissed by itself; `None`: never.
+    pub timeout: Option<Duration>,
+}
+
+impl ToastOptions {
+    /// Options with `timeout`.
+    pub fn timeout(timeout: Option<Duration>) -> Self {
+        Self { timeout }
+    }
+}
+
+#[derive(Debug, Clone)]
+struct ManagedToast<I, T> {
+    id: I,
+    value: T,
+    status: ToastTransitionStatus,
+    timeout_remaining: Option<Duration>,
+    transition_elapsed: Duration,
+    last_advance: Instant,
+}
+
+/// What [`ToastManager::advance`] changed.
+#[derive(Debug, Clone, PartialEq)]
+#[non_exhaustive]
+pub struct ToastAdvance<I, T> {
+    /// A mounted toast changed phase or membership.
+    pub changed: bool,
+    /// The toasts whose enter transition completed.
+    pub presented: Vec<I>,
+    /// The toasts that began their exit transition (their timeout ran out).
+    pub ending: Vec<I>,
+    /// The toasts removed after their exit transition, with their values.
+    pub removed: Vec<(I, T)>,
+}
+
+/// Ordered toast storage, lifecycle, auto-hide, limits and exit
+/// coordination, oldest first (TS: the `ToastManager` class).
+#[derive(Debug, Clone)]
+pub struct ToastManager<I, T> {
+    entries: VecDeque<ManagedToast<I, T>>,
+    transition_duration: Duration,
+    exit_duration: Duration,
+}
+
+impl<I, T> ToastManager<I, T> {
+    /// A manager whose enter and exit take `motion`'s durations.
+    pub fn new(motion: ToastMotion) -> Self {
+        Self {
+            entries: VecDeque::new(),
+            transition_duration: motion.duration,
+            exit_duration: motion.exit_duration,
+        }
+    }
+
+    /// The number of mounted toasts, ending ones included.
+    pub fn len(&self) -> usize {
+        self.entries.len()
+    }
+
+    /// No toast is mounted.
+    pub fn is_empty(&self) -> bool {
+        self.entries.is_empty()
+    }
+
+    /// The mounted toasts' ids, values and phases, oldest first.
+    pub fn iter(&self) -> impl DoubleEndedIterator<Item = (&I, &T, ToastTransitionStatus)> {
+        self.entries.iter().map(|e| (&e.id, &e.value, e.status))
+    }
+
+    /// The newest `limit` toasts that are not ending, and the ending ones,
+    /// oldest first.
+    pub fn visible(&self, limit: usize) -> impl Iterator<Item = (&I, &T, ToastTransitionStatus)> {
+        let first = self
+            .entries
+            .iter()
+            .filter(|e| e.status != ToastTransitionStatus::Ending)
+            .count()
+            .saturating_sub(limit);
+        let mut active = 0usize;
+        self.entries.iter().filter_map(move |e| {
+            let visible = if e.status == ToastTransitionStatus::Ending {
+                true
+            } else {
+                let keep = active >= first;
+                active += 1;
+                keep
+            };
+            visible.then_some((&e.id, &e.value, e.status))
+        })
+    }
+
+    /// A mounted toast's value.
+    pub fn get(&self, id: &I) -> Option<&T>
+    where
+        I: Eq,
+    {
+        self.entries
+            .iter()
+            .find_map(|e| (&e.id == id).then_some(&e.value))
+    }
+}
+
+impl<I: Clone + Eq, T> ToastManager<I, T> {
+    /// Adds the newest toast, replacing one with the same id (whose value
+    /// it returns).
+    pub fn push(&mut self, id: I, value: T, options: ToastOptions, now: Instant) -> Option<T> {
+        let replaced = self
+            .entries
+            .iter()
+            .position(|e| e.id == id)
+            .and_then(|i| self.entries.remove(i))
+            .map(|e| e.value);
+        self.entries.push_back(ManagedToast {
+            id,
+            value,
+            status: ToastTransitionStatus::Starting,
+            timeout_remaining: options.timeout,
+            transition_elapsed: Duration::ZERO,
+            last_advance: now,
+        });
+        replaced
+    }
+
+    /// Begins a toast's exit transition; whether its state changed.
+    pub fn dismiss(&mut self, id: &I, now: Instant) -> bool {
+        let Some(e) = self.entries.iter_mut().find(|e| &e.id == id) else {
+            return false;
+        };
+        if e.status == ToastTransitionStatus::Ending {
+            return false;
+        }
+        e.status = ToastTransitionStatus::Ending;
+        e.transition_elapsed = Duration::ZERO;
+        e.last_advance = now;
+        true
+    }
+
+    /// Begins the exit transition of every toast that is not ending; their
+    /// ids.
+    pub fn dismiss_all(&mut self, now: Instant) -> Vec<I> {
+        let mut changed = Vec::new();
+        for e in &mut self.entries {
+            if e.status != ToastTransitionStatus::Ending {
+                e.status = ToastTransitionStatus::Ending;
+                e.transition_elapsed = Duration::ZERO;
+                e.last_advance = now;
+                changed.push(e.id.clone());
+            }
+        }
+        changed
+    }
+
+    /// Advances the lifecycle to `now`; present toasts' timeouts stand
+    /// still while `paused`.
+    pub fn advance(&mut self, now: Instant, paused: bool) -> ToastAdvance<I, T> {
+        let (mut ending, mut presented, mut changed) = (Vec::new(), Vec::new(), false);
+        for e in &mut self.entries {
+            let delta = now.saturating_duration_since(e.last_advance);
+            e.last_advance = now;
+            match e.status {
+                ToastTransitionStatus::Starting => {
+                    e.transition_elapsed += delta;
+                    if e.transition_elapsed >= self.transition_duration {
+                        e.status = ToastTransitionStatus::Present;
+                        e.transition_elapsed = Duration::ZERO;
+                        presented.push(e.id.clone());
+                        changed = true;
+                    }
+                }
+                ToastTransitionStatus::Present if !paused => {
+                    if let Some(remaining) = &mut e.timeout_remaining {
+                        *remaining = remaining.saturating_sub(delta);
+                        if remaining.is_zero() {
+                            e.status = ToastTransitionStatus::Ending;
+                            e.transition_elapsed = Duration::ZERO;
+                            ending.push(e.id.clone());
+                            changed = true;
+                        }
+                    }
+                }
+                ToastTransitionStatus::Ending => e.transition_elapsed += delta,
+                ToastTransitionStatus::Present => {}
+            }
+        }
+        let mut removed = Vec::new();
+        let mut i = 0;
+        while i < self.entries.len() {
+            let e = &self.entries[i];
+            if e.status == ToastTransitionStatus::Ending
+                && e.transition_elapsed >= self.exit_duration
+            {
+                let e = self.entries.remove(i).expect("the index is in range");
+                removed.push((e.id, e.value));
+                changed = true;
+            } else {
+                i += 1;
+            }
+        }
+        ToastAdvance {
+            changed,
+            presented,
+            ending,
+            removed,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn the_timeout_pauses_and_removal_waits_for_the_exit() {
+        let motion = ToastMotion::sonner();
+        let start = Instant::now();
+        let mut manager = ToastManager::new(motion);
+        manager.push(
+            "a",
+            1,
+            ToastOptions::timeout(Some(Duration::from_secs(5))),
+            start,
+        );
+        manager.advance(start + motion.duration, false);
+        manager.advance(start + motion.duration + Duration::from_secs(4), true);
+        assert_eq!(
+            manager.iter().next().unwrap().2,
+            ToastTransitionStatus::Present
+        );
+        let ending = manager.advance(start + motion.duration + Duration::from_secs(9), false);
+        assert_eq!(ending.ending, vec!["a"]);
+        assert!(ending.removed.is_empty());
+        let removed = manager.advance(
+            start + motion.duration + motion.exit_duration + Duration::from_secs(9),
+            false,
+        );
+        assert_eq!(removed.removed, vec![("a", 1)]);
+    }
+
+    #[test]
+    fn the_limit_keeps_ending_toasts_mounted() {
+        let now = Instant::now();
+        let mut manager = ToastManager::new(ToastMotion::sonner());
+        for id in ["a", "b", "c"] {
+            manager.push(id, id, ToastOptions::default(), now);
+        }
+        manager.dismiss(&"a", now);
+        assert_eq!(
+            manager.visible(1).map(|(id, ..)| *id).collect::<Vec<_>>(),
+            vec!["a", "c"]
+        );
+    }
+
+    #[test]
+    fn a_duplicate_id_replaces_and_becomes_the_newest() {
+        let now = Instant::now();
+        let mut manager = ToastManager::new(ToastMotion::sonner());
+        manager.push("a", 1, ToastOptions::default(), now);
+        manager.push("b", 2, ToastOptions::default(), now);
+        assert_eq!(manager.push("a", 3, ToastOptions::default(), now), Some(1));
+        assert_eq!(
+            manager
+                .iter()
+                .map(|(id, v, _)| (*id, *v))
+                .collect::<Vec<_>>(),
+            vec![("b", 2), ("a", 3)]
+        );
+        assert_eq!(manager.get(&"a"), Some(&3));
+        assert_eq!(manager.len(), 2);
+    }
+
+    #[test]
+    fn a_new_toast_has_its_own_clock() {
+        let motion = ToastMotion::sonner();
+        let start = Instant::now();
+        let mut manager = ToastManager::new(motion);
+        manager.push("old", 1, ToastOptions::default(), start);
+        let later = start + Duration::from_secs(60);
+        manager.push("new", 2, ToastOptions::default(), later);
+        manager.advance(later + Duration::from_millis(50), false);
+        assert_eq!(
+            manager
+                .iter()
+                .find(|(id, ..)| **id == "new")
+                .map(|(.., s)| s),
+            Some(ToastTransitionStatus::Starting)
+        );
+        assert_eq!(manager.dismiss_all(later), vec!["old", "new"]);
+        assert!(!manager.dismiss(&"old", later));
+    }
+}
diff --git a/third-party/nsh/crates/nsh-base/src/toast/mod.rs b/third-party/nsh/crates/nsh-base/src/toast/mod.rs
new file mode 100644
index 0000000000..65a9a6b31f
--- /dev/null
+++ b/third-party/nsh/crates/nsh-base/src/toast/mod.rs
@@ -0,0 +1,98 @@
+//! Toasts, as `gpui-base`'s: [`Toast`], an unstyled semantic root whose
+//! `transition_status` the app's presentation follows; [`ToastStack`],
+//! which measures its toasts and lays them out as overlapping layers or,
+//! expanded (hovered or pressed), a list, and moves them with springs;
+//! [`ToastManager`], headless ordered storage and lifecycle (enter, timeout,
+//! pause, exit), a class in TS; [`ToastMotion`], their tokens.
+//!
+//! NSH adds what touch screens and the platforms' screen readers need, on
+//! the same primitives: a toast is swiped away (`swipe_direction`, its
+//! `DismissRequest`), the stack hears Escape and the Android back button
+//! (its `Cancel`), a finger on the stack expands it as the pointer does
+//! (and so pauses the manager's timeouts, which the app passes as
+//! `paused`), and a toast's `accessibility_label` is read as it shows.
+//!
+//! The imperative layer (`toast("Copied")` from anywhere, with a title, a
+//! description and an action) is a styled library's, as `gpui-component`'s
+//! notifications are: an app builds it on these (the gallery's
+//! `examples/gallery/toast.tsx`).
+
+mod js;
+mod manager;
+mod root;
+mod stack;
+
+pub(crate) use js::ToastManager as ToastManagerClass;
+pub use manager::{ToastAdvance, ToastManager, ToastMotion, ToastOptions, ToastTransitionStatus};
+pub use root::{SwipeDirection, Toast, ToastElement, ToastEvent, ToastProps, toast};
+pub use stack::{
+    Anchor, ToastStack, ToastStackElement, ToastStackEvent, ToastStackProps, ToastStackState,
+    toast_stack,
+};
+
+/// The website's example (`website/elements/toast.md`), compiled.
+#[cfg(test)]
+#[allow(dead_code)]
+mod example {
+    use std::time::Duration;
+
+    use nsh_core::{time::now, ui::prelude::*};
+
+    use crate::{
+        Anchor, ToastManager, ToastMotion, ToastOptions, ToastStackState, theme, toast, toast_stack,
+    };
+
+    pub(super) fn toasts() -> impl IntoElement {
+        let manager = stored(ToastManager::<u64, SharedString>::new(ToastMotion::sonner()));
+        let shown = signal(Vec::new());
+        let state = ToastStackState::new();
+        let sync = move || {
+            shown.set(manager.with(|m| {
+                m.visible(3)
+                    .map(|(id, t, s)| (*id, t.clone(), s))
+                    .collect::<Vec<_>>()
+            }))
+        };
+        // Advance the lifecycle while toasts are mounted, paused while expanded.
+        set_interval(Duration::from_millis(50), move || {
+            if manager.with(|m| m.advance(now(), state.is_expanded()).changed) {
+                sync();
+            }
+        });
+        manager.with(|m| {
+            m.push(
+                1,
+                "Copied".into(),
+                ToastOptions::timeout(Some(Duration::from_secs(5))),
+                now(),
+            )
+        });
+        sync();
+        toast_stack()
+            .placement(Anchor::BottomCenter)
+            .state(state)
+            .w(px(340.0))
+            .child(each(
+                shown,
+                |(id, _, _)| *id,
+                move |item, _| {
+                    toast()
+                        .transition_status(move || item.get().2)
+                        .accessibility_label(move || Some(item.get().1))
+                        .on_dismiss_request(move || {
+                            manager.with(|m| m.dismiss(&item.get().0, now()));
+                        })
+                        .rounded_xl()
+                        .px_4()
+                        .py_3()
+                        .bg(move || theme().card)
+                        .shadow_lg()
+                        .transition_all()
+                        .duration(200.0)
+                        .starting_class("opacity-0 translate-y-4")
+                        .ending_class("opacity-0 scale-95")
+                        .child(text(move || item.get().1))
+                },
+            ))
+    }
+}
diff --git a/third-party/nsh/crates/nsh-base/src/toast/root.rs b/third-party/nsh/crates/nsh-base/src/toast/root.rs
new file mode 100644
index 0000000000..c7a850a6c9
--- /dev/null
+++ b/third-party/nsh/crates/nsh-base/src/toast/root.rs
@@ -0,0 +1,246 @@
+//! `Toast`: the unstyled semantic root of a toast.
+
+use std::{rc::Rc, time::Duration};
+
+use nsh_core::{TouchEvent, TouchPhase, ui::prelude::*};
+
+use super::ToastTransitionStatus;
+
+/// How far a pointer moves toward the swipe before the toast follows it
+/// (its buttons' presses stay presses below that).
+const SWIPE_SLOP: f32 = 10.0;
+/// A release this fast toward the swipe (points per millisecond) dismisses
+/// it short of the threshold: a flick.
+const FLICK: f32 = 0.5;
+/// How long a toast takes to follow a release (away, or back).
+const SETTLE_MS: f32 = 200.0;
+/// How long a toast waits for its first display frame before it counts as
+/// past it without one (a surface not on screen).
+const FIRST_FRAME_MS: u64 = 50;
+
+/// `class` with every utility important (`opacity-0` is `opacity-0!`).
+fn important(class: &str) -> SharedString {
+    class
+        .split_whitespace()
+        .map(|t| {
+            if t.ends_with('!') || t.split(':').next_back().is_some_and(|u| u.starts_with('!')) {
+                t.to_owned()
+            } else {
+                format!("{t}!")
+            }
+        })
+        .collect::<Vec<_>>()
+        .join(" ")
+        .into()
+}
+
+/// The direction a toast is swiped away in.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PropEnum)]
+pub enum SwipeDirection {
+    /// Toward the right edge.
+    #[default]
+    Right,
+    /// Toward the left edge.
+    Left,
+    /// Toward the top.
+    Up,
+    /// Toward the bottom.
+    Down,
+}
+
+/// The props of [`Toast`].
+#[derive(Debug, Clone, PartialEq, Props)]
+pub struct ToastProps {
+    /// Its lifecycle phase (a `ToastManager`'s), which the state classes
+    /// follow (default `"starting"`).
+    #[prop(default)]
+    pub transition_status: Prop<ToastTransitionStatus>,
+    /// Classes it has in its first frame while `"starting"`, which its
+    /// `transition` classes animate from (Tailwind's `starting:`, CSS's
+    /// `@starting-style`): its enter motion. The state classes apply over its
+    /// `class` and `style`, as a state's styles do.
+    pub starting_class: Prop<Option<SharedString>>,
+    /// Classes it has while `"ending"`: its exit motion.
+    pub ending_class: Prop<Option<SharedString>>,
+    /// Classes it has while a pointer swipes it (`transition-none`: it
+    /// follows at once).
+    pub swiping_class: Prop<Option<SharedString>>,
+    /// The direction a finger or the mouse swipes it away in (default
+    /// `"right"`); `null`: it is not swiped.
+    #[prop(default = Some(SwipeDirection::Right))]
+    pub swipe_direction: Prop<Option<SwipeDirection>>,
+    /// How far it is swiped before a release asks to dismiss it (default
+    /// 50).
+    #[prop(default = Points(50.0))]
+    pub swipe_threshold: Prop<Points>,
+    /// What the screen reader reads as it shows (its title and
+    /// description).
+    pub accessibility_label: Prop<Option<SharedString>>,
+    /// Its content.
+    pub children: Prop<Children>,
+}
+
+/// The events of a [`Toast`].
+#[derive(Debug, Clone, PartialEq, Event)]
+pub enum ToastEvent {
+    /// The user swiped it away: dismiss it (a `ToastManager`'s `dismiss`).
+    /// It stays where the swipe took it.
+    DismissRequest,
+}
+
+/// An unstyled semantic toast root (`role` alert), as `gpui-base`'s
+/// `Toast`: the app owns its presentation and motion, by its
+/// `transitionStatus` and the state classes (`startingClass`,
+/// `endingClass`, `swipingClass`) its `class` and `style` add to. A pointer
+/// dragged toward `swipeDirection` moves it, and a release past
+/// `swipeThreshold` or a flick asks to dismiss it (`onDismissRequest`);
+/// short of it, it goes back. The screen reader reads its
+/// `accessibilityLabel` as it shows. Placed in a `ToastStack`, which lays
+/// toasts out.
+#[component]
+pub fn Toast(props: ToastProps, cx: Scope<ToastEvent>) -> impl IntoElement {
+    let status = props.transition_status;
+    if let Some(label) = props.accessibility_label.get_untracked() {
+        announce_for_accessibility(label.to_string());
+    }
+
+    // Past its first frame: the next display frame, or a frame's time where
+    // none comes, whichever is first.
+    let entered = signal(reduce_motion());
+    if !entered.get_untracked() {
+        let enter = Rc::new(move || {
+            if !entered.get_untracked() {
+                entered.set(true);
+            }
+        });
+        let first = enter.clone();
+        request_animation_frame(move |_| first());
+        set_timeout(Duration::from_millis(FIRST_FRAME_MS), move || enter());
+    }
+
+    // The swipe: it follows the pointer toward its direction.
+    let x = animated_value(0.0);
+    let y = animated_value(0.0);
+    let swiping = signal(false);
+    let size = stored(Size::<Points>::default());
+    // Where the pointer went down: it follows from there, not from where
+    // it claimed the touches.
+    let down = stored(None::<Point<Points>>);
+    let direction = move || props.swipe_direction.get_untracked();
+    let along = move |d: Point<Points>| -> f32 {
+        match direction() {
+            Some(SwipeDirection::Right) => d.x.0,
+            Some(SwipeDirection::Left) => -d.x.0,
+            Some(SwipeDirection::Down) => d.y.0,
+            Some(SwipeDirection::Up) => -d.y.0,
+            None => 0.0,
+        }
+    };
+    let horizontal = move || {
+        matches!(
+            direction(),
+            Some(SwipeDirection::Right | SwipeDirection::Left)
+        )
+    };
+    let to = move |distance: f32| -> (f32, f32) {
+        match direction() {
+            Some(SwipeDirection::Right) => (distance, 0.0),
+            Some(SwipeDirection::Left) => (-distance, 0.0),
+            Some(SwipeDirection::Down) => (0.0, distance),
+            Some(SwipeDirection::Up) => (0.0, -distance),
+            None => (0.0, 0.0),
+        }
+    };
+    let moved = move |e: &ResponderEvent| {
+        let from = down.with(|d| d.unwrap_or(e.gesture.origin));
+        point(e.page.x - from.x, e.page.y - from.y)
+    };
+    let back = move || {
+        x.timing(0.0, SETTLE_MS, Easing::EaseOut);
+        y.timing(0.0, SETTLE_MS, Easing::EaseOut);
+    };
+    let release = move |e: &ResponderEvent| {
+        swiping.set(false);
+        let distance = along(moved(e));
+        let speed = along(point(px(e.gesture.vx), px(e.gesture.vy)));
+        if distance >= props.swipe_threshold.get_untracked().0 || speed >= FLICK {
+            let far = size.with(|s| if horizontal() { s.width.0 } else { s.height.0 });
+            let (tx, ty) = to(far.max(distance) + 16.0);
+            x.timing(tx, SETTLE_MS, Easing::EaseOut);
+            y.timing(ty, SETTLE_MS, Easing::EaseOut);
+            cx.emit(ToastEvent::DismissRequest);
+        } else {
+            back();
+        }
+    };
+
+    // The state's classes win over the app's `class` and `style`, as a
+    // state's styles do (`activeStyle`) and as CSS's state selectors do by
+    // their specificity: applied as `!` utilities.
+    let class = move || {
+        let state = if status.get() == ToastTransitionStatus::Ending {
+            props.ending_class.get()
+        } else if swiping.get() {
+            props.swiping_class.get()
+        } else if status.get() == ToastTransitionStatus::Starting && !entered.get() {
+            props.starting_class.get()
+        } else {
+            None
+        };
+        state.map(|c| important(&c))
+    };
+
+    view()
+        .role(AccessibilityRole::Alert)
+        .optional_label(props.accessibility_label)
+        .app_class(class)
+        .bind_translate_x(&x)
+        .bind_translate_y(&y)
+        .on_layout(move |frame: &Bounds<Points>| {
+            size.replace(frame.size);
+        })
+        .on_touch(move |e: &TouchEvent| {
+            if e.phase == TouchPhase::Start {
+                down.replace(e.points.first().map(|p| p.page));
+            }
+        })
+        .claim_touches(TouchClaim::on_move().ask(move |request: &ClaimRequest| {
+            if direction().is_none() || status.get() == ToastTransitionStatus::Ending {
+                return;
+            }
+            let d = request.event.gesture.delta;
+            let (main, cross) = if horizontal() {
+                (d.x.0, d.y.0)
+            } else {
+                (d.y.0, d.x.0)
+            };
+            if along(d) >= SWIPE_SLOP && main.abs() > cross.abs() {
+                request.claim();
+            }
+        }))
+        .keep_touches(true)
+        .on_responder_grant(move |_: &ResponderEvent| swiping.set(true))
+        .on_responder_move(move |e: &ResponderEvent| {
+            // Toward the direction only.
+            let (dx, dy) = to(along(moved(e)).max(0.0));
+            x.set(dx);
+            y.set(dy);
+        })
+        .on_responder_release(release)
+        .on_responder_terminate(move |_: &ResponderEvent| {
+            swiping.set(false);
+            back();
+        })
+        .child(props.children)
+}
+
+#[cfg(test)]
+mod tests {
+    #[test]
+    fn state_classes_are_important() {
+        assert_eq!(
+            &*super::important("opacity-0 dark:bg-black/50 !p-2 m-1!"),
+            "opacity-0! dark:bg-black/50! !p-2 m-1!"
+        );
+    }
+}
diff --git a/third-party/nsh/crates/nsh-base/src/toast/stack.rs b/third-party/nsh/crates/nsh-base/src/toast/stack.rs
new file mode 100644
index 0000000000..694e3aa475
--- /dev/null
+++ b/third-party/nsh/crates/nsh-base/src/toast/stack.rs
@@ -0,0 +1,345 @@
+//! `ToastStack`: measures its toasts and lays them out as overlapping
+//! layers or, expanded, a list, moving them with springs.
+
+use nsh_core::{
+    TouchEvent, TouchPhase, TransformKind,
+    ui::{Spring, prelude::*},
+};
+
+use super::ToastMotion;
+
+/// The edge of the viewport a [`ToastStack`]'s geometry is anchored to:
+/// the newest toast nearest it.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PropEnum)]
+pub enum Anchor {
+    /// The top left corner.
+    TopLeft,
+    /// The middle of the top edge.
+    TopCenter,
+    /// The top right corner.
+    #[default]
+    TopRight,
+    /// The bottom left corner.
+    BottomLeft,
+    /// The middle of the bottom edge.
+    BottomCenter,
+    /// The bottom right corner.
+    BottomRight,
+    /// The middle of the left edge.
+    LeftCenter,
+    /// The middle of the right edge.
+    RightCenter,
+}
+
+impl Anchor {
+    /// Anchored to the bottom: the stack grows upward.
+    fn bottom(self) -> bool {
+        matches!(
+            self,
+            Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight
+        )
+    }
+}
+
+/// The props of [`ToastStack`].
+#[derive(Debug, Clone, PartialEq, Props)]
+pub struct ToastStackProps {
+    /// Its motion tokens (Sonner's by default; fields left out keep theirs).
+    #[prop(default)]
+    pub motion: Prop<ToastMotion>,
+    /// The edge its geometry is anchored to (default `"topRight"`): the
+    /// newest toast is nearest it. The app places the stack itself (an
+    /// `Overlay` at that edge, inside the safe area).
+    #[prop(default)]
+    pub placement: Prop<Anchor>,
+    /// Escape and the Android back button are heard while it holds toasts
+    /// (`onCancel`; default true).
+    #[prop(default = true)]
+    pub close_on_escape: Prop<bool>,
+    /// The toasts, oldest first: each child is an item, keyed by its node.
+    pub children: Prop<Children>,
+}
+
+/// The events of a [`ToastStack`].
+#[derive(Debug, Clone, PartialEq, Event)]
+pub enum ToastStackEvent {
+    /// Interaction expanded or collapsed it: the pointer on it, or a finger
+    /// down on it. While expanded, pass `paused` to the manager's
+    /// `advance`.
+    #[args(expanded)]
+    ExpandedChange(bool),
+    /// Escape or the Android back button while it holds toasts: dismiss
+    /// the newest.
+    Cancel,
+}
+
+/// Whether a [`ToastStack`] is expanded, as `gpui-base`'s
+/// `ToastStackState` (Rust; TS: `onExpandedChange`): made with the view,
+/// given to the stack with [`ToastStackElement::state`].
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct ToastStackState {
+    expanded: Signal<bool>,
+}
+
+impl ToastStackState {
+    /// A state of the current owner, collapsed.
+    pub fn new() -> Self {
+        Self {
+            expanded: signal(false),
+        }
+    }
+
+    /// Interaction expanded the stack (tracked).
+    pub fn is_expanded(&self) -> bool {
+        self.expanded.get()
+    }
+}
+
+impl Default for ToastStackState {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl ToastStackElement {
+    /// Keeps `state` up to date with the stack (its `onExpandedChange`).
+    pub fn state(self, state: ToastStackState) -> Self {
+        self.on_expanded_change(move |expanded: &bool| state.expanded.set(*expanded))
+    }
+}
+
+/// An item of the stack: its key, its place, its measured height.
+#[derive(Debug, Clone, Copy, PartialEq)]
+struct Item {
+    key: u64,
+    index: usize,
+    height: Points,
+}
+
+/// The stack's height collapsed and expanded, for items of `heights`
+/// (oldest first), as `gpui-base`'s.
+fn stack_heights(heights: &[Points], gap: Points, peek: Points) -> (Points, Points) {
+    let count = heights.len();
+    let expanded =
+        heights.iter().map(|h| h.0).sum::<f32>() + gap.0 * count.saturating_sub(1) as f32;
+    let front = heights.last().map_or(0.0, |h| h.0);
+    let collapsed = heights
+        .iter()
+        .enumerate()
+        .map(|(i, h)| h.0 + peek.0 * (count - 1 - i) as f32)
+        .fold(front + peek.0 * count.saturating_sub(1) as f32, f32::max);
+    (Points(collapsed), Points(expanded))
+}
+
+/// How far item `index` of `heights` is from the anchored edge, collapsed
+/// and expanded.
+fn distance(heights: &[Points], index: usize, gap: Points, peek: Points) -> (Points, Points) {
+    let count = heights.len();
+    let rank = count.saturating_sub(1 + index) as f32;
+    let newer: f32 = heights
+        .get(index + 1..)
+        .unwrap_or(&[])
+        .iter()
+        .map(|h| h.0)
+        .sum();
+    (Points(peek.0 * rank), Points(newer + gap.0 * rank))
+}
+
+/// A critically damped spring whose response is `duration` (no overshoot:
+/// a height or an opacity past its target reads as a glitch).
+fn spring_of(motion: ToastMotion) -> Spring {
+    let response = motion.duration.as_secs_f32().max(0.01);
+    let stiffness = (std::f32::consts::TAU / response).powi(2);
+    Spring {
+        stiffness,
+        damping: 2.0 * stiffness.sqrt(),
+        mass: 1.0,
+        velocity: None,
+    }
+}
+
+/// A deep toast stack, as `gpui-base`'s `ToastStack`: it measures its
+/// toasts (its children, oldest first) and lays them out from its
+/// `placement`'s edge, the newest nearest it. Collapsed, they overlap as
+/// layers `collapsedPeek` apart, each narrower by `collapsedScaleStep` and
+/// only `collapsedVisible` shown; expanded (the pointer on it, a finger
+/// down on it: `onExpandedChange`), they are a list `expandedGap` apart.
+/// Arrivals, departures and expansion move them with springs of the
+/// motion's `duration`. It hears Escape and back while it holds toasts
+/// (`onCancel`). It looks like nothing: its `class` and `style` give its
+/// width; the app places it.
+#[component]
+pub fn ToastStack(props: ToastStackProps, cx: Scope<ToastStackEvent>) -> impl IntoElement {
+    let hovered = signal(false);
+    let touched = signal(false);
+    let expanded = computed(move || hovered.get() || touched.get());
+    {
+        let was = stored(false);
+        effect(move || {
+            let now = expanded.get();
+            if was.replace(now) != now {
+                untracked(|| cx.emit(ToastStackEvent::ExpandedChange(now)));
+            }
+        });
+    }
+    let items = signal(Vec::<Item>::new());
+    let next = stored(0u64);
+    // The measured heights, oldest first.
+    let heights = computed(move || {
+        let mut all = items.get();
+        all.sort_by_key(|i| i.index);
+        all.into_iter().map(|i| i.height).collect::<Vec<_>>()
+    });
+    let height = move || {
+        let m = props.motion.get();
+        let (collapsed, full) =
+            heights.with(|h| stack_heights(h, m.expanded_gap, m.collapsed_peek));
+        if expanded.get() { full } else { collapsed }
+    };
+    let any = move || !props.children.get().is_empty();
+
+    view()
+        .relative()
+        .style_with(move |s| s.h(height()))
+        .on_hover_in(move || hovered.set(true))
+        .on_hover_out(move || hovered.set(false))
+        // Observed, never claimed: the toasts' presses and swipes go on.
+        .on_touch(move |e: &TouchEvent| match e.phase {
+            TouchPhase::Start => touched.set(true),
+            TouchPhase::End | TouchPhase::Cancel => touched.set(false),
+            TouchPhase::Move => {}
+        })
+        .children_each(props.children, move |child, at| {
+            let key = next.with(|n| *n);
+            next.replace(key + 1);
+            items.update(|v| {
+                v.push(Item {
+                    key,
+                    index: untracked(|| at.index()),
+                    height: Points::ZERO,
+                })
+            });
+            effect(move || {
+                let index = at.index();
+                untracked(|| {
+                    if items.with(|v| v.iter().any(|i| i.key == key && i.index != index)) {
+                        items.update(|v| {
+                            if let Some(i) = v.iter_mut().find(|i| i.key == key) {
+                                i.index = index;
+                            }
+                        });
+                    }
+                });
+            });
+            cleanup(move || {
+                let _ = items.try_get().map(|mut v| {
+                    v.retain(|i| i.key != key);
+                    items.try_set(v);
+                });
+            });
+            let y = animated_value(0.0);
+            let scale_x = animated_value(1.0);
+            let opacity = animated_value(1.0);
+            let shown = signal(true);
+            let first = stored(true);
+            effect(move || {
+                let m = props.motion.get();
+                let bottom = props.placement.get().bottom();
+                let open = expanded.get();
+                let (index, count) = (at.index(), at.count());
+                let (collapsed, full) = heights.with(|h| {
+                    // Heights not measured yet count as nothing.
+                    let mut h = h.clone();
+                    h.resize(count, Points::ZERO);
+                    distance(&h, index, m.expanded_gap, m.collapsed_peek)
+                });
+                let rank = count.saturating_sub(1 + index);
+                let visible = m.collapsed_visible.max(1);
+                let d = if open { full } else { collapsed };
+                let ty = if bottom { -d.0 } else { d.0 };
+                let sx = if open {
+                    1.0
+                } else {
+                    1.0 - m.collapsed_scale_step * rank.min(visible - 1) as f32
+                };
+                let on = open || rank < visible;
+                untracked(|| {
+                    shown.set(on);
+                    let op = if on { 1.0 } else { 0.0 };
+                    if first.replace(false) || reduce_motion() {
+                        y.set(ty);
+                        scale_x.set(sx);
+                        opacity.set(op);
+                    } else {
+                        let spring = spring_of(m);
+                        y.spring(ty, spring);
+                        scale_x.spring(sx, spring);
+                        opacity.spring(op, spring);
+                    }
+                });
+            });
+            view()
+                .absolute()
+                .left_0()
+                .right_0()
+                .style_with(move |s| {
+                    if props.placement.get().bottom() {
+                        s.bottom_0()
+                    } else {
+                        s.top_0()
+                    }
+                })
+                .bind_translate_y(&y)
+                .bind_transform(TransformKind::ScaleX, &scale_x)
+                .bind_opacity(&opacity)
+                .pointer_events(move || {
+                    if shown.get() {
+                        PointerEvents::BoxNone
+                    } else {
+                        PointerEvents::None
+                    }
+                })
+                .on_layout(move |frame: &Bounds<Points>| {
+                    let h = frame.size.height;
+                    if items.with_untracked(|v| v.iter().any(|i| i.key == key && i.height != h)) {
+                        items.update(|v| {
+                            if let Some(i) = v.iter_mut().find(|i| i.key == key) {
+                                i.height = h;
+                            }
+                        });
+                    }
+                })
+                .child(child)
+        })
+        // Escape and back, only while it holds toasts: an overlay of no
+        // size, so nothing else loses them to an empty stack.
+        .child(when(
+            move || any() && props.close_on_escape.get(),
+            move || {
+                overlay()
+                    .size_0()
+                    .pointer_events(PointerEvents::None)
+                    .focusable(false)
+                    .on_cancel(move || cx.emit(ToastStackEvent::Cancel))
+            },
+        ))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn the_geometry_anchors_the_newest_and_takes_any_heights() {
+        let heights = [Points(40.0), Points(60.0), Points(80.0)];
+        let (gap, peek) = (Points(12.0), Points(12.0));
+        assert_eq!(
+            stack_heights(&heights, gap, peek),
+            (Points(104.0), Points(204.0))
+        );
+        let at = |i| distance(&heights, i, gap, peek);
+        assert_eq!(at(0), (Points(24.0), Points(164.0)));
+        assert_eq!(at(1), (Points(12.0), Points(92.0)));
+        assert_eq!(at(2), (Points(0.0), Points(0.0)));
+    }
+}
diff --git a/third-party/nsh/crates/nsh-cli/src/android.rs b/third-party/nsh/crates/nsh-cli/src/android.rs
index 2afd9c9df2..dd2c572f30 100644
--- a/third-party/nsh/crates/nsh-cli/src/android.rs
+++ b/third-party/nsh/crates/nsh-cli/src/android.rs
@@ -80,7 +80,13 @@ impl Sdk {
         let bin = toolchain.join("bin");
         // On Windows the NDK's per-target compilers are batch files.
         let script = if cfg!(windows) { ".cmd" } else { "" };
-        let clang = bin.join(format!("{triple}{MIN_SDK}-clang{script}"));
+        // ARMv7's compilers are named `armv7a-linux-androideabi`, the
+        // others after Rust's triple.
+        let clang_target = match triple {
+            "armv7-linux-androideabi" => "armv7a-linux-androideabi",
+            other => other,
+        };
+        let clang = bin.join(format!("{clang_target}{MIN_SDK}-clang{script}"));
         let env_triple = triple.replace('-', "_");
         let mut cmd = Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
         cmd.env(format!("CC_{env_triple}"), &clang)
diff --git a/third-party/nsh/crates/nsh-cli/src/rust_app.rs b/third-party/nsh/crates/nsh-cli/src/rust_app.rs
index 3ab56d4a4b..73c1ff0246 100644
--- a/third-party/nsh/crates/nsh-cli/src/rust_app.rs
+++ b/third-party/nsh/crates/nsh-cli/src/rust_app.rs
@@ -977,6 +977,7 @@ opt-level = 3
                 self.nsh.dep("nsh-base", HIGHLIGHT),
             ),
         )?;
+        write(&ws.join("android/build.rs"), ANDROID_BUILD_RS)?;
         let android = "#[cfg(target_os = \"android\")]\n";
         // A Rust app's root, which the development library runs when a
         // test session says so (`nsh test --target android`).
@@ -1115,6 +1116,7 @@ opt-level = 3
                 self.root_fn(android, root),
             ),
         )?;
+        write(&ws.join("rust-android/build.rs"), ANDROID_BUILD_RS)?;
         let wasm = "#[cfg(target_arch = \"wasm32\")]\n";
         write(
             &ws.join("rust-web/Cargo.toml"),
@@ -1854,6 +1856,19 @@ struct Build<'a> {
     what: &'a str,
 }
 
+/// Link arguments belong to the final cdylib, so apps' Android runtimes
+/// need the same 16 KB alignment as `nsh-platform-android`'s `build.rs`.
+const ANDROID_BUILD_RS: &str = r#"//! Android's shared runtime supports both 4 KB and 16 KB page sizes.
+
+fn main() {
+    println!("cargo:rerun-if-changed=build.rs");
+    if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
+        println!("cargo:rustc-link-arg-cdylib=-Wl,-z,max-page-size=16384");
+        println!("cargo:rustc-link-arg-cdylib=-Wl,-z,common-page-size=16384");
+    }
+}
+"#;
+
 /// The `build.rs` of the web runtimes: the 8 MiB stack nsh's own has, and
 /// QuickJS' local time from the page (`nsh-platform-web`'s `build.rs`).
 const WEB_BUILD_RS: &str = "//! The web runtime's stack: 8 MiB, as nsh's own (`.cargo/config.toml`);\n\
diff --git a/third-party/nsh/crates/nsh-core/Cargo.toml b/third-party/nsh/crates/nsh-core/Cargo.toml
index 819a7ae556..e6afb84329 100644
--- a/third-party/nsh/crates/nsh-core/Cargo.toml
+++ b/third-party/nsh/crates/nsh-core/Cargo.toml
@@ -42,6 +42,9 @@ futures-util.workspace = true
 tokio-tungstenite.workspace = true
 # `process`'s children: their process groups and exits (`runtime/process.rs`).
 libc.workspace = true
+# The image disk cache (`image/cache.rs`).
+httpdate.workspace = true
+crc32fast.workspace = true
 
 # `process`'s children on Windows: their jobs (`runtime/process.rs`).
 [target.'cfg(windows)'.dependencies]
diff --git a/third-party/nsh/crates/nsh-core/src/animation/driver.rs b/third-party/nsh/crates/nsh-core/src/animation/driver.rs
index f5f3e3702a..5685b7a29e 100644
--- a/third-party/nsh/crates/nsh-core/src/animation/driver.rs
+++ b/third-party/nsh/crates/nsh-core/src/animation/driver.rs
@@ -1,7 +1,7 @@
 //! Animation drivers. All are closed-form functions of elapsed time, so the
 //! result does not depend on frame rate or dropped frames.
 
-use crate::animation::ops::{AnimationId, Curve, Driver, EaseMode, Easing};
+use crate::animation::ops::{AnimationId, Curve, Driver, EaseMode, Easing, StepPosition};
 
 /// Spring rest thresholds (value units and units per second).
 const REST_DISPLACEMENT: f32 = 0.001;
@@ -84,10 +84,12 @@ impl Running {
                 delay_ms,
             } => {
                 let t_ms = t * 1000.0 - delay_ms;
-                if t_ms <= 0.0 {
+                // A step of no duration (a keyframe's jump) ends on the
+                // frame it starts, so the next one starts with it.
+                if t_ms < 0.0 || (t_ms == 0.0 && duration_ms > 0.0) {
                     return (self.from, 0.0, false);
                 }
-                if t_ms >= duration_ms || duration_ms <= 0.0 {
+                if t_ms >= duration_ms {
                     return (to, 0.0, true);
                 }
                 let p = ease(easing, t_ms / duration_ms);
@@ -270,9 +272,35 @@ pub(crate) fn ease(easing: Easing, p: f32) -> f32 {
             EaseMode::InOut if p < 0.5 => curve_in(curve, p * 2.0) / 2.0,
             EaseMode::InOut => 1.0 - curve_in(curve, (1.0 - p) * 2.0) / 2.0,
         },
+        Easing::Steps(count, position) => steps(count, position, p),
     }
 }
 
+/// CSS `steps(count, position)` at `p` (Easing Functions Level 2, without
+/// the before flag, which only matters for fill modes).
+fn steps(count: u32, position: StepPosition, p: f32) -> f32 {
+    let count = match position {
+        StepPosition::JumpNone => count.max(2),
+        _ => count.max(1),
+    } as f32;
+    let jumps = match position {
+        StepPosition::JumpStart | StepPosition::JumpEnd => count,
+        StepPosition::JumpNone => count - 1.0,
+        StepPosition::JumpBoth => count + 1.0,
+    };
+    let mut step = (p * count).floor();
+    if matches!(position, StepPosition::JumpStart | StepPosition::JumpBoth) {
+        step += 1.0;
+    }
+    if p >= 0.0 && step < 0.0 {
+        step = 0.0;
+    }
+    if p <= 1.0 && step > jumps {
+        step = jumps;
+    }
+    step / jumps
+}
+
 /// React Native's `Easing` functions (their own, "in", form).
 fn curve_in(curve: Curve, t: f32) -> f32 {
     use std::f32::consts::PI;
diff --git a/third-party/nsh/crates/nsh-core/src/animation/mod.rs b/third-party/nsh/crates/nsh-core/src/animation/mod.rs
index 1aa6616366..d755c8b0a1 100644
--- a/third-party/nsh/crates/nsh-core/src/animation/mod.rs
+++ b/third-party/nsh/crates/nsh-core/src/animation/mod.rs
@@ -4,6 +4,9 @@
 //! once; the platform evaluates them every display frame on the UI thread,
 //! so animations, gestures and scroll-linked effects never wait for JS.
 //!
+//! Keyframes, staggering and motions (`motion.rs`) are data that become
+//! the same composite trees, so they need nothing of a platform either.
+//!
 //! It is platform-free: the platform feeds it [`AnimationOp`]s, frame
 //! timestamps, gesture and scroll values, and reads back evaluated values.
 //! [`Animator`] does that for the desktop backends, binding the graph to
@@ -12,14 +15,19 @@
 mod animator;
 mod driver;
 mod graph;
+mod motion;
 mod ops;
 
 pub use self::{
     animator::{AnimValues, Animator},
     driver::{MAX_SAMPLES, sample_driver, sample_easing},
     graph::{AnimationValue, Finished, Graph},
+    motion::{
+        Keyframe, KeyframeTiming, MotionChannel, MotionKind, MotionPreset, MotionSpec, PRESETS,
+        PlaybackDirection, PresetDef, StaggerFrom, keyframe_ends, keyframes, stagger_delay,
+    },
     ops::{
         AnimationId, AnimationOp, AnimationProp, Composite, Curve, Driver, EaseMode, Easing,
-        Extrapolate, MathOp, NodeId, Operand, Output,
+        Extrapolate, MathOp, NodeId, Operand, Output, StepPosition,
     },
 };
diff --git a/third-party/nsh/crates/nsh-core/src/animation/motion.rs b/third-party/nsh/crates/nsh-core/src/animation/motion.rs
new file mode 100644
index 0000000000..4f6da02b5a
--- /dev/null
+++ b/third-party/nsh/crates/nsh-core/src/animation/motion.rs
@@ -0,0 +1,1073 @@
+//! Keyframes, staggering and motions: what is played on animated values
+//! described as data, turned into [`Composite`] trees the graph runs like
+//! any other (so every platform plays them, with no code of its own).
+//!
+//! - [`keyframes`]: one value through keyframes, as CSS `@keyframes` and
+//!   the Web Animations API mean them: offsets (missing ones spread
+//!   evenly), an easing per segment, iterations, a direction, a delay.
+//! - [`stagger_delay`]: the delay of each item of a staggered group,
+//!   counted from the first, the last, the center or any item.
+//! - [`MotionSpec`]: several channels of a view ([`MotionChannel`]: opacity and
+//!   the parts of its transform) through keyframes with one timing, and
+//!   the ready-made ones ([`MotionPreset`]: entrances, exits, attention
+//!   seekers and loops). The values are the app's: a motion has no look,
+//!   it only moves what the app styled.
+
+use super::ops::{Composite, Driver, Easing, NodeId};
+use crate::TransformKind;
+
+/// One keyframe of a value: where it is at `offset` (0..=1 of an
+/// iteration) and the easing of the segment that starts there.
+#[derive(Debug, Clone, Copy, PartialEq)]
+#[non_exhaustive]
+pub struct Keyframe {
+    /// Where it is in the iteration, 0..=1; `None` spreads it evenly
+    /// between its neighbors (the first is 0 and the last 1 by default).
+    pub offset: Option<f32>,
+    /// The value there.
+    pub value: f32,
+    /// The curve to the next keyframe (`None`: the timing's).
+    pub easing: Option<Easing>,
+}
+
+impl Default for Keyframe {
+    fn default() -> Self {
+        Self {
+            offset: None,
+            value: 0.0,
+            easing: None,
+        }
+    }
+}
+
+impl Keyframe {
+    /// `value`, spread evenly with the keyframes around it.
+    pub const fn new(value: f32) -> Self {
+        Self {
+            offset: None,
+            value,
+            easing: None,
+        }
+    }
+
+    /// `value` at `offset` (0..=1).
+    pub const fn at(offset: f32, value: f32) -> Self {
+        Self {
+            offset: Some(offset),
+            value,
+            easing: None,
+        }
+    }
+
+    /// With `easing` for the segment that starts here.
+    pub const fn ease(mut self, easing: Easing) -> Self {
+        self.easing = Some(easing);
+        self
+    }
+}
+
+/// Which way the iterations of keyframes play (CSS `animation-direction`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum PlaybackDirection {
+    /// From the first keyframe to the last, each iteration.
+    #[default]
+    Normal,
+    /// From the last to the first.
+    Reverse,
+    /// Forwards, then backwards, and so on.
+    Alternate,
+    /// Backwards, then forwards, and so on.
+    AlternateReverse,
+}
+
+impl PlaybackDirection {
+    /// Parses a CSS `animation-direction`.
+    pub fn parse(name: &str) -> Option<PlaybackDirection> {
+        Some(match name {
+            "normal" => PlaybackDirection::Normal,
+            "reverse" => PlaybackDirection::Reverse,
+            "alternate" => PlaybackDirection::Alternate,
+            "alternate-reverse" => PlaybackDirection::AlternateReverse,
+            _ => return None,
+        })
+    }
+
+    /// Its CSS name.
+    pub fn name(self) -> &'static str {
+        match self {
+            PlaybackDirection::Normal => "normal",
+            PlaybackDirection::Reverse => "reverse",
+            PlaybackDirection::Alternate => "alternate",
+            PlaybackDirection::AlternateReverse => "alternate-reverse",
+        }
+    }
+
+    /// Whether iteration `index` (from 0) plays backwards.
+    fn reversed(self, index: u32) -> bool {
+        match self {
+            PlaybackDirection::Normal => false,
+            PlaybackDirection::Reverse => true,
+            PlaybackDirection::Alternate => !index.is_multiple_of(2),
+            PlaybackDirection::AlternateReverse => index.is_multiple_of(2),
+        }
+    }
+
+    fn alternates(self) -> bool {
+        matches!(
+            self,
+            PlaybackDirection::Alternate | PlaybackDirection::AlternateReverse
+        )
+    }
+}
+
+/// How keyframes play (CSS `animation-duration`, `-delay`,
+/// `-timing-function`, `-iteration-count` and `-direction`).
+#[derive(Debug, Clone, Copy, PartialEq)]
+#[non_exhaustive]
+pub struct KeyframeTiming {
+    /// One iteration, in milliseconds.
+    pub duration_ms: f32,
+    /// Before the first iteration, in milliseconds.
+    pub delay_ms: f32,
+    /// The curve of each segment without one of its own (CSS `ease` by
+    /// default; as in CSS it shapes every segment, not the whole).
+    pub easing: Easing,
+    /// How many iterations; `None` repeats until stopped.
+    pub iterations: Option<u32>,
+    /// Which way the iterations play.
+    pub direction: PlaybackDirection,
+}
+
+impl Default for KeyframeTiming {
+    fn default() -> Self {
+        Self {
+            duration_ms: 300.0,
+            delay_ms: 0.0,
+            easing: Easing::EASE,
+            iterations: Some(1),
+            direction: PlaybackDirection::Normal,
+        }
+    }
+}
+
+impl KeyframeTiming {
+    /// One iteration of `duration_ms` with the defaults otherwise.
+    pub fn new(duration_ms: f32) -> Self {
+        Self {
+            duration_ms,
+            ..Self::default()
+        }
+    }
+
+    /// With `easing` for segments without one.
+    pub fn easing(mut self, easing: Easing) -> Self {
+        self.easing = easing;
+        self
+    }
+
+    /// Waiting `delay_ms` before the first iteration.
+    pub fn delay(mut self, delay_ms: f32) -> Self {
+        self.delay_ms = delay_ms;
+        self
+    }
+
+    /// `iterations` times (`None`: until stopped).
+    pub fn iterations(mut self, iterations: Option<u32>) -> Self {
+        self.iterations = iterations;
+        self
+    }
+
+    /// Repeating until stopped.
+    pub fn infinite(self) -> Self {
+        self.iterations(None)
+    }
+
+    /// Playing in `direction`.
+    pub fn direction(mut self, direction: PlaybackDirection) -> Self {
+        self.direction = direction;
+        self
+    }
+}
+
+/// Keyframes with their offsets resolved (increasing, within 0..=1), as
+/// the Web Animations API spaces them: the first defaults to 0 and the
+/// last to 1 (a single one is the end), the ones between spread evenly
+/// between their neighbors with an offset.
+fn resolve(frames: &[Keyframe]) -> Vec<(f32, f32, Option<Easing>)> {
+    let n = frames.len();
+    let mut offsets: Vec<Option<f32>> = frames
+        .iter()
+        .map(|f| {
+            f.offset
+                .filter(|o| o.is_finite())
+                .map(|o| o.clamp(0.0, 1.0))
+        })
+        .collect();
+    if n == 1 {
+        offsets[0].get_or_insert(1.0);
+    } else if n > 1 {
+        offsets[0].get_or_insert(0.0);
+        offsets[n - 1].get_or_insert(1.0);
+    }
+    let mut i = 0;
+    while i < n {
+        if offsets[i].is_some() {
+            i += 1;
+            continue;
+        }
+        let start = i - 1;
+        let mut end = i;
+        while offsets[end].is_none() {
+            end += 1;
+        }
+        let (a, b) = (offsets[start].unwrap_or(0.0), offsets[end].unwrap_or(1.0));
+        for (k, slot) in offsets.iter_mut().enumerate().take(end).skip(i) {
+            *slot = Some(a + (b - a) * (k - start) as f32 / (end - start) as f32);
+        }
+        i = end;
+    }
+    // Offsets go up: one below the one before it is held there.
+    let mut last = 0.0f32;
+    frames
+        .iter()
+        .zip(offsets)
+        .map(|(f, o)| {
+            let o = o.unwrap_or(1.0).max(last);
+            last = o;
+            (o, f.value, f.easing)
+        })
+        .collect()
+}
+
+/// One iteration over `frames` (resolved), forwards or backwards.
+fn iteration(
+    node: NodeId,
+    frames: &[(f32, f32, Option<Easing>)],
+    timing: &KeyframeTiming,
+    backwards: bool,
+) -> Composite {
+    let duration = timing.duration_ms.max(0.0);
+    // Backwards, offsets mirror and each segment runs its curve reversed.
+    let oriented: Vec<(f32, f32, Easing)> = if backwards {
+        let mut out = Vec::with_capacity(frames.len());
+        for i in (0..frames.len()).rev() {
+            let easing = match i {
+                0 => timing.easing,
+                _ => frames[i - 1].2.unwrap_or(timing.easing).reversed(),
+            };
+            out.push((1.0 - frames[i].0, frames[i].1, easing));
+        }
+        out
+    } else {
+        frames
+            .iter()
+            .map(|&(o, v, e)| (o, v, e.unwrap_or(timing.easing)))
+            .collect()
+    };
+    let timing_to = |to: f32, ms: f32, easing: Easing| Composite::Run {
+        node,
+        driver: Driver::Timing {
+            to,
+            duration_ms: ms,
+            easing,
+            delay_ms: 0.0,
+        },
+    };
+    let mut steps = Vec::with_capacity(oriented.len() + 1);
+    let Some(&(first_offset, first_value, _)) = oriented.first() else {
+        return Composite::Sequence(steps);
+    };
+    // Without a keyframe at 0 the value goes from where it is, as CSS
+    // goes from the underlying value.
+    steps.push(timing_to(
+        first_value,
+        first_offset * duration,
+        timing.easing,
+    ));
+    for pair in oriented.windows(2) {
+        let ((a, _, easing), (b, value, _)) = (pair[0], pair[1]);
+        steps.push(timing_to(value, (b - a) * duration, easing));
+    }
+    let last = oriented.last().map_or(1.0, |f| f.0);
+    if last < 1.0 {
+        steps.push(Composite::Delay((1.0 - last) * duration));
+    }
+    Composite::Sequence(steps)
+}
+
+/// The value `node` through `frames` as `timing` says: a tree of timing
+/// steps the graph runs natively, each starting at the exact end of the
+/// one before. Each iteration starts at its first keyframe (a jump, as in
+/// CSS), so a loop of keyframes that end where they start never jumps;
+/// a delay holds that first keyframe (CSS `animation-fill-mode:
+/// backwards`). The value stays where the last iteration leaves it.
+pub fn keyframes(node: NodeId, frames: &[Keyframe], timing: &KeyframeTiming) -> Composite {
+    let frames = resolve(frames);
+    let pass = |index| iteration(node, &frames, timing, timing.direction.reversed(index));
+    let body = match (timing.iterations, timing.direction.alternates()) {
+        (Some(0), _) => Composite::Sequence(Vec::new()),
+        (Some(1), _) => pass(0),
+        (iterations, false) => Composite::Loop {
+            child: Box::new(pass(0)),
+            iterations,
+            reset: false,
+        },
+        (None, true) => Composite::Loop {
+            child: Box::new(Composite::Sequence(vec![pass(0), pass(1)])),
+            iterations: None,
+            reset: false,
+        },
+        (Some(n), true) => {
+            let pairs = Composite::Loop {
+                child: Box::new(Composite::Sequence(vec![pass(0), pass(1)])),
+                iterations: Some(n / 2),
+                reset: false,
+            };
+            match n % 2 {
+                0 => pairs,
+                _ => Composite::Sequence(vec![pairs, pass(0)]),
+            }
+        }
+    };
+    if timing.delay_ms <= 0.0 {
+        return body;
+    }
+    // During the delay the value already holds the first keyframe (CSS
+    // `animation-fill-mode: backwards`), so a delayed entrance does not
+    // show its view at rest first.
+    let mut steps = Vec::with_capacity(3);
+    let first = if timing.direction.reversed(0) {
+        frames.last().map(|f| (1.0 - f.0, f.1))
+    } else {
+        frames.first().map(|f| (f.0, f.1))
+    };
+    if let Some((0.0, value)) = first {
+        steps.push(Composite::Run {
+            node,
+            driver: Driver::Timing {
+                to: value,
+                duration_ms: 0.0,
+                easing: Easing::Linear,
+                delay_ms: 0.0,
+            },
+        });
+    }
+    steps.push(Composite::Delay(timing.delay_ms));
+    steps.push(body);
+    Composite::Sequence(steps)
+}
+
+/// Where the value of `frames` is when keyframes played as `timing` says
+/// begin and when they end: `(start, end)`. `None` without keyframes.
+pub fn keyframe_ends(frames: &[Keyframe], timing: &KeyframeTiming) -> Option<(f32, f32)> {
+    let (first, last) = (frames.first()?.value, frames.last()?.value);
+    let at = |backwards| if backwards { last } else { first };
+    let start = at(timing.direction.reversed(0));
+    let end = match timing.iterations {
+        Some(0) => start,
+        Some(n) => {
+            if timing.direction.reversed(n - 1) {
+                first
+            } else {
+                last
+            }
+        }
+        // Stopped at some point; where a single iteration ends.
+        None => {
+            if timing.direction.reversed(0) {
+                first
+            } else {
+                last
+            }
+        }
+    };
+    Some((start, end))
+}
+
+/// Where the items of a staggered group start counting from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum StaggerFrom {
+    /// The first item starts at once, the others one interval after the
+    /// one before.
+    #[default]
+    First,
+    /// The last item starts at once.
+    Last,
+    /// The middle item starts at once, the others spread out from it.
+    Center,
+    /// The item at this index starts at once.
+    Index(usize),
+}
+
+/// How many intervals item `index` of `count` waits, counted from `from`.
+pub fn stagger_delay(index: usize, count: usize, from: StaggerFrom) -> f32 {
+    if count == 0 {
+        return 0.0;
+    }
+    let index = index.min(count - 1);
+    match from {
+        StaggerFrom::First => index as f32,
+        StaggerFrom::Last => (count - 1 - index) as f32,
+        // Even counts have two middle items; both start at once.
+        StaggerFrom::Center => (index as f32 - (count - 1) as f32 / 2.0).abs().floor(),
+        StaggerFrom::Index(origin) => index.abs_diff(origin.min(count - 1)) as f32,
+    }
+}
+
+/// What a motion moves: the opacity of a view and the parts of its
+/// transform, applied in this order (translations first, then rotations,
+/// then scales, about the view's transform origin).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum MotionChannel {
+    /// `opacity` (multiplies the view's), 0..1.
+    Opacity,
+    /// `translateX`, points.
+    TranslateX,
+    /// `translateY`, points.
+    TranslateY,
+    /// `rotate`, degrees clockwise.
+    Rotate,
+    /// `rotateX`, degrees.
+    RotateX,
+    /// `rotateY`, degrees.
+    RotateY,
+    /// `scale`.
+    Scale,
+    /// `scaleX`.
+    ScaleX,
+    /// `scaleY`.
+    ScaleY,
+}
+
+impl MotionChannel {
+    /// Every channel, in the order a motion applies them.
+    pub const ALL: [MotionChannel; 9] = [
+        MotionChannel::Opacity,
+        MotionChannel::TranslateX,
+        MotionChannel::TranslateY,
+        MotionChannel::Rotate,
+        MotionChannel::RotateX,
+        MotionChannel::RotateY,
+        MotionChannel::Scale,
+        MotionChannel::ScaleX,
+        MotionChannel::ScaleY,
+    ];
+
+    /// Its position in [`Self::ALL`].
+    pub fn index(self) -> usize {
+        self as usize
+    }
+
+    /// Its name in styles (`opacity`, `translateX`, ...).
+    pub fn name(self) -> &'static str {
+        match self {
+            MotionChannel::Opacity => "opacity",
+            MotionChannel::TranslateX => "translateX",
+            MotionChannel::TranslateY => "translateY",
+            MotionChannel::Rotate => "rotate",
+            MotionChannel::RotateX => "rotateX",
+            MotionChannel::RotateY => "rotateY",
+            MotionChannel::Scale => "scale",
+            MotionChannel::ScaleX => "scaleX",
+            MotionChannel::ScaleY => "scaleY",
+        }
+    }
+
+    /// The channel named `name`.
+    pub fn parse(name: &str) -> Option<MotionChannel> {
+        MotionChannel::ALL.into_iter().find(|c| c.name() == name)
+    }
+
+    /// Its value at rest (what changes nothing).
+    pub fn identity(self) -> f32 {
+        match self {
+            MotionChannel::Opacity
+            | MotionChannel::Scale
+            | MotionChannel::ScaleX
+            | MotionChannel::ScaleY => 1.0,
+            _ => 0.0,
+        }
+    }
+
+    /// The transform part it binds to (`None`: the opacity).
+    pub fn transform(self) -> Option<TransformKind> {
+        Some(match self {
+            MotionChannel::Opacity => return None,
+            MotionChannel::TranslateX => TransformKind::TranslateX,
+            MotionChannel::TranslateY => TransformKind::TranslateY,
+            MotionChannel::Rotate => TransformKind::Rotate,
+            MotionChannel::RotateX => TransformKind::RotateX,
+            MotionChannel::RotateY => TransformKind::RotateY,
+            MotionChannel::Scale => TransformKind::Scale,
+            MotionChannel::ScaleX => TransformKind::ScaleX,
+            MotionChannel::ScaleY => TransformKind::ScaleY,
+        })
+    }
+}
+
+/// Several channels of a view through keyframes, played with one timing:
+/// a preset ([`MotionPreset`]) or the app's own.
+#[derive(Debug, Clone, PartialEq, Default)]
+#[non_exhaustive]
+pub struct MotionSpec {
+    /// The keyframes of each channel it moves.
+    pub tracks: Vec<(MotionChannel, Vec<Keyframe>)>,
+    /// How they play.
+    pub timing: KeyframeTiming,
+}
+
+impl MotionSpec {
+    /// A motion over `timing` with no tracks yet.
+    pub fn new(timing: KeyframeTiming) -> Self {
+        Self {
+            tracks: Vec::new(),
+            timing,
+        }
+    }
+
+    /// With `channel` through `frames` (replacing its track, if any).
+    pub fn track(
+        mut self,
+        channel: MotionChannel,
+        frames: impl IntoIterator<Item = Keyframe>,
+    ) -> Self {
+        let frames: Vec<Keyframe> = frames.into_iter().collect();
+        match self.tracks.iter_mut().find(|(c, _)| *c == channel) {
+            Some((_, f)) => *f = frames,
+            None => self.tracks.push((channel, frames)),
+        }
+        self
+    }
+
+    /// With `channel` through `values`, spread evenly.
+    pub fn values(self, channel: MotionChannel, values: impl IntoIterator<Item = f32>) -> Self {
+        self.track(channel, values.into_iter().map(Keyframe::new))
+    }
+
+    /// With its timing changed by `f`.
+    pub fn timing(mut self, f: impl FnOnce(KeyframeTiming) -> KeyframeTiming) -> Self {
+        self.timing = f(self.timing);
+        self
+    }
+
+    /// The tree that plays it on the channels' value nodes (`nodes`, by
+    /// [`MotionChannel::index`]). `reduced`: the user asked for less motion,
+    /// so it lands where it ends at once, without moving.
+    pub fn composite(&self, nodes: &[NodeId; 9], reduced: bool) -> Composite {
+        let mut children = Vec::with_capacity(self.tracks.len());
+        for (channel, frames) in &self.tracks {
+            let node = nodes[channel.index()];
+            if reduced {
+                let Some((_, end)) = keyframe_ends(frames, &self.timing) else {
+                    continue;
+                };
+                children.push(Composite::Run {
+                    node,
+                    driver: Driver::Timing {
+                        to: end,
+                        duration_ms: 0.0,
+                        easing: Easing::Linear,
+                        delay_ms: 0.0,
+                    },
+                });
+            } else {
+                children.push(keyframes(node, frames, &self.timing));
+            }
+        }
+        Composite::Parallel {
+            children,
+            stop_together: true,
+        }
+    }
+
+    /// Where each channel it moves starts: a motion that brings a view in
+    /// starts from there, set before its first frame shows.
+    pub fn start_values(&self) -> impl Iterator<Item = (MotionChannel, f32)> + '_ {
+        self.tracks.iter().filter_map(|(channel, frames)| {
+            Some((*channel, keyframe_ends(frames, &self.timing)?.0))
+        })
+    }
+}
+
+/// What a preset is for.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MotionKind {
+    /// Brings a view in: it starts hidden or away and ends at rest.
+    Enter,
+    /// Takes a view out: it starts at rest and ends hidden or away.
+    Exit,
+    /// Draws the eye: it starts and ends at rest.
+    Attention,
+    /// Repeats until stopped.
+    Loop,
+}
+
+impl MotionKind {
+    /// Its name (`enter`, `exit`, `attention`, `loop`).
+    pub fn name(self) -> &'static str {
+        match self {
+            MotionKind::Enter => "enter",
+            MotionKind::Exit => "exit",
+            MotionKind::Attention => "attention",
+            MotionKind::Loop => "loop",
+        }
+    }
+}
+
+/// Keyframes of a preset's track: `(offset, value)`.
+type Track = (MotionChannel, &'static [(f32, f32)]);
+
+/// A ready-made motion, as data.
+#[derive(Debug)]
+pub struct PresetDef {
+    /// Its name in JS (`fadeIn`, `shake`, ...).
+    pub name: &'static str,
+    /// What it is for.
+    pub kind: MotionKind,
+    /// One line about it.
+    pub doc: &'static str,
+    duration_ms: f32,
+    easing: Easing,
+    iterations: Option<u32>,
+    direction: PlaybackDirection,
+    tracks: &'static [Track],
+}
+
+impl PresetDef {
+    /// The motion.
+    pub fn spec(&self) -> MotionSpec {
+        let timing = KeyframeTiming {
+            duration_ms: self.duration_ms,
+            easing: self.easing,
+            iterations: self.iterations,
+            direction: self.direction,
+            ..KeyframeTiming::default()
+        };
+        let mut spec = MotionSpec::new(timing);
+        for (channel, frames) in self.tracks {
+            spec = spec.track(*channel, frames.iter().map(|&(o, v)| Keyframe::at(o, v)));
+        }
+        spec
+    }
+}
+
+use MotionChannel::{
+    Opacity as O, Rotate as R, RotateX as RX, RotateY as RY, Scale as S, ScaleX as SX,
+    ScaleY as SY, TranslateX as X, TranslateY as Y,
+};
+
+/// Strong deceleration: things that arrive.
+const OUT_EXPO: Easing = Easing::Bezier(0.16, 1.0, 0.3, 1.0);
+/// Things that leave.
+const IN_QUART: Easing = Easing::Bezier(0.5, 0.0, 0.75, 0.0);
+/// A slight overshoot.
+const OUT_BACK: Easing = Easing::Bezier(0.34, 1.56, 0.64, 1.0);
+const IN_OUT_SINE: Easing = Easing::Bezier(0.37, 0.0, 0.63, 1.0);
+
+macro_rules! presets {
+    ($(
+        $(#[doc = $doc:literal])+
+        $variant:ident $name:literal $kind:ident $ms:literal $easing:expr, $iterations:expr, $direction:ident
+            [$($channel:ident: [$(($o:literal, $v:literal)),* $(,)?]),* $(,)?]
+    )*) => {
+        /// The ready-made motions: entrances, exits, attention seekers and
+        /// loops, as data ([`PresetDef`]). Distances are in points, sized
+        /// for controls and cards; scale a motion's values for larger
+        /// views, or write your own keyframes.
+        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+        #[non_exhaustive]
+        pub enum MotionPreset {
+            $($(#[doc = $doc])+ $variant,)*
+        }
+
+        /// Every preset, in [`MotionPreset`]'s order.
+        pub const PRESETS: &[PresetDef] = &[$(
+            PresetDef {
+                name: $name,
+                kind: MotionKind::$kind,
+                doc: concat!($($doc),+),
+                duration_ms: $ms,
+                easing: $easing,
+                iterations: $iterations,
+                direction: PlaybackDirection::$direction,
+                tracks: &[$(($channel, &[$(($o, $v)),*])),*],
+            },
+        )*];
+
+        impl MotionPreset {
+            /// Every preset.
+            pub const ALL: &[MotionPreset] = &[$(MotionPreset::$variant),*];
+        }
+    };
+}
+
+presets! {
+    /// Fades in.
+    FadeIn "fadeIn" Enter 300.0 Easing::EaseOut, Some(1), Normal [O: [(0.0, 0.0), (1.0, 1.0)]]
+    /// Fades in, rising into place.
+    FadeInUp "fadeInUp" Enter 450.0 OUT_EXPO, Some(1), Normal
+        [O: [(0.0, 0.0), (1.0, 1.0)], Y: [(0.0, 24.0), (1.0, 0.0)]]
+    /// Fades in, dropping into place.
+    FadeInDown "fadeInDown" Enter 450.0 OUT_EXPO, Some(1), Normal
+        [O: [(0.0, 0.0), (1.0, 1.0)], Y: [(0.0, -24.0), (1.0, 0.0)]]
+    /// Fades in, coming from the left.
+    FadeInLeft "fadeInLeft" Enter 450.0 OUT_EXPO, Some(1), Normal
+        [O: [(0.0, 0.0), (1.0, 1.0)], X: [(0.0, -24.0), (1.0, 0.0)]]
+    /// Fades in, coming from the right.
+    FadeInRight "fadeInRight" Enter 450.0 OUT_EXPO, Some(1), Normal
+        [O: [(0.0, 0.0), (1.0, 1.0)], X: [(0.0, 24.0), (1.0, 0.0)]]
+    /// Fades in, growing from 90 %.
+    ZoomIn "zoomIn" Enter 350.0 OUT_EXPO, Some(1), Normal
+        [O: [(0.0, 0.0), (1.0, 1.0)], S: [(0.0, 0.9), (1.0, 1.0)]]
+    /// Pops in: grows from half its size past its own and settles.
+    PopIn "popIn" Enter 450.0 OUT_BACK, Some(1), Normal
+        [O: [(0.0, 0.0), (0.4, 1.0), (1.0, 1.0)], S: [(0.0, 0.5), (1.0, 1.0)]]
+    /// Bounces in, overshooting twice.
+    BounceIn "bounceIn" Enter 650.0 Easing::Bezier(0.215, 0.61, 0.355, 1.0), Some(1), Normal
+        [O: [(0.0, 0.0), (0.6, 1.0), (1.0, 1.0)],
+         S: [(0.0, 0.3), (0.2, 1.1), (0.4, 0.9), (0.6, 1.03), (0.8, 0.97), (1.0, 1.0)]]
+    /// Slides up from below, unfaded (sheets, toasts).
+    SlideInUp "slideInUp" Enter 400.0 OUT_EXPO, Some(1), Normal [Y: [(0.0, 64.0), (1.0, 0.0)]]
+    /// Slides down from above, unfaded.
+    SlideInDown "slideInDown" Enter 400.0 OUT_EXPO, Some(1), Normal [Y: [(0.0, -64.0), (1.0, 0.0)]]
+    /// Flips in about its horizontal axis.
+    FlipInX "flipInX" Enter 600.0 Easing::EaseOut, Some(1), Normal
+        [O: [(0.0, 0.0), (0.6, 1.0), (1.0, 1.0)], RX: [(0.0, 90.0), (0.4, -20.0), (0.6, 10.0), (0.8, -5.0), (1.0, 0.0)]]
+    /// Flips in about its vertical axis.
+    FlipInY "flipInY" Enter 600.0 Easing::EaseOut, Some(1), Normal
+        [O: [(0.0, 0.0), (0.6, 1.0), (1.0, 1.0)], RY: [(0.0, 90.0), (0.4, -20.0), (0.6, 10.0), (0.8, -5.0), (1.0, 0.0)]]
+    /// Fades out.
+    FadeOut "fadeOut" Exit 250.0 Easing::EaseIn, Some(1), Normal [O: [(0.0, 1.0), (1.0, 0.0)]]
+    /// Fades out, rising away.
+    FadeOutUp "fadeOutUp" Exit 300.0 IN_QUART, Some(1), Normal
+        [O: [(0.0, 1.0), (1.0, 0.0)], Y: [(0.0, 0.0), (1.0, -24.0)]]
+    /// Fades out, dropping away.
+    FadeOutDown "fadeOutDown" Exit 300.0 IN_QUART, Some(1), Normal
+        [O: [(0.0, 1.0), (1.0, 0.0)], Y: [(0.0, 0.0), (1.0, 24.0)]]
+    /// Fades out to the left.
+    FadeOutLeft "fadeOutLeft" Exit 300.0 IN_QUART, Some(1), Normal
+        [O: [(0.0, 1.0), (1.0, 0.0)], X: [(0.0, 0.0), (1.0, -24.0)]]
+    /// Fades out to the right.
+    FadeOutRight "fadeOutRight" Exit 300.0 IN_QUART, Some(1), Normal
+        [O: [(0.0, 1.0), (1.0, 0.0)], X: [(0.0, 0.0), (1.0, 24.0)]]
+    /// Fades out, shrinking to 90 %.
+    ZoomOut "zoomOut" Exit 250.0 IN_QUART, Some(1), Normal
+        [O: [(0.0, 1.0), (1.0, 0.0)], S: [(0.0, 1.0), (1.0, 0.9)]]
+    /// Pops out: swells a little, then shrinks away.
+    PopOut "popOut" Exit 300.0 Easing::EaseIn, Some(1), Normal
+        [O: [(0.0, 1.0), (0.3, 1.0), (1.0, 0.0)], S: [(0.0, 1.0), (0.3, 1.08), (1.0, 0.5)]]
+    /// Slides down out of view, unfaded.
+    SlideOutDown "slideOutDown" Exit 300.0 IN_QUART, Some(1), Normal [Y: [(0.0, 0.0), (1.0, 64.0)]]
+    /// Slides up out of view, unfaded.
+    SlideOutUp "slideOutUp" Exit 300.0 IN_QUART, Some(1), Normal [Y: [(0.0, 0.0), (1.0, -64.0)]]
+    /// Shakes sideways: a wrong password, an invalid field.
+    Shake "shake" Attention 500.0 Easing::EaseInOut, Some(1), Normal
+        [X: [(0.0, 0.0), (0.1, -8.0), (0.3, 8.0), (0.5, -8.0), (0.7, 8.0), (0.9, -4.0), (1.0, 0.0)]]
+    /// Shakes its head: a gentler no.
+    HeadShake "headShake" Attention 800.0 Easing::EaseInOut, Some(1), Normal
+        [X: [(0.0, 0.0), (0.065, -6.0), (0.185, 5.0), (0.315, -3.0), (0.435, 2.0), (0.5, 0.0), (1.0, 0.0)],
+         RY: [(0.0, 0.0), (0.065, -9.0), (0.185, 7.0), (0.315, -5.0), (0.435, 3.0), (0.5, 0.0), (1.0, 0.0)]]
+    /// Wiggles about its center: something to notice.
+    Wiggle "wiggle" Attention 600.0 Easing::EaseInOut, Some(1), Normal
+        [R: [(0.0, 0.0), (0.15, -6.0), (0.3, 6.0), (0.45, -4.0), (0.6, 4.0), (0.75, -2.0), (1.0, 0.0)]]
+    /// Swings side to side like a hanging sign (hang it from its top edge
+    /// with a top transform origin).
+    Swing "swing" Attention 900.0 Easing::EaseInOut, Some(1), Normal
+        [R: [(0.0, 0.0), (0.2, 15.0), (0.4, -10.0), (0.6, 5.0), (0.8, -5.0), (1.0, 0.0)]]
+    /// Grows a little and back: a soft emphasis.
+    Pulse "pulse" Attention 500.0 Easing::EaseInOut, Some(1), Normal
+        [S: [(0.0, 1.0), (0.5, 1.06), (1.0, 1.0)]]
+    /// Beats twice, like a heart: a like, a favorite.
+    Heartbeat "heartbeat" Attention 900.0 Easing::EaseInOut, Some(1), Normal
+        [S: [(0.0, 1.0), (0.14, 1.25), (0.28, 1.0), (0.42, 1.25), (0.7, 1.0), (1.0, 1.0)]]
+    /// Squashes and stretches like rubber.
+    RubberBand "rubberBand" Attention 800.0 Easing::EaseInOut, Some(1), Normal
+        [SX: [(0.0, 1.0), (0.3, 1.25), (0.4, 0.75), (0.5, 1.15), (0.65, 0.95), (0.75, 1.05), (1.0, 1.0)],
+         SY: [(0.0, 1.0), (0.3, 0.75), (0.4, 1.25), (0.5, 0.85), (0.65, 1.05), (0.75, 0.95), (1.0, 1.0)]]
+    /// Wobbles like jelly.
+    Jello "jello" Attention 800.0 Easing::EaseInOut, Some(1), Normal
+        [SX: [(0.0, 1.0), (0.2, 1.12), (0.4, 0.92), (0.6, 1.05), (0.8, 0.98), (1.0, 1.0)],
+         SY: [(0.0, 1.0), (0.2, 0.88), (0.4, 1.08), (0.6, 0.95), (0.8, 1.02), (1.0, 1.0)]]
+    /// Shrinks, then grows with a wiggle: a reward.
+    Tada "tada" Attention 900.0 Easing::EaseInOut, Some(1), Normal
+        [S: [(0.0, 1.0), (0.1, 0.9), (0.2, 0.9), (0.3, 1.1), (0.9, 1.1), (1.0, 1.0)],
+         R: [(0.0, 0.0), (0.1, -3.0), (0.2, -3.0), (0.3, 3.0), (0.4, -3.0), (0.5, 3.0), (0.6, -3.0), (0.7, 3.0), (0.8, -3.0), (0.9, 3.0), (1.0, 0.0)]]
+    /// Jumps and lands with a squash.
+    Jump "jump" Attention 700.0 Easing::EaseInOut, Some(1), Normal
+        [Y: [(0.0, 0.0), (0.4, -18.0), (0.55, -18.0), (0.8, 0.0), (1.0, 0.0)],
+         SY: [(0.0, 1.0), (0.15, 0.9), (0.4, 1.05), (0.8, 0.92), (1.0, 1.0)]]
+    /// Blinks out and in twice.
+    Flash "flash" Attention 800.0 Easing::Linear, Some(1), Normal
+        [O: [(0.0, 1.0), (0.25, 0.0), (0.5, 1.0), (0.75, 0.0), (1.0, 1.0)]]
+    /// Turns once, forever (CSS `animate-spin`'s turn on a motion).
+    Spin "spin" Loop 1000.0 Easing::Linear, None, Normal [R: [(0.0, 0.0), (1.0, 360.0)]]
+    /// Floats up and down, forever.
+    Float "float" Loop 1500.0 IN_OUT_SINE, None, Alternate [Y: [(0.0, 0.0), (1.0, -8.0)]]
+    /// Breathes: grows and fades a little, forever.
+    Breathe "breathe" Loop 1800.0 IN_OUT_SINE, None, Alternate
+        [S: [(0.0, 1.0), (1.0, 1.05)], O: [(0.0, 1.0), (1.0, 0.8)]]
+    /// Sways about its center, forever.
+    Sway "sway" Loop 1600.0 IN_OUT_SINE, None, Alternate [R: [(0.0, -4.0), (1.0, 4.0)]]
+    /// Bobs like a bouncing ball, forever (Tailwind's `animate-bounce`).
+    Bob "bob" Loop 1000.0 Easing::Linear, None, Normal
+        [Y: [(0.0, 0.0), (0.5, -12.0), (1.0, 0.0)]]
+    /// Glows in and out, forever (Tailwind's `animate-pulse` on a motion).
+    Glow "glow" Loop 2000.0 Easing::Bezier(0.4, 0.0, 0.6, 1.0), None, Normal
+        [O: [(0.0, 1.0), (0.5, 0.5), (1.0, 1.0)]]
+}
+
+impl MotionPreset {
+    /// Its definition.
+    pub fn def(self) -> &'static PresetDef {
+        &PRESETS[self as usize]
+    }
+
+    /// Its name in JS.
+    pub fn name(self) -> &'static str {
+        self.def().name
+    }
+
+    /// The preset named `name`.
+    pub fn parse(name: &str) -> Option<MotionPreset> {
+        MotionPreset::ALL.iter().copied().find(|p| p.name() == name)
+    }
+
+    /// The motion.
+    pub fn spec(self) -> MotionSpec {
+        self.def().spec()
+    }
+}
+
+impl From<MotionPreset> for MotionSpec {
+    fn from(preset: MotionPreset) -> Self {
+        preset.spec()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::animation::{AnimationOp, Finished, Graph};
+
+    fn value(g: &mut Graph, node: NodeId, start: f32) {
+        g.apply(
+            AnimationOp::CreateValue { node, value: start },
+            &mut Vec::new(),
+        );
+    }
+
+    /// Runs `spec` on node 2 from `start`, sampled at `times` (ms).
+    fn run(spec: Composite, start: f32, times: &[f64]) -> (Vec<f32>, Vec<Finished>) {
+        let mut g = Graph::default();
+        value(&mut g, 2, start);
+        let mut f = Vec::new();
+        g.apply(
+            AnimationOp::StartComposite {
+                animation: 10,
+                key: 12,
+                spec,
+            },
+            &mut f,
+        );
+        // Frames every 10 ms up to each sample (a composite starts at most a
+        // few steps per frame).
+        let mut out = Vec::new();
+        let mut now = 0.0;
+        for &t in times {
+            while now + 10.0 < t {
+                now += 10.0;
+                g.tick(now / 1000.0, &mut f);
+            }
+            now = t;
+            g.tick(t / 1000.0, &mut f);
+            out.push((g.value(2).unwrap() * 1000.0).round() / 1000.0);
+        }
+        (out, f)
+    }
+
+    fn linear(ms: f32) -> KeyframeTiming {
+        KeyframeTiming::new(ms).easing(Easing::Linear)
+    }
+
+    #[test]
+    fn offsets_spread_as_the_web_animations_api_spreads_them() {
+        let frames = [
+            Keyframe::new(0.0),
+            Keyframe::new(1.0),
+            Keyframe::at(0.8, 2.0),
+            Keyframe::new(3.0),
+        ];
+        let resolved: Vec<f32> = resolve(&frames).iter().map(|f| f.0).collect();
+        assert_eq!(resolved, [0.0, 0.4, 0.8, 1.0]);
+        assert_eq!(resolve(&[Keyframe::new(5.0)])[0].0, 1.0);
+        // Out of order: held at the one before.
+        let held: Vec<f32> = resolve(&[Keyframe::at(0.5, 0.0), Keyframe::at(0.2, 1.0)])
+            .iter()
+            .map(|f| f.0)
+            .collect();
+        assert_eq!(held, [0.5, 0.5]);
+    }
+
+    #[test]
+    fn keyframes_play_their_segments_on_time() {
+        let frames = [
+            Keyframe::new(0.0),
+            Keyframe::at(0.25, 10.0),
+            Keyframe::new(0.0),
+        ];
+        let spec = keyframes(2, &frames, &linear(400.0));
+        // The jump to the first keyframe happens on the first frame.
+        let (values, f) = run(spec, 7.0, &[0.0, 50.0, 100.0, 250.0, 400.0, 450.0]);
+        assert_eq!(values, [0.0, 5.0, 10.0, 5.0, 0.0, 0.0]);
+        assert!(f.iter().any(|f| f.animation == 10 && f.finished), "{f:?}");
+    }
+
+    #[test]
+    fn directions_iterations_and_delays() {
+        let frames = [Keyframe::new(0.0), Keyframe::new(10.0)];
+        let alternate = linear(100.0)
+            .direction(PlaybackDirection::Alternate)
+            .iterations(Some(3));
+        let (values, _) = run(
+            keyframes(2, &frames, &alternate),
+            0.0,
+            &[0.0, 50.0, 150.0, 250.0, 300.0, 400.0],
+        );
+        assert_eq!(values, [0.0, 5.0, 5.0, 5.0, 10.0, 10.0]);
+        let reverse = linear(100.0)
+            .direction(PlaybackDirection::Reverse)
+            .delay(100.0);
+        let (values, _) = run(
+            keyframes(2, &frames, &reverse),
+            3.0,
+            &[0.0, 50.0, 150.0, 200.0],
+        );
+        // The delay holds the first keyframe (the last, backwards).
+        assert_eq!(values, [10.0, 10.0, 5.0, 0.0]);
+        // Endless: still going long after.
+        let (values, f) = run(
+            keyframes(2, &frames, &linear(100.0).infinite()),
+            0.0,
+            &[0.0, 10_050.0],
+        );
+        assert_eq!(values[1], 5.0);
+        assert!(!f.iter().any(|f| f.animation == 10), "{f:?}");
+        assert_eq!(
+            keyframe_ends(&frames, &alternate.iterations(Some(2))),
+            Some((0.0, 0.0))
+        );
+    }
+
+    #[test]
+    fn a_reversed_segment_runs_its_curve_backwards() {
+        let ease = Easing::Bezier(0.42, 0.0, 1.0, 1.0);
+        let frames = [Keyframe::new(0.0).ease(ease), Keyframe::new(1.0)];
+        let timing = KeyframeTiming::new(100.0).direction(PlaybackDirection::Reverse);
+        let (backwards, _) = run(keyframes(2, &frames, &timing), 0.0, &[0.0, 25.0]);
+        let (forwards, _) = run(
+            keyframes(2, &frames, &KeyframeTiming::new(100.0)),
+            0.0,
+            &[0.0, 75.0],
+        );
+        assert!(
+            (backwards[1] - forwards[1]).abs() < 1e-3,
+            "{backwards:?} {forwards:?}"
+        );
+    }
+
+    #[test]
+    fn steps_jump_as_in_css() {
+        use super::super::sample_easing;
+        use crate::animation::StepPosition::*;
+        let at = |e| sample_easing(Easing::Steps(4, e), 5);
+        assert_eq!(at(JumpEnd), [0.0, 0.25, 0.5, 0.75, 1.0]);
+        assert_eq!(at(JumpStart), [0.25, 0.5, 0.75, 1.0, 1.0]);
+        let none = sample_easing(Easing::Steps(3, JumpNone), 4);
+        assert_eq!(none, [0.0, 0.5, 1.0, 1.0]);
+        let both = sample_easing(Easing::Steps(1, JumpBoth), 3);
+        assert_eq!(both, [0.5, 0.5, 1.0]);
+        assert_eq!(
+            Easing::Steps(2, JumpStart).reversed(),
+            Easing::Steps(2, JumpEnd)
+        );
+    }
+
+    #[test]
+    fn staggering_counts_from_its_origin() {
+        let delays = |from| {
+            (0..5)
+                .map(|i| stagger_delay(i, 5, from))
+                .collect::<Vec<_>>()
+        };
+        assert_eq!(delays(StaggerFrom::First), [0.0, 1.0, 2.0, 3.0, 4.0]);
+        assert_eq!(delays(StaggerFrom::Last), [4.0, 3.0, 2.0, 1.0, 0.0]);
+        assert_eq!(delays(StaggerFrom::Center), [2.0, 1.0, 0.0, 1.0, 2.0]);
+        assert_eq!(delays(StaggerFrom::Index(1)), [1.0, 0.0, 1.0, 2.0, 3.0]);
+        let even: Vec<f32> = (0..4)
+            .map(|i| stagger_delay(i, 4, StaggerFrom::Center))
+            .collect();
+        assert_eq!(even, [1.0, 0.0, 0.0, 1.0]);
+    }
+
+    #[test]
+    fn presets_are_well_formed() {
+        assert_eq!(PRESETS.len(), MotionPreset::ALL.len());
+        for (i, preset) in MotionPreset::ALL.iter().enumerate() {
+            let def = preset.def();
+            assert_eq!(def.name, PRESETS[i].name);
+            assert_eq!(MotionPreset::parse(def.name), Some(*preset));
+            let spec = preset.spec();
+            assert!(!spec.tracks.is_empty(), "{}", def.name);
+            for (channel, frames) in &spec.tracks {
+                let resolved = resolve(frames);
+                assert_eq!(resolved.first().map(|f| f.0), Some(0.0), "{}", def.name);
+                assert_eq!(resolved.last().map(|f| f.0), Some(1.0), "{}", def.name);
+                assert!(
+                    resolved.windows(2).all(|w| w[0].0 <= w[1].0),
+                    "{}",
+                    def.name
+                );
+                let (start, end) = keyframe_ends(frames, &spec.timing).unwrap();
+                let rest = channel.identity();
+                match def.kind {
+                    MotionKind::Enter => assert_eq!(end, rest, "{} ends at rest", def.name),
+                    MotionKind::Exit => assert_eq!(start, rest, "{} starts at rest", def.name),
+                    MotionKind::Attention => {
+                        assert_eq!((start, end), (rest, rest), "{}", def.name)
+                    }
+                    MotionKind::Loop => assert!(spec.timing.iterations.is_none(), "{}", def.name),
+                }
+            }
+        }
+    }
+
+    #[test]
+    fn a_reduced_motion_lands_at_its_end() {
+        let spec = MotionPreset::FadeInUp.spec();
+        let nodes = [2, 4, 6, 8, 10, 12, 14, 16, 18];
+        let Composite::Parallel { children, .. } = spec.composite(&nodes, true) else {
+            panic!()
+        };
+        assert_eq!(children.len(), 2);
+        for child in &children {
+            assert!(matches!(
+                child,
+                Composite::Run {
+                    driver: Driver::Timing {
+                        duration_ms: 0.0,
+                        ..
+                    },
+                    ..
+                }
+            ));
+        }
+        let starts: Vec<_> = spec.start_values().collect();
+        assert_eq!(
+            starts,
+            [
+                (MotionChannel::Opacity, 0.0),
+                (MotionChannel::TranslateY, 24.0)
+            ]
+        );
+    }
+}
diff --git a/third-party/nsh/crates/nsh-core/src/animation/ops.rs b/third-party/nsh/crates/nsh-core/src/animation/ops.rs
index b50c6f5b01..f6ee9f19ad 100644
--- a/third-party/nsh/crates/nsh-core/src/animation/ops.rs
+++ b/third-party/nsh/crates/nsh-core/src/animation/ops.rs
@@ -62,6 +62,82 @@ pub enum Easing {
     /// One of React Native's `Easing` functions with its `in` / `out` /
     /// `inOut` form.
     Curve(Curve, EaseMode),
+    /// CSS `steps(count, position)`: jumps between `count` levels instead
+    /// of moving (a frame-by-frame sprite, a ticking clock hand).
+    Steps(u32, StepPosition),
+}
+
+impl Easing {
+    /// CSS `ease`.
+    pub const EASE: Easing = Easing::Bezier(0.25, 0.1, 0.25, 1.0);
+
+    /// The curve played backwards (`1 - e(1 - t)`): what a keyframe segment
+    /// follows when its animation runs in reverse, as in CSS.
+    pub fn reversed(self) -> Easing {
+        match self {
+            Easing::Linear => Easing::Linear,
+            Easing::EaseIn => Easing::EaseOut,
+            Easing::EaseOut => Easing::EaseIn,
+            Easing::EaseInOut => Easing::EaseInOut,
+            Easing::Bezier(x1, y1, x2, y2) => {
+                Easing::Bezier(1.0 - x2, 1.0 - y2, 1.0 - x1, 1.0 - y1)
+            }
+            Easing::Curve(curve, mode) => Easing::Curve(
+                curve,
+                match mode {
+                    EaseMode::In => EaseMode::Out,
+                    EaseMode::Out => EaseMode::In,
+                    EaseMode::InOut => EaseMode::InOut,
+                },
+            ),
+            Easing::Steps(count, position) => Easing::Steps(
+                count,
+                match position {
+                    StepPosition::JumpStart => StepPosition::JumpEnd,
+                    StepPosition::JumpEnd => StepPosition::JumpStart,
+                    other => other,
+                },
+            ),
+        }
+    }
+}
+
+/// Where a [`Easing::Steps`] curve jumps (CSS's `<step-position>`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum StepPosition {
+    /// The first jump happens at the start (`jump-start`, `start`).
+    JumpStart,
+    /// The last jump happens at the end (`jump-end`, `end`; the default).
+    #[default]
+    JumpEnd,
+    /// No jump at either end: holds the first and the last level
+    /// (`jump-none`).
+    JumpNone,
+    /// Jumps at both ends (`jump-both`).
+    JumpBoth,
+}
+
+impl StepPosition {
+    /// Parses a CSS `<step-position>`.
+    pub fn parse(name: &str) -> Option<StepPosition> {
+        Some(match name {
+            "jump-start" | "start" => StepPosition::JumpStart,
+            "jump-end" | "end" => StepPosition::JumpEnd,
+            "jump-none" => StepPosition::JumpNone,
+            "jump-both" => StepPosition::JumpBoth,
+            _ => return None,
+        })
+    }
+
+    /// Its CSS name.
+    pub fn name(self) -> &'static str {
+        match self {
+            StepPosition::JumpStart => "jump-start",
+            StepPosition::JumpEnd => "jump-end",
+            StepPosition::JumpNone => "jump-none",
+            StepPosition::JumpBoth => "jump-both",
+        }
+    }
 }
 
 /// A shape of React Native's `Easing` module (its "in" form).
diff --git a/third-party/nsh/crates/nsh-core/src/byte_size.rs b/third-party/nsh/crates/nsh-core/src/byte_size.rs
new file mode 100644
index 0000000000..80ba38e287
--- /dev/null
+++ b/third-party/nsh/crates/nsh-core/src/byte_size.rs
@@ -0,0 +1,68 @@
+//! A size in bytes, as limits of what is kept (caches) are given: in
+//! mebibytes or gibibytes, typed as lengths are ([`crate::geometry`]).
+
+use std::fmt;
+
+/// A size in bytes, made from mebibytes or gibibytes (`ByteSize::mib(256)`):
+/// how much a cache keeps at most.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct ByteSize(u64);
+
+impl ByteSize {
+    /// No bytes.
+    pub const ZERO: ByteSize = ByteSize(0);
+
+    /// `n` mebibytes (`n` × 2²⁰ bytes).
+    pub const fn mib(n: u64) -> ByteSize {
+        ByteSize(n.saturating_mul(1 << 20))
+    }
+
+    /// `n` gibibytes (`n` × 2³⁰ bytes).
+    pub const fn gib(n: u64) -> ByteSize {
+        ByteSize(n.saturating_mul(1 << 30))
+    }
+
+    /// The size in bytes.
+    pub const fn bytes(self) -> u64 {
+        self.0
+    }
+
+    /// Whether it is no bytes.
+    pub const fn is_zero(self) -> bool {
+        self.0 == 0
+    }
+
+    /// The size in bytes, for tests and internal bookkeeping of a crate
+    /// (whole mebibytes are the public way to make one).
+    #[cfg(test)]
+    pub(crate) const fn from_bytes(bytes: u64) -> ByteSize {
+        ByteSize(bytes)
+    }
+}
+
+impl fmt::Display for ByteSize {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if self.0.is_multiple_of(1 << 30) && self.0 > 0 {
+            write!(f, "{} GiB", self.0 >> 30)
+        } else if self.0.is_multiple_of(1 << 20) {
+            write!(f, "{} MiB", self.0 >> 20)
+        } else {
+            write!(f, "{} bytes", self.0)
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::ByteSize;
+
+    #[test]
+    fn sizes_are_made_from_mebibytes() {
+        assert_eq!(ByteSize::mib(256).bytes(), 256 << 20);
+        assert_eq!(ByteSize::gib(2), ByteSize::mib(2048));
+        assert!(ByteSize::mib(0).is_zero());
+        assert_eq!(ByteSize::mib(u64::MAX).bytes(), u64::MAX);
+        assert_eq!(ByteSize::mib(256).to_string(), "256 MiB");
+        assert_eq!(ByteSize::gib(1).to_string(), "1 GiB");
+    }
+}
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 06f849a1de..047d702c0a 100644
--- a/third-party/nsh/crates/nsh-core/src/core/components.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/components.rs
@@ -1061,6 +1061,7 @@ impl<M: TextEngine> Core<M> {
                 self.key(child)?;
                 let inst = self.components.instances.get_mut(&parent).expect("host");
                 let was_empty = inst.js_children.is_empty();
+                let moved = inst.js_children.contains(&child);
                 inst.js_children.retain(|&c| c != child);
                 let at = before.and_then(|b| inst.js_children.iter().position(|&c| c == b));
                 inst.js_children
@@ -1087,7 +1088,10 @@ impl<M: TextEngine> Core<M> {
                         let anchor = self.anchor_node(anchor);
                         self.join_nested_at(nested, child, at.and(before).or(anchor));
                     }
-                    // Its wrapper is the view's (a region of them).
+                    // Its wrapper is the view's (a region of them, keyed
+                    // by the child): a child moved among the others keeps
+                    // its wrapper, which the region moves.
+                    Placement::Each if moved => {}
                     Placement::Each => self.detach_id(child),
                     Placement::None => self.detach_id(child),
                 }
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 afe6c3a895..4779c3d75f 100644
--- a/third-party/nsh/crates/nsh-core/src/core/mod.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/mod.rs
@@ -3081,6 +3081,7 @@ impl<M: TextEngine> Core<M> {
                         ScreenProp::HeaderTransparent(v) => c.header_transparent = v,
                         ScreenProp::HeaderBackTitle(v) => c.back_title = v,
                         ScreenProp::HeaderBackVisible(v) => c.back_visible = v,
+                        ScreenProp::HeaderRightSystemItem(v) => c.right_system_item = v,
                         ScreenProp::PopOnSwipe(v) => c.gesture_enabled = v,
                         ScreenProp::PopOnBack(v) => c.prevent_back = !v,
                         ScreenProp::Animation(v) => c.animation = v,
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 f41f7539db..c5099d5763 100644
--- a/third-party/nsh/crates/nsh-core/src/core/tests.rs
+++ b/third-party/nsh/crates/nsh-core/src/core/tests.rs
@@ -7050,6 +7050,36 @@ mod components {
         assert_eq!(frame_of(&batch, w4).map(|f| f.origin.y.0), Some(26.0));
         assert!(wrapper_of(&batch, 4).is_none() && wrapper_of(&batch, 6).is_none());
 
+        // A child moved (inserted again, as a keyed `<Each>` moves its row)
+        // keeps its wrapper and stays in it; the wrapper moves.
+        apply(
+            &mut c,
+            vec![Op::Insert {
+                parent: 2,
+                child: 8,
+                before: None,
+            }],
+        );
+        let batch = c.commit().unwrap();
+        assert!(
+            !batch
+                .items
+                .iter()
+                .any(|i| matches!(i, MountItem::Remove { id } if *id == 8)),
+            "the moved child stays in its wrapper"
+        );
+        assert_eq!(frame_of(&batch, w8).map(|f| f.origin.y.0), Some(52.0));
+        assert_eq!(frame_of(&batch, w4).map(|f| f.origin.y.0), Some(0.0));
+        apply(
+            &mut c,
+            vec![Op::Insert {
+                parent: 2,
+                child: 8,
+                before: Some(4),
+            }],
+        );
+        c.commit().unwrap();
+
         // A child removed takes its wrapper away; the child itself stays JS's.
         apply(
             &mut c,
@@ -11486,6 +11516,69 @@ mod views {
         assert_eq!(dark.background_color, Some(crate::Color(0x000000ff)));
     }
 
+    /// A screen whose trailing header button is UIKit's own.
+    #[derive(Default)]
+    struct SystemItemPage;
+
+    #[derive(Clone, PartialEq, Props)]
+    struct SystemItemPageProps {}
+
+    impl Component for SystemItemPage {
+        type Props = SystemItemPageProps;
+        type Event = NoEvent;
+
+        // The core's own stack and screen: `nsh_base`'s wrap them.
+        #[allow(deprecated)]
+        fn view(_: SystemItemPageProps, _: Scope<NoEvent>) -> impl IntoElement {
+            stack().flex_1().child(
+                screen()
+                    .header_right_system_item(crate::HeaderSystemItem::Add)
+                    .header_right(view().on_press(|_: &()| {}))
+                    .child(view().flex_1()),
+            )
+        }
+    }
+
+    /// The platform draws the trailing header button itself, by the name the
+    /// screen passes (`add` here); the `headerRight` view stays the press
+    /// target either way.
+    #[test]
+    fn a_system_header_button_reaches_the_screens_config() {
+        let mut c = core();
+        c.mount_root(&Root::new::<SystemItemPage>(|| SystemItemPageProps {}));
+        let config = c
+            .commit()
+            .unwrap()
+            .items
+            .iter()
+            .find_map(|i| match i {
+                MountItem::ScreenConfig { config, .. } => Some((**config).clone()),
+                _ => None,
+            })
+            .expect("a screen");
+        assert_eq!(config.right_system_item, Some(crate::HeaderSystemItem::Add));
+        assert!(config.right.is_some(), "the trailing slot is set");
+    }
+
+    /// Without the prop the header is built as before: no system button, so
+    /// the platform wraps the app's `headerRight` view instead.
+    #[test]
+    fn a_screen_without_the_prop_has_no_system_header_button() {
+        let mut c = core();
+        c.mount_root(&Root::new::<Page>(|| PageProps {}));
+        let config = c
+            .commit()
+            .unwrap()
+            .items
+            .iter()
+            .find_map(|i| match i {
+                MountItem::ScreenConfig { config, .. } => Some((**config).clone()),
+                _ => None,
+            })
+            .expect("a screen");
+        assert_eq!(config.right_system_item, None);
+    }
+
     /// A holder of its children as its view's root.
     #[derive(Default)]
     struct Tray;
diff --git a/third-party/nsh/crates/nsh-core/src/image.rs b/third-party/nsh/crates/nsh-core/src/image.rs
index 7823401ddc..18210faacc 100644
--- a/third-party/nsh/crates/nsh-core/src/image.rs
+++ b/third-party/nsh/crates/nsh-core/src/image.rs
@@ -11,6 +11,9 @@
 //! them as they do a download's. The backends that cannot take the source
 //! itself (Android's Java loader, the browser's `<img>`) look the bytes up by
 //! URI in an [`InlineImages`].
+//!
+//! Downloaded images are kept on disk by a [`DiskCache`] (native backends:
+//! `cache.rs` has the policy and the format).
 
 use std::{
     fmt,
@@ -20,6 +23,11 @@ use std::{
 
 use rustc_hash::FxHashMap;
 
+#[cfg(not(target_arch = "wasm32"))]
+mod cache;
+#[cfg(not(target_arch = "wasm32"))]
+pub use cache::{CacheHeaders, DEFAULT_DISK_CACHE_SIZE, DiskCache, Fetched, Lookup, Validators};
+
 type Bytes = dyn AsRef<[u8]> + Send + Sync;
 
 /// The bytes of an in-memory image, shared: cloning one copies no bytes.
diff --git a/third-party/nsh/crates/nsh-core/src/image/cache.rs b/third-party/nsh/crates/nsh-core/src/image/cache.rs
new file mode 100644
index 0000000000..83c07f9e9d
--- /dev/null
+++ b/third-party/nsh/crates/nsh-core/src/image/cache.rs
@@ -0,0 +1,789 @@
+//! A persistent cache of downloaded image bytes: what the backends'
+//! loaders fetch over HTTP(S) is kept on disk, encoded (never decoded
+//! bitmaps), so an app shows images it showed before without downloading
+//! them again, across launches and while offline.
+//!
+//! One shared implementation for every native backend: the loaders keep
+//! their own networking (ureq on the desktops, `NSURLSession` on Apple's,
+//! `HttpURLConnection` on Android) and route each download through
+//! [`DiskCache::lookup`] and [`DiskCache::complete`] (or
+//! [`DiskCache::load`], which does both around a blocking fetch). Every call
+//! does file IO: call them off the main thread.
+//!
+//! **Policy** (HTTP caching as a private cache, RFC 9111, simplified):
+//! - A `200` (or `203`) response is stored unless it says `no-store` or
+//!   `Vary: *`, or is larger than an eighth of the cache. Other statuses are
+//!   never stored.
+//! - It is fresh for `Cache-Control: max-age`, else `Expires` − `Date`, else
+//!   a tenth of the time since `Last-Modified` (at most 7 days), less its
+//!   age (`Age`, or the time since `Date`); with none of them, or with
+//!   `no-cache` (or `Pragma: no-cache`), it is stale at once.
+//! - A fresh entry is served without a request. A stale one is revalidated
+//!   with `If-None-Match` / `If-Modified-Since` (its `ETag` /
+//!   `Last-Modified`): a `304` serves it again, fresh by the `304`'s
+//!   headers over the stored ones; a `200` replaces it; a `4xx` drops it.
+//! - When the request fails (no network) or the server answers `5xx`, a
+//!   stale entry is served anyway, unless it said `must-revalidate` or
+//!   `no-cache`.
+//!
+//! Entries are keyed by URL alone: image sources carry no request headers,
+//! and a loader sends the same ones for every image, so whatever a
+//! response varies on (`Vary`) is the same for all of its requests.
+//!
+//! **Storage**: one file per entry in the cache's directory, named by a
+//! digest of its URL, holding the URL, the policy's dates and headers, the
+//! body and a checksum of all of them. A file is written whole under a
+//! temporary name and renamed into place, so readers (other threads,
+//! surfaces or processes on the same directory) see a complete entry or
+//! none, without locks. An entry that does not read back whole (truncated,
+//! a checksum or URL mismatch, an unknown format) is deleted and is a miss.
+//! The cache is bounded in bytes: past its limit the least recently used
+//! entries (by file modification time, which a hit refreshes) are deleted
+//! until it is down to three quarters of it.
+//!
+//! **Sharing**: a [`DiskCache`] is a handle; surfaces of one app open their
+//! own on the app's directory (there is no process-wide registry), and the
+//! files are the only state they share. Each handle keeps an estimate of
+//! the directory's size, made exact by a scan when it trims.
+
+use std::{
+    fs,
+    io::{self, Read, Write},
+    path::{Path, PathBuf},
+    sync::{
+        Arc, Mutex,
+        atomic::{AtomicU64, Ordering},
+    },
+    time::{Duration, SystemTime, UNIX_EPOCH},
+};
+
+use super::ImageData;
+use crate::ByteSize;
+
+/// The size a disk cache is bounded to unless its embedder says otherwise.
+pub const DEFAULT_DISK_CACHE_SIZE: ByteSize = ByteSize::mib(256);
+
+/// What a file starts with: the format and its version.
+const MAGIC: &[u8; 8] = b"NSHIMG1\n";
+
+/// The longest header read back (an entry with a longer one is corrupt).
+const MAX_HEADER: usize = 64 << 10;
+
+/// The longest heuristic freshness (`Last-Modified` without explicit
+/// freshness): RFC 9111's suggested tenth of the age, capped as browsers do.
+const MAX_HEURISTIC: Duration = Duration::from_secs(7 * 24 * 60 * 60);
+
+/// A hit refreshes an entry's modification time (its place in the LRU
+/// order) when it is at least this old: not a write per hit.
+const TOUCH_AFTER: Duration = Duration::from_secs(10 * 60);
+
+/// Temporary files older than this were left by a writer that died.
+const STALE_TEMPORARY: Duration = Duration::from_secs(60 * 60);
+
+/// The response headers the cache reads: [`CacheHeaders::NAMES`].
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+#[non_exhaustive]
+pub struct CacheHeaders {
+    /// `Cache-Control`.
+    pub cache_control: Option<String>,
+    /// `Expires`.
+    pub expires: Option<String>,
+    /// `Date`.
+    pub date: Option<String>,
+    /// `Age`.
+    pub age: Option<String>,
+    /// `ETag`.
+    pub etag: Option<String>,
+    /// `Last-Modified`.
+    pub last_modified: Option<String>,
+    /// `Vary`.
+    pub vary: Option<String>,
+    /// `Pragma` (`no-cache` without a `Cache-Control`).
+    pub pragma: Option<String>,
+}
+
+impl CacheHeaders {
+    /// The names of the headers the cache reads, in lower case: a loader
+    /// hands over these (and may hand over more, which are ignored).
+    pub const NAMES: [&'static str; 8] = [
+        "cache-control",
+        "expires",
+        "date",
+        "age",
+        "etag",
+        "last-modified",
+        "vary",
+        "pragma",
+    ];
+
+    /// The headers among `pairs` (name, value), names in any case;
+    /// repeated ones are joined with `, ` (as HTTP combines field lines).
+    pub fn from_pairs<'a>(pairs: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
+        let mut headers = Self::default();
+        for (name, value) in pairs {
+            if let Some(slot) = headers.slot(name) {
+                match slot {
+                    Some(existing) => {
+                        existing.push_str(", ");
+                        existing.push_str(value.trim());
+                    }
+                    None => *slot = Some(value.trim().to_owned()),
+                }
+            }
+        }
+        headers
+    }
+
+    /// The headers `get` returns for [`CacheHeaders::NAMES`] (it is asked
+    /// in lower case).
+    pub fn from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
+        let mut headers = Self::default();
+        for name in Self::NAMES {
+            if let Some(value) = get(name)
+                && let Some(slot) = headers.slot(name)
+            {
+                *slot = Some(value.trim().to_owned());
+            }
+        }
+        headers
+    }
+
+    fn slot(&mut self, name: &str) -> Option<&mut Option<String>> {
+        let name = name.trim();
+        Some(match () {
+            _ if name.eq_ignore_ascii_case("cache-control") => &mut self.cache_control,
+            _ if name.eq_ignore_ascii_case("expires") => &mut self.expires,
+            _ if name.eq_ignore_ascii_case("date") => &mut self.date,
+            _ if name.eq_ignore_ascii_case("age") => &mut self.age,
+            _ if name.eq_ignore_ascii_case("etag") => &mut self.etag,
+            _ if name.eq_ignore_ascii_case("last-modified") => &mut self.last_modified,
+            _ if name.eq_ignore_ascii_case("vary") => &mut self.vary,
+            _ if name.eq_ignore_ascii_case("pragma") => &mut self.pragma,
+            _ => return None,
+        })
+    }
+
+    /// `self` updated with the headers `newer` has (a `304`'s over the
+    /// stored response's).
+    fn updated(mut self, newer: &CacheHeaders) -> Self {
+        let pick = |old: &mut Option<String>, new: &Option<String>| {
+            if new.is_some() {
+                old.clone_from(new);
+            }
+        };
+        pick(&mut self.cache_control, &newer.cache_control);
+        pick(&mut self.expires, &newer.expires);
+        pick(&mut self.date, &newer.date);
+        pick(&mut self.age, &newer.age);
+        pick(&mut self.etag, &newer.etag);
+        pick(&mut self.last_modified, &newer.last_modified);
+        pick(&mut self.vary, &newer.vary);
+        pick(&mut self.pragma, &newer.pragma);
+        self
+    }
+}
+
+/// A response to an image request, as a loader read it (after following
+/// redirects).
+#[derive(Debug, Clone, Default)]
+#[non_exhaustive]
+pub struct Fetched {
+    /// The HTTP status.
+    pub status: u16,
+    /// Its caching headers.
+    pub headers: CacheHeaders,
+    /// The body (empty for a `304`).
+    pub body: Vec<u8>,
+}
+
+impl Fetched {
+    /// A response with `status`, `headers` and `body`.
+    pub fn new(status: u16, headers: CacheHeaders, body: Vec<u8>) -> Self {
+        Self {
+            status,
+            headers,
+            body,
+        }
+    }
+}
+
+/// What the cache holds for a URL ([`DiskCache::lookup`]).
+#[derive(Debug, Clone)]
+pub enum Lookup {
+    /// A fresh copy: serve it, no request.
+    Fresh(ImageData),
+    /// A stale copy: request with these validators, then
+    /// [`DiskCache::complete`].
+    Stale(Validators),
+    /// Nothing: request, then [`DiskCache::complete`].
+    Miss,
+}
+
+impl Lookup {
+    /// The conditional request headers to send ([`Validators::headers`]),
+    /// none for a miss (or a fresh copy).
+    pub fn request_headers(&self) -> Vec<(&'static str, String)> {
+        match self {
+            Lookup::Stale(validators) => validators.headers(),
+            _ => Vec::new(),
+        }
+    }
+}
+
+/// A stale entry's validators.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+#[non_exhaustive]
+pub struct Validators {
+    /// Its `ETag`.
+    pub etag: Option<String>,
+    /// Its `Last-Modified`.
+    pub last_modified: Option<String>,
+}
+
+impl Validators {
+    /// The conditional request headers: `If-None-Match` and
+    /// `If-Modified-Since`.
+    pub fn headers(&self) -> Vec<(&'static str, String)> {
+        let mut headers = Vec::new();
+        if let Some(etag) = &self.etag {
+            headers.push(("If-None-Match", etag.clone()));
+        }
+        if let Some(date) = &self.last_modified {
+            headers.push(("If-Modified-Since", date.clone()));
+        }
+        headers
+    }
+}
+
+/// A persistent, size-bounded cache of downloaded image bytes in one
+/// directory (the module docs have its policy). Cloning a handle shares it.
+#[derive(Clone)]
+pub struct DiskCache(Arc<Inner>);
+
+struct Inner {
+    dir: PathBuf,
+    max_size: ByteSize,
+    max_bytes: u64,
+    /// The directory's size as this handle knows it: exact after a scan,
+    /// plus what it wrote since. `None` until its first write scans.
+    size: Mutex<Option<u64>>,
+}
+
+impl std::fmt::Debug for DiskCache {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("DiskCache")
+            .field("dir", &self.0.dir)
+            .field("max_bytes", &self.0.max_bytes)
+            .finish()
+    }
+}
+
+impl DiskCache {
+    /// A cache in `dir` (created when it first stores), bounded to
+    /// `max_size`; `None` when that is no bytes (no disk cache). Touches
+    /// no file.
+    pub fn open(dir: impl Into<PathBuf>, max_size: ByteSize) -> Option<Self> {
+        let max_bytes = max_size.bytes();
+        (max_bytes > 0).then(|| {
+            Self(Arc::new(Inner {
+                dir: dir.into(),
+                max_size,
+                max_bytes,
+                size: Mutex::new(None),
+            }))
+        })
+    }
+
+    /// Its directory.
+    pub fn dir(&self) -> &Path {
+        &self.0.dir
+    }
+
+    /// The size it is bounded to.
+    pub fn max_size(&self) -> ByteSize {
+        self.0.max_size
+    }
+
+    /// What it holds for `url`. Blocking.
+    pub fn lookup(&self, url: &str) -> Lookup {
+        self.lookup_at(url, now())
+    }
+
+    /// Ends the request for `url` that a [`Lookup`] started: stores, updates
+    /// or drops the entry as the policy says, and returns the bytes to show
+    /// (the response's, or the cached ones for a `304`, or a stale copy when
+    /// the request failed and it may be served), or the error. Blocking.
+    pub fn complete(
+        &self,
+        url: &str,
+        response: Result<Fetched, String>,
+    ) -> Result<ImageData, String> {
+        self.complete_at(url, response, now())
+    }
+
+    /// Gets `url`'s bytes through the cache: a fresh entry, or `fetch`
+    /// (blocking; given the conditional headers to send, none on a miss)
+    /// then [`DiskCache::complete`].
+    pub fn load(
+        &self,
+        url: &str,
+        fetch: impl FnOnce(&[(&'static str, String)]) -> Result<Fetched, String>,
+    ) -> Result<ImageData, String> {
+        let lookup = self.lookup(url);
+        if let Lookup::Fresh(bytes) = lookup {
+            return Ok(bytes);
+        }
+        self.complete(url, fetch(&lookup.request_headers()))
+    }
+
+    /// Drops `url`'s entry.
+    pub fn remove(&self, url: &str) {
+        let _ = fs::remove_file(self.path(url));
+    }
+
+    /// Drops every entry. Entries another handle is writing at that moment
+    /// may survive.
+    pub fn clear(&self) {
+        if let Ok(entries) = fs::read_dir(&self.0.dir) {
+            for entry in entries.flatten() {
+                let _ = fs::remove_file(entry.path());
+            }
+        }
+        *self.0.size.lock().unwrap_or_else(|e| e.into_inner()) = Some(0);
+    }
+
+    fn lookup_at(&self, url: &str, now: u64) -> Lookup {
+        let path = self.path(url);
+        let Some(meta) = self.read_meta(&path, url) else {
+            return Lookup::Miss;
+        };
+        if now < meta.fresh_until {
+            return match self.read(&path, url) {
+                Some((_, body)) => {
+                    touch(&path);
+                    Lookup::Fresh(body.into())
+                }
+                None => Lookup::Miss,
+            };
+        }
+        let validators = Validators {
+            etag: meta.headers.etag.clone(),
+            last_modified: meta.headers.last_modified.clone(),
+        };
+        if validators.etag.is_none() && validators.last_modified.is_none() {
+            // Nothing to revalidate with: requested in full (and served if
+            // that fails).
+            return Lookup::Stale(Validators::default());
+        }
+        Lookup::Stale(validators)
+    }
+
+    fn complete_at(
+        &self,
+        url: &str,
+        response: Result<Fetched, String>,
+        now: u64,
+    ) -> Result<ImageData, String> {
+        let response = match response {
+            Ok(response) => response,
+            Err(error) => return self.stale(url).ok_or(error),
+        };
+        match response.status {
+            200 | 203 => {
+                let Fetched { headers, body, .. } = response;
+                self.store(url, &headers, &body, now);
+                Ok(body.into())
+            }
+            304 => {
+                let path = self.path(url);
+                let (meta, body) = self
+                    .read(&path, url)
+                    .ok_or_else(|| "HTTP 304 without a cached copy".to_owned())?;
+                let headers = meta.headers.updated(&response.headers);
+                let policy = Policy::of(&headers, now);
+                if policy.store {
+                    self.write(url, &headers, &policy, &body);
+                } else {
+                    self.remove(url);
+                }
+                touch(&path);
+                Ok(body.into())
+            }
+            // Shown, never stored (`206`, `204`, ...).
+            200..=299 => Ok(response.body.into()),
+            status @ 500..=599 => self.stale(url).ok_or_else(|| format!("HTTP {status}")),
+            status @ 300..=399 => Err(format!("redirect refused (HTTP {status})")),
+            status => {
+                self.remove(url);
+                Err(format!("HTTP {status}"))
+            }
+        }
+    }
+
+    /// The stored copy of `url` when it may be served stale (the request
+    /// failed).
+    fn stale(&self, url: &str) -> Option<ImageData> {
+        let (meta, body) = self.read(&self.path(url), url)?;
+        if meta.strict {
+            return None;
+        }
+        log::debug!("image cache: serving {url} stale");
+        Some(body.into())
+    }
+
+    fn store(&self, url: &str, headers: &CacheHeaders, body: &[u8], now: u64) {
+        let policy = Policy::of(headers, now);
+        if !policy.store || body.len() as u64 > self.0.max_bytes / 8 {
+            self.remove(url);
+            return;
+        }
+        self.write(url, headers, &policy, body);
+    }
+
+    fn write(&self, url: &str, headers: &CacheHeaders, policy: &Policy, body: &[u8]) {
+        let header = encode_header(url, headers, policy);
+        let mut crc = crc32fast::Hasher::new();
+        crc.update(&header);
+        crc.update(body);
+        let mut file = Vec::with_capacity(MAGIC.len() + 16 + header.len() + body.len());
+        file.extend_from_slice(MAGIC);
+        file.extend_from_slice(&(header.len() as u32).to_le_bytes());
+        file.extend_from_slice(&header);
+        file.extend_from_slice(&(body.len() as u64).to_le_bytes());
+        file.extend_from_slice(&crc.finalize().to_le_bytes());
+        file.extend_from_slice(body);
+        let written = file.len() as u64;
+        if let Err(e) = self.write_file(&self.path(url), &file) {
+            log::debug!("image cache: cannot store {url}: {e}");
+            return;
+        }
+        let over = {
+            let mut size = self.0.size.lock().unwrap_or_else(|e| e.into_inner());
+            match size.as_mut() {
+                Some(size) => {
+                    *size += written;
+                    *size > self.0.max_bytes
+                }
+                // A first write: learn the directory's size.
+                None => true,
+            }
+        };
+        if over {
+            self.trim();
+        }
+    }
+
+    /// Writes `bytes` to `path` whole: under a temporary name, renamed into
+    /// place.
+    fn write_file(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
+        static NEXT: AtomicU64 = AtomicU64::new(0);
+        fs::create_dir_all(&self.0.dir)?;
+        let nanos = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .map_or(0, |d| d.subsec_nanos());
+        let temporary = self.0.dir.join(format!(
+            ".tmp-{}-{}-{nanos}",
+            std::process::id(),
+            NEXT.fetch_add(1, Ordering::Relaxed)
+        ));
+        let result = fs::File::create(&temporary)
+            .and_then(|mut f| f.write_all(bytes))
+            .and_then(|()| fs::rename(&temporary, path));
+        if result.is_err() {
+            let _ = fs::remove_file(&temporary);
+        }
+        result
+    }
+
+    /// Scans the directory: deletes temporary files writers left, and the
+    /// least recently used entries while it is over its limit (down to
+    /// three quarters of it).
+    fn trim(&self) {
+        let Ok(entries) = fs::read_dir(&self.0.dir) else {
+            return;
+        };
+        let now = SystemTime::now();
+        let mut files = Vec::new();
+        let mut total = 0;
+        for entry in entries.flatten() {
+            let Ok(meta) = entry.metadata() else {
+                continue;
+            };
+            if !meta.is_file() {
+                continue;
+            }
+            let modified = meta.modified().unwrap_or(UNIX_EPOCH);
+            if entry.file_name().to_string_lossy().starts_with('.') {
+                if now.duration_since(modified).unwrap_or_default() > STALE_TEMPORARY {
+                    let _ = fs::remove_file(entry.path());
+                }
+                continue;
+            }
+            total += meta.len();
+            files.push((modified, meta.len(), entry.path()));
+        }
+        if total > self.0.max_bytes {
+            files.sort_unstable_by_key(|(modified, ..)| *modified);
+            let target = self.0.max_bytes / 4 * 3;
+            for (_, len, path) in files {
+                if total <= target {
+                    break;
+                }
+                // Gone already (another handle trimmed it) counts as gone.
+                let _ = fs::remove_file(&path);
+                total -= len;
+            }
+        }
+        *self.0.size.lock().unwrap_or_else(|e| e.into_inner()) = Some(total);
+    }
+
+    fn path(&self, url: &str) -> PathBuf {
+        use sha2::{Digest, Sha256};
+        let digest = Sha256::digest(url.as_bytes());
+        let mut name = String::with_capacity(32);
+        for byte in &digest[..16] {
+            name.push_str(&format!("{byte:02x}"));
+        }
+        self.0.dir.join(name)
+    }
+
+    /// The header of the entry at `path` if it is `url`'s (its body
+    /// unread and unchecked: enough to decide what to request).
+    fn read_meta(&self, path: &Path, url: &str) -> Option<Meta> {
+        let mut file = fs::File::open(path).ok()?;
+        let header = read_header(&mut file);
+        drop(file);
+        match header.as_ref().and_then(|h| decode_header(h)) {
+            Some(meta) if meta.url == url => Some(meta),
+            Some(_) => None,
+            None => {
+                corrupt(path);
+                None
+            }
+        }
+    }
+
+    /// The entry at `path` if it is `url`'s and reads back whole.
+    fn read(&self, path: &Path, url: &str) -> Option<(Meta, Vec<u8>)> {
+        let mut file = fs::File::open(path).ok()?;
+        let entry = (|| {
+            let header = read_header(&mut file)?;
+            let mut fixed = [0u8; 12];
+            file.read_exact(&mut fixed).ok()?;
+            let len = u64::from_le_bytes(fixed[..8].try_into().ok()?);
+            let crc = u32::from_le_bytes(fixed[8..].try_into().ok()?);
+            let expected = file.metadata().ok()?.len();
+            let offset = (MAGIC.len() + 4 + header.len() + 12) as u64;
+            if offset.checked_add(len)? != expected {
+                return None;
+            }
+            let mut body = Vec::with_capacity(len as usize);
+            file.read_to_end(&mut body).ok()?;
+            let mut hasher = crc32fast::Hasher::new();
+            hasher.update(&header);
+            hasher.update(&body);
+            if hasher.finalize() != crc || body.len() as u64 != len {
+                return None;
+            }
+            Some((decode_header(&header)?, body))
+        })();
+        drop(file);
+        match entry {
+            Some((meta, body)) if meta.url == url => Some((meta, body)),
+            Some(_) => None,
+            None => {
+                corrupt(path);
+                None
+            }
+        }
+    }
+}
+
+/// What a response's headers say about storing it.
+#[derive(Debug, Clone, PartialEq)]
+struct Policy {
+    /// It may be stored.
+    store: bool,
+    /// Until when it is fresh (seconds since the epoch).
+    fresh_until: u64,
+    /// It may not be served stale (`must-revalidate`, `no-cache`).
+    strict: bool,
+}
+
+impl Policy {
+    fn of(headers: &CacheHeaders, now: u64) -> Self {
+        let mut no_store = false;
+        let mut no_cache = false;
+        let mut must_revalidate = false;
+        let mut max_age = None;
+        if let Some(value) = &headers.cache_control {
+            for directive in value.split(',') {
+                let (name, argument) = match directive.split_once('=') {
+                    Some((name, argument)) => {
+                        (name.trim(), Some(argument.trim().trim_matches('"')))
+                    }
+                    None => (directive.trim(), None),
+                };
+                match name.to_ascii_lowercase().as_str() {
+                    "no-store" => no_store = true,
+                    // `no-cache="field"` only bars those fields.
+                    "no-cache" if argument.is_none() => no_cache = true,
+                    "must-revalidate" => must_revalidate = true,
+                    "max-age" => max_age = argument.and_then(|a| a.parse::<u64>().ok()),
+                    _ => {}
+                }
+            }
+        } else if headers
+            .pragma
+            .as_deref()
+            .is_some_and(|p| p.to_ascii_lowercase().contains("no-cache"))
+        {
+            no_cache = true;
+        }
+        let vary_all = headers
+            .vary
+            .as_deref()
+            .is_some_and(|v| v.split(',').any(|f| f.trim() == "*"));
+        let date = headers.date.as_deref().and_then(http_date);
+        // The response's age: what caches before it said, or the time since
+        // the server dated it.
+        let apparent = date.map_or(0, |date| now.saturating_sub(date));
+        let age = headers
+            .age
+            .as_deref()
+            .and_then(|a| a.trim().parse::<u64>().ok())
+            .unwrap_or(0)
+            .max(apparent);
+        let lifetime = if no_cache {
+            0
+        } else if let Some(max_age) = max_age {
+            max_age
+        } else if let Some(expires) = &headers.expires {
+            // An invalid date (`0`, say) is in the past.
+            http_date(expires).map_or(0, |expires| expires.saturating_sub(date.unwrap_or(now)))
+        } else if let Some(modified) = headers.last_modified.as_deref().and_then(http_date) {
+            (date.unwrap_or(now).saturating_sub(modified) / 10).min(MAX_HEURISTIC.as_secs())
+        } else {
+            0
+        };
+        Policy {
+            store: !no_store && !vary_all,
+            fresh_until: now.saturating_add(lifetime.saturating_sub(age)),
+            strict: must_revalidate || no_cache,
+        }
+    }
+}
+
+/// An entry's header, decoded.
+#[derive(Debug)]
+struct Meta {
+    url: String,
+    fresh_until: u64,
+    strict: bool,
+    headers: CacheHeaders,
+}
+
+fn encode_header(url: &str, headers: &CacheHeaders, policy: &Policy) -> Vec<u8> {
+    let mut text = String::new();
+    let mut line = |name: &str, value: &str| {
+        // Values never hold line breaks (HTTP forbids them); a URL is one
+        // line too.
+        if !value.contains(['\n', '\t']) {
+            text.push_str(name);
+            text.push('\t');
+            text.push_str(value);
+            text.push('\n');
+        }
+    };
+    line("url", url);
+    line("fresh", &policy.fresh_until.to_string());
+    line("strict", if policy.strict { "1" } else { "0" });
+    for (name, value) in [
+        ("cache-control", &headers.cache_control),
+        ("expires", &headers.expires),
+        ("etag", &headers.etag),
+        ("last-modified", &headers.last_modified),
+        ("vary", &headers.vary),
+        ("pragma", &headers.pragma),
+    ] {
+        if let Some(value) = value {
+            line(name, value);
+        }
+    }
+    text.into_bytes()
+}
+
+fn decode_header(bytes: &[u8]) -> Option<Meta> {
+    let text = std::str::from_utf8(bytes).ok()?;
+    let mut url = None;
+    let mut fresh_until = None;
+    let mut strict = None;
+    let mut pairs = Vec::new();
+    for line in text.lines() {
+        let (name, value) = line.split_once('\t')?;
+        match name {
+            "url" => url = Some(value.to_owned()),
+            "fresh" => fresh_until = Some(value.parse().ok()?),
+            "strict" => strict = Some(value == "1"),
+            _ => pairs.push((name, value)),
+        }
+    }
+    Some(Meta {
+        url: url?,
+        fresh_until: fresh_until?,
+        strict: strict?,
+        headers: CacheHeaders::from_pairs(pairs),
+    })
+}
+
+/// The header of an entry file, after checking its format.
+fn read_header(file: &mut fs::File) -> Option<Vec<u8>> {
+    let mut start = [0u8; 12];
+    file.read_exact(&mut start).ok()?;
+    if &start[..8] != MAGIC {
+        return None;
+    }
+    let len = u32::from_le_bytes(start[8..].try_into().ok()?) as usize;
+    if len > MAX_HEADER {
+        return None;
+    }
+    let mut header = vec![0; len];
+    file.read_exact(&mut header).ok()?;
+    Some(header)
+}
+
+/// Drops an entry that did not read back.
+fn corrupt(path: &Path) {
+    log::debug!("image cache: dropping corrupt entry {}", path.display());
+    let _ = fs::remove_file(path);
+}
+
+/// Refreshes `path`'s place in the LRU order (not more often than
+/// [`TOUCH_AFTER`]).
+fn touch(path: &Path) {
+    let now = SystemTime::now();
+    let old = fs::metadata(path)
+        .and_then(|m| m.modified())
+        .is_ok_and(|m| now.duration_since(m).unwrap_or_default() >= TOUCH_AFTER);
+    if old && let Ok(file) = fs::OpenOptions::new().write(true).open(path) {
+        let _ = file.set_modified(now);
+    }
+}
+
+fn now() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_or(0, |d| d.as_secs())
+}
+
+/// An HTTP date in seconds since the epoch.
+fn http_date(value: &str) -> Option<u64> {
+    httpdate::parse_http_date(value.trim())
+        .ok()?
+        .duration_since(UNIX_EPOCH)
+        .ok()
+        .map(|d| d.as_secs())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/third-party/nsh/crates/nsh-core/src/image/cache/tests.rs b/third-party/nsh/crates/nsh-core/src/image/cache/tests.rs
new file mode 100644
index 0000000000..5ef7880325
--- /dev/null
+++ b/third-party/nsh/crates/nsh-core/src/image/cache/tests.rs
@@ -0,0 +1,518 @@
+use std::{
+    fs,
+    path::PathBuf,
+    sync::atomic::{AtomicU64, Ordering},
+    time::{Duration, SystemTime},
+};
+
+use super::*;
+use crate::ByteSize;
+
+/// A fresh directory under the system's temporary one, removed on drop.
+struct TempDir(PathBuf);
+
+impl TempDir {
+    fn new() -> Self {
+        static NEXT: AtomicU64 = AtomicU64::new(0);
+        let dir = std::env::temp_dir().join(format!(
+            "nsh-image-cache-{}-{}",
+            std::process::id(),
+            NEXT.fetch_add(1, Ordering::Relaxed)
+        ));
+        let _ = fs::remove_dir_all(&dir);
+        Self(dir)
+    }
+}
+
+impl Drop for TempDir {
+    fn drop(&mut self) {
+        let _ = fs::remove_dir_all(&self.0);
+    }
+}
+
+const T0: u64 = 1_800_000_000;
+const URL: &str = "https://img.example.com/avatar/42.png";
+
+fn date(secs: u64) -> String {
+    httpdate::fmt_http_date(UNIX_EPOCH + Duration::from_secs(secs))
+}
+
+fn headers(pairs: &[(&str, &str)]) -> CacheHeaders {
+    CacheHeaders::from_pairs(pairs.iter().copied())
+}
+
+fn ok(pairs: &[(&str, &str)], body: &[u8]) -> Result<Fetched, String> {
+    Ok(Fetched::new(200, headers(pairs), body.to_vec()))
+}
+
+fn status(code: u16, pairs: &[(&str, &str)]) -> Result<Fetched, String> {
+    Ok(Fetched::new(code, headers(pairs), Vec::new()))
+}
+
+fn fresh(lookup: Lookup) -> Option<Vec<u8>> {
+    match lookup {
+        Lookup::Fresh(bytes) => Some(bytes.to_vec()),
+        _ => None,
+    }
+}
+
+#[test]
+fn stores_and_serves_fresh_entries() {
+    let dir = TempDir::new();
+    let cache = DiskCache::open(&dir.0, ByteSize::from_bytes(1 << 20)).unwrap();
+    assert!(matches!(cache.lookup_at(URL, T0), Lookup::Miss));
+    let shown = cache
+        .complete_at(
+            URL,
+            ok(&[("Cache-Control", "public, max-age=600")], b"png"),
+            T0,
+        )
+        .unwrap();
+    assert_eq!(&*shown, b"png");
+    assert_eq!(
+        fresh(cache.lookup_at(URL, T0 + 599)).as_deref(),
+        Some(&b"png"[..])
+    );
+    // Another handle on the directory (another surface) sees it.
+    let other = DiskCache::open(&dir.0, ByteSize::from_bytes(1 << 20)).unwrap();
+    assert_eq!(
+        fresh(other.lookup_at(URL, T0 + 1)).as_deref(),
+        Some(&b"png"[..])
+    );
+    assert!(matches!(cache.lookup_at(URL, T0 + 600), Lookup::Stale(_)));
+    assert!(matches!(
+        cache.lookup_at("https://img.example.com/other.png", T0),
+        Lookup::Miss
+    ));
+}
+
+#[test]
+fn no_disk_cache_without_bytes() {
+    assert!(DiskCache::open("/nowhere", ByteSize::from_bytes(0)).is_none());
+}
+
+#[test]
+fn freshness_follows_the_headers() {
+    let policy = |pairs: &[(&str, &str)]| Policy::of(&headers(pairs), T0);
+    assert_eq!(
+        policy(&[("Cache-Control", "max-age=60")]).fresh_until,
+        T0 + 60
+    );
+    // Its age counts against it: `Age`, or the time since `Date`.
+    assert_eq!(
+        policy(&[("Cache-Control", "max-age=60"), ("Age", "20")]).fresh_until,
+        T0 + 40
+    );
+    assert_eq!(
+        policy(&[("Cache-Control", "max-age=60"), ("Date", &date(T0 - 30))]).fresh_until,
+        T0 + 30
+    );
+    // `max-age` wins over `Expires`.
+    assert_eq!(
+        policy(&[
+            ("Cache-Control", "max-age=60"),
+            ("Expires", &date(T0 + 9999))
+        ])
+        .fresh_until,
+        T0 + 60
+    );
+    assert_eq!(
+        policy(&[("Expires", &date(T0 + 300)), ("Date", &date(T0))]).fresh_until,
+        T0 + 300
+    );
+    // An invalid `Expires` is in the past.
+    assert_eq!(policy(&[("Expires", "0")]).fresh_until, T0);
+    // Heuristic: a tenth of the time since it changed, at most a week.
+    assert_eq!(
+        policy(&[("Last-Modified", &date(T0 - 1000)), ("Date", &date(T0))]).fresh_until,
+        T0 + 100
+    );
+    assert_eq!(
+        policy(&[("Last-Modified", &date(T0 - 1000 * 24 * 3600))]).fresh_until,
+        T0 + MAX_HEURISTIC.as_secs()
+    );
+    // Nothing said: stale at once (still stored, for revalidation and
+    // offline use).
+    let silent = policy(&[]);
+    assert_eq!(
+        (silent.store, silent.fresh_until, silent.strict),
+        (true, T0, false)
+    );
+    let no_cache = policy(&[("Cache-Control", "no-cache, max-age=600")]);
+    assert_eq!(
+        (no_cache.store, no_cache.fresh_until, no_cache.strict),
+        (true, T0, true)
+    );
+    assert_eq!(policy(&[("Pragma", "no-cache")]).fresh_until, T0);
+    // `no-cache="Set-Cookie"` only bars a field.
+    assert_eq!(
+        policy(&[("Cache-Control", "no-cache=\"Set-Cookie\", max-age=5")]).fresh_until,
+        T0 + 5
+    );
+    assert!(policy(&[("Cache-Control", "must-revalidate, max-age=5")]).strict);
+    assert!(!policy(&[("Cache-Control", "private, No-Store")]).store);
+    assert!(!policy(&[("Vary", "Accept, *")]).store);
+    assert!(policy(&[("Vary", "Accept-Encoding")]).store);
+}
+
+#[test]
+fn honors_no_store() {
+    let dir = TempDir::new();
+    let cache = DiskCache::open(&dir.0, ByteSize::from_bytes(1 << 20)).unwrap();
+    cache
+        .complete_at(URL, ok(&[("Cache-Control", "max-age=60")], b"old"), T0)
+        .unwrap();
+    // A later `no-store` response is shown and drops what was kept.
+    let shown = cache
+        .complete_at(URL, ok(&[("Cache-Control", "no-store")], b"new"), T0 + 61)
+        .unwrap();
+    assert_eq!(&*shown, b"new");
+    assert!(matches!(cache.lookup_at(URL, T0 + 61), Lookup::Miss));
+    // Neither are other statuses kept.
+    let partial = Fetched::new(
+        206,
+        headers(&[("Cache-Control", "max-age=60")]),
+        b"pa".to_vec(),
+    );
+    assert_eq!(&*cache.complete_at(URL, Ok(partial), T0).unwrap(), b"pa");
+    assert!(matches!(cache.lookup_at(URL, T0), Lookup::Miss));
+}
+
+#[test]
+fn revalidates_stale_entries() {
+    let dir = TempDir::new();
+    let cache = DiskCache::open(&dir.0, ByteSize::from_bytes(1 << 20)).unwrap();
+    let response = ok(
+        &[
+            ("Cache-Control", "max-age=60"),
+            ("ETag", "\"v1\""),
+            ("Last-Modified", &date(T0 - 3600)),
+        ],
+        b"png",
+    );
+    cache.complete_at(URL, response, T0).unwrap();
+    let lookup = cache.lookup_at(URL, T0 + 61);
+    let Lookup::Stale(validators) = &lookup else {
+        panic!("stale");
+    };
+    assert_eq!(validators.etag.as_deref(), Some("\"v1\""));
+    assert_eq!(
+        lookup.request_headers(),
+        vec![
+            ("If-None-Match", "\"v1\"".to_owned()),
+            ("If-Modified-Since", date(T0 - 3600))
+        ]
+    );
+    // 304: the copy serves again, fresh by the stored `max-age` from now
+    // (the 304 says nothing new).
+    let shown = cache.complete_at(URL, status(304, &[]), T0 + 61).unwrap();
+    assert_eq!(&*shown, b"png");
+    assert_eq!(
+        fresh(cache.lookup_at(URL, T0 + 120)).as_deref(),
+        Some(&b"png"[..])
+    );
+    // A 304's headers win over the stored ones.
+    cache
+        .complete_at(
+            URL,
+            status(
+                304,
+                &[("Cache-Control", "max-age=3600"), ("ETag", "\"v2\"")],
+            ),
+            T0 + 200,
+        )
+        .unwrap();
+    assert!(fresh(cache.lookup_at(URL, T0 + 3000)).is_some());
+    let Lookup::Stale(validators) = cache.lookup_at(URL, T0 + 4000) else {
+        panic!("stale");
+    };
+    assert_eq!(validators.etag.as_deref(), Some("\"v2\""));
+    // 200: replaced.
+    cache
+        .complete_at(
+            URL,
+            ok(&[("Cache-Control", "max-age=60")], b"new"),
+            T0 + 4000,
+        )
+        .unwrap();
+    assert_eq!(
+        fresh(cache.lookup_at(URL, T0 + 4001)).as_deref(),
+        Some(&b"new"[..])
+    );
+    // A 304 for an entry that went away is an error, not empty bytes.
+    cache.remove(URL);
+    assert!(cache.complete_at(URL, status(304, &[]), T0).is_err());
+}
+
+#[test]
+fn serves_stale_copies_when_offline() {
+    let dir = TempDir::new();
+    let cache = DiskCache::open(&dir.0, ByteSize::from_bytes(1 << 20)).unwrap();
+    cache.complete_at(URL, ok(&[], b"png"), T0).unwrap();
+    // Nothing said about freshness: stale, without validators.
+    let Lookup::Stale(validators) = cache.lookup_at(URL, T0) else {
+        panic!("stale");
+    };
+    assert_eq!(validators, Validators::default());
+    let offline = cache.complete_at(URL, Err("offline".into()), T0 + 1);
+    assert_eq!(&*offline.unwrap(), b"png");
+    let unavailable = cache.complete_at(URL, status(503, &[]), T0 + 1);
+    assert_eq!(&*unavailable.unwrap(), b"png");
+    // Gone (404): dropped.
+    assert_eq!(
+        cache
+            .complete_at(URL, status(404, &[]), T0 + 2)
+            .unwrap_err(),
+        "HTTP 404"
+    );
+    assert!(matches!(cache.lookup_at(URL, T0 + 2), Lookup::Miss));
+    assert_eq!(
+        cache
+            .complete_at(URL, Err("offline".into()), T0 + 3)
+            .unwrap_err(),
+        "offline"
+    );
+    // `must-revalidate` is never served stale.
+    cache
+        .complete_at(
+            URL,
+            ok(&[("Cache-Control", "max-age=1, must-revalidate")], b"png"),
+            T0,
+        )
+        .unwrap();
+    assert_eq!(
+        cache
+            .complete_at(URL, Err("offline".into()), T0 + 5)
+            .unwrap_err(),
+        "offline"
+    );
+    assert_eq!(
+        cache
+            .complete_at(URL, status(502, &[]), T0 + 5)
+            .unwrap_err(),
+        "HTTP 502"
+    );
+    assert!(
+        cache
+            .complete_at(URL, status(302, &[]), T0)
+            .unwrap_err()
+            .contains("302")
+    );
+}
+
+#[test]
+fn drops_corrupt_entries() {
+    let dir = TempDir::new();
+    let cache = DiskCache::open(&dir.0, ByteSize::from_bytes(1 << 20)).unwrap();
+    let store = || {
+        cache
+            .complete_at(
+                URL,
+                ok(&[("Cache-Control", "max-age=600")], b"0123456789"),
+                T0,
+            )
+            .unwrap();
+    };
+    let path = cache.path(URL);
+    type Damage = dyn Fn(&mut Vec<u8>);
+    let damage: [&Damage; 5] = [
+        // Truncated (a writer that died, a full disk).
+        &|bytes| bytes.truncate(bytes.len() - 3),
+        // A flipped body byte.
+        &|bytes| {
+            let last = bytes.len() - 1;
+            bytes[last] ^= 1;
+        },
+        // A flipped digit of its freshness (parses, fails the checksum).
+        &|bytes| {
+            let at = bytes.windows(7).position(|w| w == b"\nstrict").unwrap() - 1;
+            bytes[at] ^= 1;
+        },
+        // Another format.
+        &|bytes| bytes[..8].copy_from_slice(b"GARBAGE!"),
+        // An absurd header length.
+        &|bytes| bytes[8..12].copy_from_slice(&u32::MAX.to_le_bytes()),
+    ];
+    for damage in damage {
+        store();
+        let mut bytes = fs::read(&path).unwrap();
+        damage(&mut bytes);
+        fs::write(&path, &bytes).unwrap();
+        assert!(fresh(cache.lookup_at(URL, T0)).is_none());
+        assert!(!path.exists(), "a corrupt entry is deleted");
+    }
+    // An empty file, and a file of another URL under this name (a digest
+    // collision, or a renamed file), are misses.
+    fs::write(&path, b"").unwrap();
+    assert!(matches!(cache.lookup_at(URL, T0), Lookup::Miss));
+    store();
+    fs::rename(&path, cache.path("https://other.example/a.png")).unwrap();
+    assert!(matches!(
+        cache.lookup_at("https://other.example/a.png", T0),
+        Lookup::Miss
+    ));
+    // A stale entry's damaged body fails its 304, not silently.
+    cache
+        .complete_at(URL, ok(&[("ETag", "\"e\"")], b"0123456789"), T0)
+        .unwrap();
+    let mut bytes = fs::read(&path).unwrap();
+    bytes.pop();
+    fs::write(&path, &bytes).unwrap();
+    assert!(matches!(cache.lookup_at(URL, T0), Lookup::Stale(_)));
+    assert!(cache.complete_at(URL, status(304, &[]), T0).is_err());
+}
+
+#[test]
+fn evicts_the_least_recently_used() {
+    let dir = TempDir::new();
+    let body = vec![7u8; 1000];
+    // Room for twelve entries (of about 1130 bytes).
+    let cache = DiskCache::open(&dir.0, ByteSize::from_bytes(12 * 1200)).unwrap();
+    let url = |i: usize| format!("https://img.example.com/{i}.png");
+    let age = |i: usize, secs: u64| {
+        let file = fs::OpenOptions::new()
+            .write(true)
+            .open(cache.path(&url(i)))
+            .unwrap();
+        file.set_modified(SystemTime::now() - Duration::from_secs(secs))
+            .unwrap();
+    };
+    for i in 0..10 {
+        cache
+            .complete_at(&url(i), ok(&[("Cache-Control", "max-age=600")], &body), T0)
+            .unwrap();
+        // Older first: entry 0 was used longest ago...
+        age(i, 10_000 - i as u64 * 100);
+    }
+    // ...but a hit makes it the most recent.
+    assert!(fresh(cache.lookup_at(&url(0), T0)).is_some());
+    for i in 10..13 {
+        cache
+            .complete_at(&url(i), ok(&[("Cache-Control", "max-age=600")], &body), T0)
+            .unwrap();
+    }
+    let kept: Vec<usize> = (0..13)
+        .filter(|&i| fresh(cache.lookup_at(&url(i), T0)).is_some())
+        .collect();
+    let total: u64 = fs::read_dir(&dir.0)
+        .unwrap()
+        .map(|e| e.unwrap().metadata().unwrap().len())
+        .sum();
+    assert!(total <= cache.max_size().bytes(), "{total} bytes kept");
+    assert!(kept.contains(&0), "the entry just used stays: {kept:?}");
+    assert!(
+        !kept.contains(&1) && !kept.contains(&2),
+        "the oldest go: {kept:?}"
+    );
+    assert!(kept.contains(&12));
+    // Bodies over an eighth of the cache are not kept.
+    let big = vec![0u8; 2000];
+    cache
+        .complete_at("https://img.example.com/big.png", ok(&[], &big), T0)
+        .unwrap();
+    assert!(matches!(
+        cache.lookup_at("https://img.example.com/big.png", T0),
+        Lookup::Miss
+    ));
+    cache.clear();
+    assert!(matches!(cache.lookup_at(&url(12), T0), Lookup::Miss));
+}
+
+#[test]
+fn trims_what_dead_writers_left() {
+    let dir = TempDir::new();
+    let cache = DiskCache::open(&dir.0, ByteSize::from_bytes(1 << 20)).unwrap();
+    fs::create_dir_all(&dir.0).unwrap();
+    let left = dir.0.join(".tmp-1-2-3");
+    fs::write(&left, b"half").unwrap();
+    let file = fs::OpenOptions::new().write(true).open(&left).unwrap();
+    file.set_modified(SystemTime::now() - Duration::from_secs(2 * 3600))
+        .unwrap();
+    let fresh_tmp = dir.0.join(".tmp-1-2-4");
+    fs::write(&fresh_tmp, b"busy").unwrap();
+    // The first write scans.
+    cache.complete_at(URL, ok(&[], b"png"), T0).unwrap();
+    assert!(!left.exists());
+    assert!(fresh_tmp.exists(), "a writer may still be writing it");
+}
+
+#[test]
+fn surfaces_share_a_directory_safely() {
+    let dir = TempDir::new();
+    let body = |i: usize, round: usize| vec![(i * 31 + round) as u8; 600 + i * 10];
+    std::thread::scope(|scope| {
+        for worker in 0..8 {
+            let path = dir.0.clone();
+            scope.spawn(move || {
+                // A handle per thread, as surfaces open their own.
+                let cache = DiskCache::open(path, ByteSize::from_bytes(40 * 1024)).unwrap();
+                for round in 0..40 {
+                    let i = (worker + round) % 24;
+                    let url = format!("https://img.example.com/{i}.png");
+                    match cache.lookup_at(&url, T0) {
+                        // Whatever is read back is whole: one writer's
+                        // body for this URL.
+                        Lookup::Fresh(bytes) => {
+                            assert_eq!(bytes.len(), 600 + i * 10);
+                            assert!(bytes.iter().all(|&b| b == bytes[0]));
+                        }
+                        Lookup::Stale(_) | Lookup::Miss => {
+                            let response = ok(&[("Cache-Control", "max-age=600")], &body(i, round));
+                            let shown = cache.complete_at(&url, response, T0).unwrap();
+                            assert_eq!(shown.len(), 600 + i * 10);
+                        }
+                    }
+                }
+            });
+        }
+    });
+    let cache = DiskCache::open(&dir.0, ByteSize::from_bytes(40 * 1024)).unwrap();
+    cache.trim();
+    let names: Vec<String> = fs::read_dir(&dir.0)
+        .unwrap()
+        .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
+        .collect();
+    assert!(
+        names.iter().all(|n| !n.starts_with('.')),
+        "no temporary file left: {names:?}"
+    );
+    let total: u64 = fs::read_dir(&dir.0)
+        .unwrap()
+        .map(|e| e.unwrap().metadata().unwrap().len())
+        .sum();
+    assert!(total <= 40 * 1024);
+}
+
+#[test]
+fn loads_through_a_fetch() {
+    let dir = TempDir::new();
+    let cache = DiskCache::open(&dir.0, ByteSize::from_bytes(1 << 20)).unwrap();
+    let mut requests = 0;
+    let mut get = |sent: &[(&'static str, String)]| {
+        requests += 1;
+        if sent.iter().any(|(name, _)| *name == "If-None-Match") {
+            return status(304, &[("Cache-Control", "max-age=600")]);
+        }
+        ok(&[("Cache-Control", "max-age=0"), ("ETag", "\"a\"")], b"png")
+    };
+    assert_eq!(&*cache.load(URL, &mut get).unwrap(), b"png");
+    // Stale at once: revalidated, then fresh for ten minutes.
+    assert_eq!(&*cache.load(URL, &mut get).unwrap(), b"png");
+    assert_eq!(&*cache.load(URL, &mut get).unwrap(), b"png");
+    assert_eq!(requests, 2);
+}
+
+#[test]
+fn reads_headers_in_any_case() {
+    let h = CacheHeaders::from_pairs([
+        ("CACHE-CONTROL", " max-age=5 "),
+        ("x-other", "1"),
+        ("Cache-Control", "public"),
+        ("etag", "\"x\""),
+    ]);
+    assert_eq!(h.cache_control.as_deref(), Some("max-age=5, public"));
+    assert_eq!(h.etag.as_deref(), Some("\"x\""));
+    let h = CacheHeaders::from_lookup(|name| (name == "last-modified").then(|| date(T0)));
+    assert_eq!(h.last_modified, Some(date(T0)));
+    assert_eq!(h.cache_control, None);
+}
diff --git a/third-party/nsh/crates/nsh-core/src/js/animation.rs b/third-party/nsh/crates/nsh-core/src/js/animation.rs
index 15b7ac636e..0d717350bf 100644
--- a/third-party/nsh/crates/nsh-core/src/js/animation.rs
+++ b/third-party/nsh/crates/nsh-core/src/js/animation.rs
@@ -2,14 +2,20 @@
 //! passes them (`Easing.quad`, `LayoutAnimation.Presets.spring`): their
 //! names, parameters and defaults in one table each, from which they are
 //! decoded, `nsh/animated` is made (`nsh-js` `animated.rs`) and
-//! `animation.gen.ts` (the declarations) is generated.
+//! `animation.gen.ts` (the declarations) is generated. Keyframes and
+//! motions (`Animated.keyframes`, `createMotion`, `motions`) are decoded
+//! here too, and their presets come from `animation::PRESETS`.
 
 use std::fmt::Write as _;
 
 use super::host::{HostCx, HostInput, HostList, HostValue, field};
 use crate::{
     LayoutAnimation, LayoutCurve, LayoutProperty, LayoutTransition,
-    animation::{Curve, EaseMode, Easing},
+    animation::{
+        Composite, Curve, EaseMode, Easing, Keyframe, KeyframeTiming, MotionChannel, MotionPreset,
+        MotionSpec, NodeId, PRESETS as MOTIONS, PlaybackDirection, StaggerFrom, StepPosition,
+        keyframes,
+    },
     ui::TsTypes,
 };
 
@@ -89,13 +95,22 @@ const NAMED: &[(&str, Easing)] = &[
     ("easeInOut", Easing::EaseInOut),
 ];
 
-/// A name, a cubic Bézier `[x1, y1, x2, y2]` or `{ curve, param, mode }`;
-/// `None` for anything else (the animation's default).
+/// A name, a cubic Bézier `[x1, y1, x2, y2]`, `{ curve, param, mode }` or
+/// `{ steps, position }`; `None` for anything else (the animation's
+/// default).
 pub fn easing<I: HostInput>(input: &I) -> Option<Easing> {
     if let Some(list) = input.as_list() {
         let p = |i| list.item(i).and_then(|v| v.as_number()).unwrap_or(0.0) as f32;
         return Some(Easing::Bezier(p(0), p(1), p(2), p(3)));
     }
+    if let Some(steps) = input.field("steps").and_then(|n| n.as_number()) {
+        let position = input
+            .field("position")
+            .and_then(|p| p.as_string())
+            .and_then(|p| StepPosition::parse(&p))
+            .unwrap_or_default();
+        return Some(Easing::Steps(steps.clamp(1.0, 10_000.0) as u32, position));
+    }
     if input.is_object() {
         let name = input.field("curve").and_then(|c| c.as_string())?;
         let spec = CURVES.iter().find(|c| c.name == name)?;
@@ -115,6 +130,154 @@ pub fn easing<I: HostInput>(input: &I) -> Option<Easing> {
     NAMED.iter().find(|(n, _)| *n == name).map(|&(_, e)| e)
 }
 
+/// A keyframe's value: a number, or an angle (`"45deg"`, `"0.5turn"`,
+/// `"1rad"`) in degrees.
+fn keyframe_value<I: HostInput>(input: &I) -> Option<f32> {
+    match input.as_number() {
+        Some(n) => Some(n as f32).filter(|n| n.is_finite()),
+        None => crate::css::angle(&input.as_string()?),
+    }
+}
+
+/// Keyframes of one value: numbers (spread evenly) or `{ offset?, value,
+/// easing? }`.
+pub fn keyframe_list<I: HostInput>(input: &I) -> Vec<Keyframe> {
+    input
+        .items()
+        .iter()
+        .filter_map(|item| {
+            if let Some(value) = keyframe_value(item) {
+                return Some(Keyframe::new(value));
+            }
+            let mut frame = Keyframe::new(keyframe_value(&item.field("value")?)?);
+            frame.offset = item
+                .field("offset")
+                .and_then(|o| o.as_number())
+                .map(|o| o as f32);
+            frame.easing = item.field("easing").and_then(|e| easing(&e));
+            Some(frame)
+        })
+        .collect()
+}
+
+/// How keyframes play: `{ duration, delay, easing, iterations, direction
+/// }` (CSS's defaults: 300 ms, `ease`, once, `normal`).
+pub fn keyframe_timing<I: HostInput>(input: &I) -> KeyframeTiming {
+    let number = |key| {
+        input
+            .field(key)
+            .and_then(|v| v.as_number())
+            .filter(|n| n.is_finite())
+            .map(|n| n as f32)
+    };
+    let defaults = KeyframeTiming::default();
+    let iterations = match input.field("iterations") {
+        Some(v) if v.as_string().as_deref() == Some("infinite") => None,
+        Some(v) => match v.as_number() {
+            Some(n) if n.is_infinite() && n > 0.0 => None,
+            Some(n) if n.is_finite() => Some(n.max(0.0).round().min(u32::MAX as f64) as u32),
+            _ => defaults.iterations,
+        },
+        None => defaults.iterations,
+    };
+    KeyframeTiming {
+        duration_ms: number("duration").map_or(defaults.duration_ms, |d| d.max(0.0)),
+        delay_ms: number("delay").map_or(0.0, |d| d.max(0.0)),
+        easing: input
+            .field("easing")
+            .and_then(|e| easing(&e))
+            .unwrap_or(defaults.easing),
+        iterations,
+        direction: input
+            .field("direction")
+            .and_then(|d| d.as_string())
+            .and_then(|d| PlaybackDirection::parse(&d))
+            .unwrap_or_default(),
+    }
+}
+
+/// `Animated.keyframes(value, config)`'s tree on `node`: `config` is `{
+/// keyframes, duration, delay, easing, iterations, direction }`.
+pub fn keyframes_composite<I: HostInput>(input: &I, node: NodeId) -> Composite {
+    let frames = input
+        .field("keyframes")
+        .map(|k| keyframe_list(&k))
+        .unwrap_or_default();
+    keyframes(node, &frames, &keyframe_timing(input))
+}
+
+/// A motion: a preset's name, or `{ keyframes, ...timing }` where
+/// `keyframes` is an object of channels' keyframes (`{ opacity: [0, 1] }`)
+/// or a list of frames of channels (`[{ opacity: 0 }, { opacity: 1,
+/// offset: 1, easing }]`, as the Web Animations API's). Unknown channels
+/// are left out.
+pub fn motion_spec<I: HostInput>(input: &I) -> Option<MotionSpec> {
+    if let Some(name) = input.as_string() {
+        return MotionPreset::parse(&name).map(MotionPreset::spec);
+    }
+    if !input.is_object() {
+        return None;
+    }
+    let mut spec = MotionSpec::new(keyframe_timing(input));
+    let frames = input.field("keyframes")?;
+    if frames.as_list().is_some() {
+        // Frames of channels: each channel's keyframes are the frames that
+        // name it, with the frame's offset and easing.
+        let items = frames.items();
+        let count = items.len();
+        for channel in MotionChannel::ALL {
+            let mut track = Vec::new();
+            for (i, item) in items.iter().enumerate() {
+                let Some(value) = item.field(channel.name()).and_then(|v| keyframe_value(&v))
+                else {
+                    continue;
+                };
+                let offset = item
+                    .field("offset")
+                    .and_then(|o| o.as_number())
+                    .map(|o| o as f32);
+                // A frame without an offset sits where its index puts it,
+                // so channels missing from some frames stay in step.
+                let offset = offset.or_else(|| match count {
+                    1 => Some(1.0),
+                    n => Some(i as f32 / (n - 1) as f32),
+                });
+                track.push(Keyframe {
+                    offset,
+                    easing: item.field("easing").and_then(|e| easing(&e)),
+                    ..Keyframe::new(value)
+                });
+            }
+            if !track.is_empty() {
+                spec = spec.track(channel, track);
+            }
+        }
+    } else {
+        for (name, value) in frames.entries() {
+            if let Some(channel) = MotionChannel::parse(&name) {
+                let track = keyframe_list(&value);
+                if !track.is_empty() {
+                    spec = spec.track(channel, track);
+                }
+            }
+        }
+    }
+    Some(spec)
+}
+
+/// Where a staggered group counts from: `"first"`, `"last"`, `"center"` or
+/// an item's index.
+pub fn stagger_from<I: HostInput>(input: &I) -> StaggerFrom {
+    if let Some(index) = input.as_number() {
+        return StaggerFrom::Index(index.max(0.0) as usize);
+    }
+    match input.as_string().as_deref() {
+        Some("last") => StaggerFrom::Last,
+        Some("center") => StaggerFrom::Center,
+        _ => StaggerFrom::First,
+    }
+}
+
 /// Makes a layout curve (from `springDamping`).
 type MakeLayoutCurve = fn(f32) -> LayoutCurve;
 
@@ -249,9 +412,13 @@ pub fn declarations() -> String {
          export type EasingCurveName = {};\n\n\
          /** A curve of the `Easing` module. */\n\
          export interface EasingCurve {{\n  curve: EasingCurveName;\n  param?: number;\n  mode: \"in\" | \"out\" | \"inOut\";\n}}\n\n\
+         /** CSS `steps()`: jumps between `steps` levels instead of moving. */\n\
+         export interface EasingSteps {{\n  steps: number;\n  position?: StepPosition;\n}}\n\n\
+         /** Where a `steps()` curve jumps (CSS's `<step-position>`; `\"jump-end\"` by default). */\n\
+         export type StepPosition = \"jump-start\" | \"jump-end\" | \"jump-none\" | \"jump-both\" | \"start\" | \"end\";\n\n\
          /** How a timing animation (or an interpolation segment) progresses: a name, a cubic\n \
-         * Bézier `[x1, y1, x2, y2]` or a curve from `Easing`. */\n\
-         export type Easing = {} | [number, number, number, number] | EasingCurve;\n",
+         * Bézier `[x1, y1, x2, y2]`, a curve from `Easing` or `Easing.steps(…)`. */\n\
+         export type Easing = {} | [number, number, number, number] | EasingCurve | EasingSteps;\n",
         names(&mut CURVES.iter().map(|c| c.name)),
         names(&mut NAMED.iter().map(|(n, _)| *n)),
     );
@@ -282,6 +449,7 @@ pub fn declarations() -> String {
             .map(|(name, _)| format!("  {name}: {layout};\n"))
             .collect::<String>()
     );
+    out.push_str(&motion_declarations());
     // `LayoutAnimation.Types` / `Properties`: each name as itself.
     let object = |iface: &str, doc: &str, names: &mut dyn Iterator<Item = &str>| {
         let fields: String = names.map(|n| format!("  readonly {n}: {n:?};\n")).collect();
@@ -300,11 +468,108 @@ pub fn declarations() -> String {
     out
 }
 
+/// The declarations of channels, keyframes and the presets' names.
+fn motion_declarations() -> String {
+    let mut out = String::new();
+    let channels = MotionChannel::ALL
+        .iter()
+        .map(|c| format!("{:?}", c.name()))
+        .collect::<Vec<_>>()
+        .join(" | ");
+    let _ = write!(
+        out,
+        "\n/** What a motion moves: the opacity and the parts of the transform (translations in points,\n \
+         * rotations in degrees), applied translations first, then rotations, then scales. */\n\
+         export type MotionChannel = {channels};\n"
+    );
+    out.push_str("\n/** The ready-made motions:\n");
+    for preset in MOTIONS {
+        let _ = writeln!(
+            out,
+            " * - `{}` ({}):{}",
+            preset.name,
+            preset.kind.name(),
+            preset.doc
+        );
+    }
+    out.push_str(" */\nexport type MotionName =\n");
+    for preset in MOTIONS {
+        let _ = writeln!(out, "  | {:?}", preset.name);
+    }
+    out.push_str("  ;\n");
+    out
+}
+
+/// A preset as JS data (as `motions.<name>`): `{ kind, keyframes: {
+/// channel: [{ offset, value }] }, duration, easing, iterations, direction
+/// }`, which `motion_spec` reads back.
+fn motion_json(preset: &crate::animation::PresetDef) -> crate::JsonValue {
+    use serde_json::{Map, Value, json};
+    let spec = preset.spec();
+    let mut keyframes = Map::new();
+    for (channel, frames) in &spec.tracks {
+        keyframes.insert(
+            channel.name().into(),
+            Value::Array(
+                frames
+                    .iter()
+                    .map(|f| json!({ "offset": f.offset, "value": f.value }))
+                    .collect(),
+            ),
+        );
+    }
+    let t = spec.timing;
+    json!({
+        "kind": preset.kind.name(),
+        "keyframes": keyframes,
+        "duration": t.duration_ms,
+        "easing": easing_json(t.easing),
+        "iterations": match t.iterations {
+            Some(n) => json!(n),
+            None => json!("infinite"),
+        },
+        "direction": t.direction.name(),
+    })
+}
+
+/// An easing as JS passes it (what `easing` decodes).
+fn easing_json(e: Easing) -> crate::JsonValue {
+    use serde_json::json;
+    match e {
+        Easing::Linear => json!("linear"),
+        Easing::EaseIn => json!("easeIn"),
+        Easing::EaseOut => json!("easeOut"),
+        Easing::EaseInOut => json!("easeInOut"),
+        Easing::Bezier(a, b, c, d) => json!([a, b, c, d]),
+        Easing::Curve(curve, mode) => {
+            let (name, param) = match curve {
+                Curve::Quad => ("quad", None),
+                Curve::Cubic => ("cubic", None),
+                Curve::Poly(n) => ("poly", Some(n)),
+                Curve::Sin => ("sin", None),
+                Curve::Circle => ("circle", None),
+                Curve::Exp => ("exp", None),
+                Curve::Elastic(b) => ("elastic", Some(b)),
+                Curve::Back(s) => ("back", Some(s)),
+                Curve::Bounce => ("bounce", None),
+            };
+            let mode = match mode {
+                EaseMode::In => "in",
+                EaseMode::Out => "out",
+                EaseMode::InOut => "inOut",
+            };
+            json!({ "curve": name, "param": param, "mode": mode })
+        }
+        Easing::Steps(steps, position) => json!({ "steps": steps, "position": position.name() }),
+    }
+}
+
 /// The easing and layout animation tables as data for `nsh/animated`
 /// (`nsh-js` `animated.rs`): `curves` (`[name, parameter,
 /// default]`, the parameter `null` for curves without one), `presets`
 /// (name -> config, as `LayoutAnimation.Presets`), `types` and
-/// `properties` (the names of `LayoutAnimation.Types` / `Properties`).
+/// `properties` (the names of `LayoutAnimation.Types` / `Properties`),
+/// `motions` (name -> motion, as `motions`).
 pub fn tables() -> crate::JsonValue {
     use serde_json::{Map, Value, json};
     let transition = |t: &LayoutTransition| {
@@ -361,5 +626,9 @@ pub fn tables() -> crate::JsonValue {
         "presets": presets,
         "types": LAYOUT_TYPES.iter().map(|t| t.0).collect::<Vec<_>>(),
         "properties": LAYOUT_PROPERTIES.iter().filter(|p| p.2).map(|p| p.0).collect::<Vec<_>>(),
+        "motions": MOTIONS
+            .iter()
+            .map(|p| (p.name.to_string(), motion_json(p)))
+            .collect::<Map<String, Value>>(),
     })
 }
diff --git a/third-party/nsh/crates/nsh-core/src/lib.rs b/third-party/nsh/crates/nsh-core/src/lib.rs
index b78517e2fd..5a5c16b6ca 100644
--- a/third-party/nsh/crates/nsh-core/src/lib.rs
+++ b/third-party/nsh/crates/nsh-core/src/lib.rs
@@ -8,6 +8,8 @@
 //! Everything here is single-threaded and allocation-conscious: the core runs
 //! on the engine thread, between the JS host and the platform backend.
 
+mod byte_size;
+pub use byte_size::ByteSize;
 pub mod access;
 pub mod animation;
 pub mod canvas;
@@ -79,12 +81,12 @@ pub use crate::{
     },
     ops::{
         AutoCapitalize, AutoFill, CellSlot, Command, ContextMenuEvent, Dialog, DialogButton,
-        DialogButtonStyle, EventKind, EventMask, EventPayload, HeaderSlot, HoverEvent,
-        IMAGE_SCALES, ImageProp, ImageScales, ImageSource, KeyboardAppearance, KeyboardDismissMode,
-        KeyboardTaps, KeyboardType, ListContentStyle, ListViewport, ModalAnimation,
-        ModalPresentation, ModalProp, NativeProp, NodeId, Op, OverlayProp, PayloadValue,
-        PressEvent, Prop, ROOT_ID, ResizeMode, ReturnKeyType, ScreenAnimation, ScreenProp,
-        ScrollProp, SnapAlignment, StatusBarConfig, StatusBarSetting, StatusBarStyle,
+        DialogButtonStyle, EventKind, EventMask, EventPayload, HeaderSlot, HeaderSystemItem,
+        HoverEvent, IMAGE_SCALES, ImageProp, ImageScales, ImageSource, KeyboardAppearance,
+        KeyboardDismissMode, KeyboardTaps, KeyboardType, ListContentStyle, ListViewport,
+        ModalAnimation, ModalPresentation, ModalProp, NativeProp, NodeId, Op, OverlayProp,
+        PayloadValue, PressEvent, Prop, ROOT_ID, ResizeMode, ReturnKeyType, ScreenAnimation,
+        ScreenProp, ScrollProp, SnapAlignment, StatusBarConfig, StatusBarSetting, StatusBarStyle,
         SubmitBehavior, Tag, TouchEvent, TouchPhase, TouchPoint, Viewability, WebSource,
     },
     props::{
diff --git a/third-party/nsh/crates/nsh-core/src/mount.rs b/third-party/nsh/crates/nsh-core/src/mount.rs
index 333bcc474c..d842fe4a82 100644
--- a/third-party/nsh/crates/nsh-core/src/mount.rs
+++ b/third-party/nsh/crates/nsh-core/src/mount.rs
@@ -376,6 +376,9 @@ pub struct ScreenConfig {
     pub left: Option<NodeId>,
     /// Trailing header item view.
     pub right: Option<NodeId>,
+    /// UIKit's own bar button in the trailing slot instead of `right`'s
+    /// view (iOS); a press on it presses `right`'s first pressable view.
+    pub right_system_item: Option<crate::HeaderSystemItem>,
     /// Custom title view.
     pub title_view: Option<NodeId>,
     /// The app's header (`header`): on a platform without a native header
@@ -406,6 +409,7 @@ impl Default for ScreenConfig {
             animation: ScreenAnimation::Default,
             left: None,
             right: None,
+            right_system_item: None,
             title_view: None,
             header_view: None,
             header_height: Points::ZERO,
diff --git a/third-party/nsh/crates/nsh-core/src/ops.rs b/third-party/nsh/crates/nsh-core/src/ops.rs
index 06de73172b..513a96791b 100644
--- a/third-party/nsh/crates/nsh-core/src/ops.rs
+++ b/third-party/nsh/crates/nsh-core/src/ops.rs
@@ -913,6 +913,33 @@ pub enum ReturnKeyType {
     Send,
 }
 
+/// A bar button UIKit draws itself, with the system's look, symbol or
+/// label (`ios={{ headerRightSystemItem }}`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PropEnum)]
+pub enum HeaderSystemItem {
+    /// A plus (`UIBarButtonSystemItemAdd`).
+    #[default]
+    Add,
+    /// The system's "Done" (`UIBarButtonSystemItemDone`).
+    Done,
+    /// A close mark (`UIBarButtonSystemItemClose`).
+    Close,
+    /// The system's "Edit" (`UIBarButtonSystemItemEdit`).
+    Edit,
+    /// A compose symbol (`UIBarButtonSystemItemCompose`).
+    Compose,
+    /// A magnifying glass (`UIBarButtonSystemItemSearch`).
+    Search,
+    /// The share symbol (`UIBarButtonSystemItemAction`).
+    Share,
+    /// A trash can (`UIBarButtonSystemItemTrash`).
+    Trash,
+    /// The system's "Save" (`UIBarButtonSystemItemSave`).
+    Save,
+    /// The system's "Cancel" (`UIBarButtonSystemItemCancel`).
+    Cancel,
+}
+
 /// What return does in an input (`submitBehavior`).
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PropEnum)]
 pub enum SubmitBehavior {
@@ -1159,6 +1186,12 @@ pub enum ScreenProp {
     /// label of the back button leading back to this page.
     #[prop(js = "headerBackTitle", on = SCREEN)]
     HeaderBackTitle(Option<Arc<str>>),
+    /// `ios={{ headerRightSystemItem }}`: UIKit's own bar button in the
+    /// system header's trailing slot, in place of the view of
+    /// `headerRight`; a press on it is a press on the first pressable view
+    /// of `headerRight`. Without a `headerRight`, nothing is drawn there.
+    #[prop(ios = "headerRightSystemItem", on = SCREEN)]
+    HeaderRightSystemItem(Option<HeaderSystemItem>),
     /// `headerBackVisible`: the system header's back button shows (default
     /// true; iOS and Android).
     #[prop(js = "headerBackVisible", default = true, on = SCREEN)]
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 d01351febc..1997f3fd8a 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/ambient.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/ambient.rs
@@ -718,6 +718,13 @@ pub fn animated_value(value: f32) -> super::AnimatedValue {
     super::AnimatedValue::new(&executor(), value)
 }
 
+/// Animated values for a view's opacity and transform, and motions to
+/// play on them (TS: `createMotion`): bind it with
+/// [`super::Styled::bind_motion`]. A `Copy` handle the current owner keeps.
+pub fn motion() -> super::Motion {
+    super::Motion::new(&executor())
+}
+
 /// A handle for an element (TS: `ref`): put it on the element with
 /// [`super::Element::element_ref`], call its commands from handlers and
 /// tasks. A `Copy` handle the current owner keeps, as a signal is.
diff --git a/third-party/nsh/crates/nsh-core/src/ui/animated.rs b/third-party/nsh/crates/nsh-core/src/ui/animated.rs
index 5a65805bdb..12c738484a 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/animated.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/animated.rs
@@ -35,7 +35,8 @@ use super::{
     task::{Action, Answer, Executor},
 };
 use crate::animation::{
-    AnimationId, AnimationOp, Composite, Driver, Easing, Extrapolate, MathOp, Operand, Output,
+    AnimationId, AnimationOp, Composite, Driver, Easing, Extrapolate, Keyframe, KeyframeTiming,
+    MathOp, MotionChannel, MotionSpec, Operand, Output, StaggerFrom,
 };
 
 /// A node of the graph, released when the last of what holds it goes
@@ -720,6 +721,17 @@ impl AnimatedValue {
         end
     }
 
+    /// Plays `frames` as CSS keyframes play (TS: `keyframes(frames,
+    /// options)`): each segment with its easing, `timing.iterations` times
+    /// in `timing.direction`.
+    pub fn keyframes(
+        &self,
+        frames: impl IntoIterator<Item = Keyframe>,
+        timing: KeyframeTiming,
+    ) -> AnimationEnd {
+        Animation::keyframes(self, frames, timing).start()
+    }
+
     /// Springs to `to`.
     pub fn spring(&self, to: f32, spring: Spring) -> AnimationEnd {
         self.start(spring.driver(to))
@@ -966,6 +978,22 @@ impl Animation {
         )
     }
 
+    /// `value` through `frames`, as CSS keyframes play (TS:
+    /// `Animated.keyframes`): offsets spread as the Web Animations API
+    /// spreads them, an easing per segment, iterations, a direction and a
+    /// delay ([`KeyframeTiming`]).
+    pub fn keyframes(
+        value: &AnimatedValue,
+        frames: impl IntoIterator<Item = Keyframe>,
+        timing: KeyframeTiming,
+    ) -> Self {
+        let frames: Vec<Keyframe> = frames.into_iter().collect();
+        Self::new(
+            crate::animation::keyframes(value.0.id(), &frames, &timing),
+            value.0.live().into_iter().collect(),
+        )
+    }
+
     /// A wait of `ms` milliseconds (in a sequence).
     pub fn delay(ms: f32) -> Self {
         Self::new(Composite::Delay(ms), Vec::new())
@@ -1022,6 +1050,25 @@ impl Animation {
         Self::parallel(staged)
     }
 
+    /// `animations` all at once, each starting `ms` milliseconds after the
+    /// one before, counted from `from` (TS: `Animated.stagger(ms, list, {
+    /// from })`): from the center, a list ripples out from its middle.
+    pub fn stagger_from(
+        ms: f32,
+        from: StaggerFrom,
+        animations: impl IntoIterator<Item = Animation>,
+    ) -> Self {
+        let animations: Vec<Animation> = animations.into_iter().collect();
+        let count = animations.len();
+        let staged = animations.into_iter().enumerate().map(|(i, a)| {
+            Self::sequence([
+                Self::delay(ms * crate::animation::stagger_delay(i, count, from)),
+                a,
+            ])
+        });
+        Self::parallel(staged)
+    }
+
     /// This animation over and over: `iterations` times, or until stopped
     /// with `None`; its values go back to where they were before each run
     /// (see [`Self::reset_before_iteration`]).
@@ -1124,6 +1171,104 @@ impl fmt::Debug for Animation {
     }
 }
 
+/// Animated values for a view's opacity and the parts of its transform
+/// (TS: `createMotion`), and motions to play on them: presets
+/// ([`crate::animation::MotionPreset`]: entrances, exits, attention seekers,
+/// loops) or keyframes of the app's own ([`MotionSpec`]). Bind it with
+/// [`super::Styled::bind_motion`]. A `Copy` handle, kept by the owner
+/// current when it is made, as an [`AnimatedValue`] is.
+///
+/// ```ignore
+/// let card = motion().entering(MotionPreset::FadeInUp);
+/// view().bind_motion(&card).on_press(move || {
+///     card.play(MotionPreset::Pulse);
+/// })
+/// ```
+#[derive(Clone, Copy)]
+pub struct Motion {
+    values: [AnimatedValue; 9],
+}
+
+impl fmt::Debug for Motion {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("Motion").finish_non_exhaustive()
+    }
+}
+
+impl Motion {
+    pub(crate) fn new(executor: &Rc<Executor>) -> Self {
+        Self {
+            values: MotionChannel::ALL.map(|c| AnimatedValue::new(executor, c.identity())),
+        }
+    }
+
+    /// The value of `channel` (opacity 0..1, translations in points,
+    /// rotations in degrees, scales).
+    pub fn value(&self, channel: MotionChannel) -> AnimatedValue {
+        self.values[channel.index()]
+    }
+
+    /// Starting where `motion` starts and playing it at once: a view that
+    /// comes in shows its first frame where the motion begins.
+    pub fn entering(self, motion: impl Into<MotionSpec>) -> Self {
+        let motion = motion.into();
+        for (channel, start) in motion.start_values() {
+            self.value(channel).set(start);
+        }
+        let _ = self.play(motion);
+        self
+    }
+
+    /// Plays `motion` on its channels (a new one interrupts the one
+    /// playing). When the user asks for less motion
+    /// ([`super::ambient::reduce_motion`]), it lands where it ends without moving.
+    pub fn play(&self, motion: impl Into<MotionSpec>) -> AnimationEnd {
+        self.run(motion.into(), super::ambient::reduce_motion())
+    }
+
+    /// Plays `motion` even when the user asks for less motion.
+    pub fn play_ignoring_reduce_motion(&self, motion: impl Into<MotionSpec>) -> AnimationEnd {
+        self.run(motion.into(), false)
+    }
+
+    /// `motion` as an [`Animation`] to compose, not started (TS:
+    /// `motion.animation(..)`): `Animation::stagger_from(60.0,
+    /// StaggerFrom::Center, cards.iter().map(|m| m.animation(..)))`. Like
+    /// [`Self::play`], it lands at its end when the user asks for less
+    /// motion (as things are when it is made).
+    pub fn animation(&self, motion: impl Into<MotionSpec>) -> Animation {
+        self.composed(motion.into(), super::ambient::reduce_motion())
+    }
+
+    fn composed(&self, motion: MotionSpec, reduced: bool) -> Animation {
+        let nodes = self.values.map(|v| v.0.id());
+        let held = self
+            .values
+            .iter()
+            .filter_map(|v| v.0.live())
+            .collect::<Vec<_>>();
+        Animation::new(motion.composite(&nodes, reduced), held)
+    }
+
+    fn run(&self, motion: MotionSpec, reduced: bool) -> AnimationEnd {
+        self.composed(motion, reduced).start()
+    }
+
+    /// Stops it where it is.
+    pub fn stop(&self) {
+        for value in &self.values {
+            value.stop();
+        }
+    }
+
+    /// Stops it and puts every channel back at rest.
+    pub fn reset(&self) {
+        for channel in MotionChannel::ALL {
+            self.value(channel).set(channel.identity());
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1253,6 +1398,57 @@ mod tests {
         Timing::new(ms, Easing::Linear).driver(to)
     }
 
+    #[test]
+    fn keyframes_staggers_and_motions_describe_their_trees() {
+        use crate::animation::{MotionPreset, StaggerFrom};
+        let runtime = Runtime::default();
+        let _entered = runtime.enter();
+        let executor = Rc::new(Executor::default());
+        let values: Vec<AnimatedValue> =
+            (0..3).map(|_| AnimatedValue::new(&executor, 0.0)).collect();
+        let step = |v: &AnimatedValue| {
+            Animation::keyframes(
+                v,
+                [Keyframe::new(0.0), Keyframe::new(1.0)],
+                KeyframeTiming::new(100.0),
+            )
+        };
+        let rippled = Animation::stagger_from(40.0, StaggerFrom::Center, values.iter().map(step));
+        let Composite::Parallel { children, .. } = &rippled.0.spec else {
+            panic!("{:?}", rippled.0.spec)
+        };
+        let delays: Vec<f32> = children
+            .iter()
+            .map(|c| match c {
+                Composite::Sequence(s) => match s[0] {
+                    Composite::Delay(ms) => ms,
+                    _ => -1.0,
+                },
+                _ => -1.0,
+            })
+            .collect();
+        assert_eq!(delays, [40.0, 0.0, 40.0]);
+
+        let motion = Motion::new(&executor);
+        executor.take_actions();
+        let _ = motion.play_ignoring_reduce_motion(MotionPreset::Shake);
+        let started = executor.take_actions().into_iter().find_map(|a| match a {
+            Action::Animate {
+                op: AnimationOp::StartComposite { spec, .. },
+                ..
+            } => Some(spec),
+            _ => None,
+        });
+        let Some(Composite::Parallel { children, .. }) = started else {
+            panic!("{started:?}")
+        };
+        // `shake` moves one channel: translateX.
+        assert_eq!(children.len(), 1);
+        let mut nodes = Vec::new();
+        children[0].nodes(&mut nodes);
+        assert_eq!(nodes, [motion.value(MotionChannel::TranslateX).node().id()]);
+    }
+
     #[test]
     fn composites_describe_their_steps() {
         let runtime = Runtime::default();
diff --git a/third-party/nsh/crates/nsh-core/src/ui/element.rs b/third-party/nsh/crates/nsh-core/src/ui/element.rs
index 63195cf58f..cda5f05885 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/element.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/element.rs
@@ -790,6 +790,28 @@ pub trait Element: IntoElement + Sized {
         self
     }
 
+    /// Binds a motion's channels: its opacity, and its transform after the
+    /// styles' transforms (translations, then rotations, then the scales,
+    /// `scale` folded into `scale_x` and `scale_y`: 7 steps of a view's 8).
+    fn bind_motion(self, motion: &super::Motion) -> Self {
+        use crate::animation::MotionChannel::*;
+        let mut this = self.bind_opacity(&motion.value(Opacity));
+        for channel in [TranslateX, TranslateY, Rotate, RotateX, RotateY] {
+            if let Some(kind) = channel.transform() {
+                this = this.bind_transform(kind, &motion.value(channel));
+            }
+        }
+        let scale = motion.value(Scale);
+        this.bind_transform(
+            crate::TransformKind::ScaleX,
+            &scale.mul(&motion.value(ScaleX)),
+        )
+        .bind_transform(
+            crate::TransformKind::ScaleY,
+            &scale.mul(&motion.value(ScaleY)),
+        )
+    }
+
     /// Moves it right by an animated value (points).
     fn bind_translate_x(self, value: &impl super::Animated) -> Self {
         self.bind_transform(crate::TransformKind::TranslateX, value)
diff --git a/third-party/nsh/crates/nsh-core/src/ui/host.rs b/third-party/nsh/crates/nsh-core/src/ui/host.rs
index b29077faea..6e9bff4ef5 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/host.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/host.rs
@@ -58,6 +58,7 @@ crate::static_reactive!(Hsla: Hsla, Rgba, Color);
 crate::static_reactive!(crate::AccessibilityRole: crate::AccessibilityRole);
 crate::static_reactive!(AutoCapitalize: AutoCapitalize);
 crate::static_reactive!(AutoFill: AutoFill);
+crate::static_reactive!(crate::HeaderSystemItem: crate::HeaderSystemItem);
 crate::static_reactive!(KeyboardAppearance: KeyboardAppearance);
 crate::static_reactive!(KeyboardType: KeyboardType);
 crate::static_reactive!(ModalAnimation: ModalAnimation);
@@ -676,6 +677,18 @@ impl Screen {
         })
     }
 
+    /// The trailing header button is UIKit's own, drawn instead of the
+    /// `header_right` view (iOS; a press on it presses that view's first
+    /// pressable view).
+    pub fn header_right_system_item(
+        self,
+        item: impl IntoReactive<crate::HeaderSystemItem>,
+    ) -> Self {
+        self.bind_prop(item, |item| {
+            Prop::Screen(ScreenProp::HeaderRightSystemItem(Some(item)))
+        })
+    }
+
     /// Whether the back button shows (default true).
     pub fn header_back_visible(self, visible: impl IntoReactive<bool>) -> Self {
         self.bind_prop(visible, |v| Prop::Screen(ScreenProp::HeaderBackVisible(v)))
diff --git a/third-party/nsh/crates/nsh-core/src/ui/mod.rs b/third-party/nsh/crates/nsh-core/src/ui/mod.rs
index 5f8ad272a4..ea03b8bd6c 100644
--- a/third-party/nsh/crates/nsh-core/src/ui/mod.rs
+++ b/third-party/nsh/crates/nsh-core/src/ui/mod.rs
@@ -109,7 +109,7 @@ pub(crate) use self::{
 pub use self::{
     animated::{
         Animated, AnimatedNode, AnimatedValue, Animation, AnimationEnd, AnimationOperand,
-        Interpolation, Spring, Timing, sample_decay, sample_decay_within, sample_spring,
+        Interpolation, Motion, Spring, Timing, sample_decay, sample_decay_within, sample_spring,
     },
     asset::{AssetFile, AssetKind, FileAsset, FontAsset, ImageAsset, ImageSrc},
     call::{
@@ -187,13 +187,17 @@ pub use crate::geometry::{
 pub use crate::{
     AccessibilityRole, AccessibilityState, AutoCapitalize, AutoFill, BackdropMaterial, BlendMode,
     BoxShadow, ContextMenuEvent, Cursor, Dialog, DialogButton, DialogButtonStyle, EdgeInsets,
-    EllipsizeMode, EventKind, EventPayload, HoverEvent, KeyboardAppearance, KeyboardDismissMode,
-    KeyboardTaps, KeyboardType, KeyframeAnimation, Keyframes, LayoutAnimation, LayoutCurve,
-    LayoutProperty, LayoutTransition, ListViewport, Measurement, ModalAnimation, ModalPresentation,
-    PanActivation, PointerEvents, PressEvent, ResizeMode, ReturnKeyType, ScreenAnimation,
-    SnapAlignment, StatusBarConfig, StatusBarSetting, StatusBarStyle, SubmitBehavior, TouchEvent,
-    TransformKind, TransformOrigin, Viewability,
-    animation::{Curve, EaseMode, Easing, Extrapolate, Output as AnimationOutput, sample_easing},
+    EllipsizeMode, EventKind, EventPayload, HeaderSystemItem, HoverEvent, KeyboardAppearance,
+    KeyboardDismissMode, KeyboardTaps, KeyboardType, KeyframeAnimation, Keyframes, LayoutAnimation,
+    LayoutCurve, LayoutProperty, LayoutTransition, ListViewport, Measurement, ModalAnimation,
+    ModalPresentation, PanActivation, PointerEvents, PressEvent, ResizeMode, ReturnKeyType,
+    ScreenAnimation, SnapAlignment, StatusBarConfig, StatusBarSetting, StatusBarStyle,
+    SubmitBehavior, TouchEvent, TransformKind, TransformOrigin, Viewability,
+    animation::{
+        Curve, EaseMode, Easing, Extrapolate, Keyframe, KeyframeTiming, MotionChannel, MotionKind,
+        MotionPreset, MotionSpec, Output as AnimationOutput, PlaybackDirection, StaggerFrom,
+        StepPosition, sample_easing,
+    },
     color::{
         ColorParseError, Hsla, Rgba, black, blue, green, hsl, hsla, opaque_grey, red, rgb, rgba,
         transparent_black, transparent_white, white, yellow,
@@ -293,7 +297,9 @@ pub mod prelude {
         KeyboardDismissMode,
         KeyboardTaps,
         KeyboardType,
+        Keyframe,
         KeyframeAnimation,
+        KeyframeTiming,
         Keyframes,
         Later,
         LayoutDirection,
@@ -308,6 +314,10 @@ pub mod prelude {
         ModalAnimation,
         ModalPresentation,
         ModuleCx,
+        Motion,
+        MotionChannel,
+        MotionPreset,
+        MotionSpec,
         NativeEvent,
         NativeView,
         NoEvent,
@@ -322,6 +332,7 @@ pub mod prelude {
         Percentage,
         Placement,
         PlatformRenderer,
+        PlaybackDirection,
         Point,
         PointerEvents,
         Points,
@@ -351,8 +362,10 @@ pub mod prelude {
         SnapAlignment,
         Spring,
         Stack,
+        StaggerFrom,
         StatusBarSetting,
         StatusBarStyle,
+        StepPosition,
         Styled,
         SubmitBehavior,
         System,
@@ -419,8 +432,8 @@ pub mod prelude {
             clipboard_files, clipboard_has_image, clipboard_has_string, clipboard_image,
             clipboard_string, color, component_ref, dark, dismiss_keyboard, element_ref, fetch,
             font_scale, global, idiom, image_size, is_desktop, is_terminal, key_label,
-            keyboard_duration, keyboard_height, locale, measure_text, next_frame, on_back_press,
-            on_key, on_key_with, open_url, os, os_version, pick_images, pixel_ratio,
+            keyboard_duration, keyboard_height, locale, measure_text, motion, next_frame,
+            on_back_press, on_key, on_key_with, open_url, os, os_version, pick_images, pixel_ratio,
             prefetch_image, reduce_motion, renderer, request_animation_frame,
             round_to_nearest_pixel, safe_area, screen_reader, set_clipboard, set_clipboard_image,
             set_color_scheme, set_interval, set_timeout, spawn, text_metrics, timer, timer_until,
diff --git a/third-party/nsh/crates/nsh-core/unitless.txt b/third-party/nsh/crates/nsh-core/unitless.txt
index ff8d601039..5ae509bc70 100644
--- a/third-party/nsh/crates/nsh-core/unitless.txt
+++ b/third-party/nsh/crates/nsh-core/unitless.txt
@@ -19,6 +19,20 @@ animation::graph::Graph::tick(now)                               # time
 animation::graph::Graph::value ->                                # a generic animated value (any prop: opacity, a color channel, a length)
 animation::ops::AnimationOp::Adjust.factor                       # an affine multiplier of a generic animated value
 animation::ops::AnimationOp::Adjust.offset                       # a generic animated value (any prop: opacity, a color channel, a length)
+animation::motion::Keyframe.offset                               # progress through an iteration (0..1, as easing coordinates)
+animation::motion::Keyframe.value                                # a generic animated value (any prop: opacity, a color channel, a length)
+animation::motion::Keyframe::at(offset)                          # progress through an iteration (0..1, as easing coordinates)
+animation::motion::Keyframe::at(value)                           # a generic animated value (any prop: opacity, a color channel, a length)
+animation::motion::Keyframe::new(value)                          # a generic animated value (any prop: opacity, a color channel, a length)
+animation::motion::KeyframeTiming.delay_ms                       # time
+animation::motion::KeyframeTiming.duration_ms                    # time
+animation::motion::KeyframeTiming::delay(delay_ms)               # time
+animation::motion::KeyframeTiming::new(duration_ms)              # time
+animation::motion::MotionChannel::identity ->                    # a motion channel's value (an opacity, points or degrees by channel)
+animation::motion::MotionSpec::start_values ->                   # a motion channel's value (an opacity, points or degrees by channel)
+animation::motion::MotionSpec::values(values)                    # a motion channel's value (an opacity, points or degrees by channel)
+animation::motion::keyframe_ends ->                              # a generic animated value (any prop: opacity, a color channel, a length)
+animation::motion::stagger_delay ->                              # a count of intervals
 animation::ops::AnimationOp::CreateDiffClamp.max                 # a generic animated value (any prop: opacity, a color channel, a length)
 animation::ops::AnimationOp::CreateDiffClamp.min                 # a generic animated value (any prop: opacity, a color channel, a length)
 animation::ops::AnimationOp::CreateInterpolate.input_range       # interpolation input stops (generic animated values)
@@ -237,6 +251,7 @@ ui::animated::Animation::decay_within(velocity)                  # a rate or a p
 ui::animated::Animation::delay(ms)                               # time
 ui::animated::Animation::spring(to)                              # an animated value (any quantity)
 ui::animated::Animation::stagger(ms)                             # time
+ui::animated::Animation::stagger_from(ms)                        # time
 ui::animated::Animation::timing(to)                              # an animated value (any quantity)
 ui::animated::Spring.damping                                     # a rate or a physical constant of an animation
 ui::animated::Spring.mass                                        # a rate or a physical constant of an animation
diff --git a/third-party/nsh/crates/nsh-engine/src/cache.rs b/third-party/nsh/crates/nsh-engine/src/cache.rs
new file mode 100644
index 0000000000..6a07e24d6f
--- /dev/null
+++ b/third-party/nsh/crates/nsh-engine/src/cache.rs
@@ -0,0 +1,138 @@
+//! Where an app's caches are kept and how much each keeps
+//! ([`CacheOptions`], [`crate::EngineConfig::cache`]). The engine opens
+//! them for the app it runs: each cache in `<root>/<app id>/<kind>`, so
+//! apps never share one. Today the one kind is the images an app
+//! downloads ([`nsh_core::image::DiskCache`], `images`).
+
+use std::path::PathBuf;
+
+pub use nsh_core::ByteSize;
+
+/// Where an app's caches are kept and how much each keeps. The default:
+/// the system's cache directory, each cache at its default size.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+#[non_exhaustive]
+pub struct CacheOptions {
+    /// The root of the app's caches: each in `<root>/<app id>/<kind>`.
+    pub location: CacheLocation,
+    /// The images the app downloads, kept as the server sent them
+    /// (`images`).
+    pub images: CachePolicy,
+}
+
+impl CacheOptions {
+    /// Caches in `location`, each at its default size.
+    pub fn at(location: CacheLocation) -> Self {
+        Self {
+            location,
+            ..Self::default()
+        }
+    }
+
+    /// No cache on disk.
+    pub fn off() -> Self {
+        Self::at(CacheLocation::Off)
+    }
+
+    /// These options with `images` for the images the app downloads.
+    #[must_use]
+    pub fn with_images(mut self, images: CachePolicy) -> Self {
+        self.images = images;
+        self
+    }
+}
+
+/// The root directory of an app's caches.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum CacheLocation {
+    /// The system's cache directory: Caches on Apple platforms (`nsh` in
+    /// it in an embedding iOS app), `%LOCALAPPDATA%` on Windows,
+    /// `$XDG_CACHE_HOME` (`~/.cache`) on Linux, `nsh` in the Context's
+    /// cache directory on Android. The system may clear it for space.
+    #[default]
+    System,
+    /// A directory of the embedding app's choosing.
+    Dir(PathBuf),
+    /// Nothing is cached on disk.
+    Off,
+}
+
+/// How much one cache keeps.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum CachePolicy {
+    /// Its default size (the images': 256 MiB,
+    /// [`nsh_core::image::DEFAULT_DISK_CACHE_SIZE`]).
+    #[default]
+    Default,
+    /// At most this much; past it the least recently used entries go.
+    MaxSize(ByteSize),
+    /// Not kept on disk.
+    Off,
+}
+
+impl CacheOptions {
+    /// The directory of the `kind` cache of app `app_id` (`None`: none on
+    /// disk), `System` resolved to [`crate::storage`]'s system cache
+    /// directory.
+    #[cfg(not(target_arch = "wasm32"))]
+    pub(crate) fn dir(&self, app_id: Option<&str>, kind: &str) -> Option<PathBuf> {
+        let root = match &self.location {
+            CacheLocation::System => crate::storage::cache_dir()?,
+            CacheLocation::Dir(dir) => dir.clone(),
+            CacheLocation::Off => return None,
+        };
+        Some(root.join(crate::app_dir_name(app_id)).join(kind))
+    }
+
+    /// The disk cache of the images app `app_id` downloads, if it keeps one.
+    #[cfg(not(target_arch = "wasm32"))]
+    pub(crate) fn open_images(&self, app_id: Option<&str>) -> Option<nsh_core::image::DiskCache> {
+        let size = match self.images {
+            CachePolicy::Default => nsh_core::image::DEFAULT_DISK_CACHE_SIZE,
+            CachePolicy::MaxSize(size) => size,
+            CachePolicy::Off => return None,
+        };
+        nsh_core::image::DiskCache::open(self.dir(app_id, "images")?, size)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::path::Path;
+
+    use super::*;
+
+    #[test]
+    fn caches_live_in_their_apps_directory() {
+        let options = CacheOptions::at(CacheLocation::Dir("/c".into()));
+        let images = options.open_images(Some("com.example.chat")).unwrap();
+        assert_eq!(images.dir(), Path::new("/c/com.example.chat/images"));
+        assert_eq!(images.max_size(), ByteSize::mib(256));
+        // An id that is no directory name stays in the root.
+        let odd = options.open_images(Some("a/b")).unwrap();
+        assert_eq!(odd.dir(), Path::new("/c/default/images"));
+        let small = options
+            .clone()
+            .with_images(CachePolicy::MaxSize(ByteSize::mib(8)));
+        assert_eq!(
+            small.open_images(None).unwrap().max_size(),
+            ByteSize::mib(8)
+        );
+        assert!(
+            options
+                .clone()
+                .with_images(CachePolicy::Off)
+                .open_images(None)
+                .is_none()
+        );
+        assert!(
+            options
+                .with_images(CachePolicy::MaxSize(ByteSize::ZERO))
+                .open_images(None)
+                .is_none()
+        );
+        assert!(CacheOptions::off().open_images(None).is_none());
+    }
+}
diff --git a/third-party/nsh/crates/nsh-engine/src/lib.rs b/third-party/nsh/crates/nsh-engine/src/lib.rs
index f6e25d0342..405ab36d5b 100644
--- a/third-party/nsh/crates/nsh-engine/src/lib.rs
+++ b/third-party/nsh/crates/nsh-engine/src/lib.rs
@@ -10,6 +10,7 @@
 
 mod access;
 mod app_directory;
+mod cache;
 mod error;
 mod host_channel;
 mod local_storage;
@@ -67,6 +68,7 @@ pub use storage::{FileStorage, data_dir};
 
 use crate::script::{NetworkQueue, RequestId, Script};
 pub use crate::{
+    cache::{ByteSize, CacheLocation, CacheOptions, CachePolicy},
     error::{AppError, AppErrorKind, PackageInfo},
     host_channel::{HostCall, HostChannel, HostReply, OpenUrlRequest},
 };
@@ -666,6 +668,10 @@ pub struct EngineConfig {
     /// system's data directory ([`data_dir`]) for apps of their own,
     /// `…/nsh` in an embedding app's. `None`: nothing is kept.
     pub data_dir: Option<std::path::PathBuf>,
+    /// Where the app's caches are kept and how much each keeps: the engine
+    /// opens them in `<root>/<app id>/<kind>` ([`Engine::image_cache`]).
+    /// [`EngineConfig::new`]: none.
+    pub cache: CacheOptions,
     /// The app's storage, overriding the one [`EngineConfig::data_dir`]
     /// names (the browser's `localStorage`, an embedder's own); `None`
     /// with no data directory either: in memory, for the engine's life.
@@ -779,6 +785,7 @@ impl EngineConfig {
             ui_sets: Vec::new(),
             runtime: None,
             data_dir: None,
+            cache: CacheOptions::off(),
             storage: None,
             clock: Clock::system(),
             context: EngineContext::default(),
@@ -1082,6 +1089,8 @@ pub struct Engine {
     host: HostChannel,
     /// Interrupts running JS when the engine stops.
     stop: Arc<std::sync::atomic::AtomicBool>,
+    /// The app's disk cache of downloaded images.
+    image_cache: Option<nsh_core::image::DiskCache>,
 }
 
 #[cfg(not(target_arch = "wasm32"))]
@@ -1110,6 +1119,7 @@ impl Engine {
         F: FnOnce() -> M + Send + 'static,
         S: MountSink,
     {
+        let image_cache = config.cache.open_images(config.app.app_id());
         let (tx, rx) = crossbeam_channel::unbounded();
         setup(&EngineControl { tx: tx.clone() });
         let engine_tx = tx.clone();
@@ -1125,9 +1135,17 @@ impl Engine {
             thread: Some(thread),
             host,
             stop,
+            image_cache,
         })
     }
 
+    /// The disk cache of the images the app downloads
+    /// ([`EngineConfig::cache`]; `None`: none). The platform's image loader
+    /// routes the app's `http(s)` images through it.
+    pub fn image_cache(&self) -> Option<nsh_core::image::DiskCache> {
+        self.image_cache.clone()
+    }
+
     /// A handle for sending input from other threads.
     pub fn control(&self) -> EngineControl {
         self.control.clone()
diff --git a/third-party/nsh/crates/nsh-engine/src/storage.rs b/third-party/nsh/crates/nsh-engine/src/storage.rs
index b819d5b472..75b0bfd9fc 100644
--- a/third-party/nsh/crates/nsh-engine/src/storage.rs
+++ b/third-party/nsh/crates/nsh-engine/src/storage.rs
@@ -83,6 +83,28 @@ fn data_dir_in(os: &str, env: impl Fn(&str) -> Option<PathBuf>) -> Option<PathBu
     }
 }
 
+/// The system's directory for caches: Caches on Apple platforms,
+/// `%LOCALAPPDATA%` on Windows, `$XDG_CACHE_HOME` (`~/.cache`) elsewhere.
+/// `None` on Android, whose cache directory only the platform knows, and
+/// without a home directory: what [`crate::CacheLocation::System`] is
+/// unless the platform resolves it itself.
+pub(crate) fn cache_dir() -> Option<PathBuf> {
+    cache_dir_in(std::env::consts::OS, |name| {
+        std::env::var_os(name).map(PathBuf::from)
+    })
+}
+
+/// [`cache_dir`] on `os` (`std::env::consts::OS`) with the environment `env`.
+fn cache_dir_in(os: &str, env: impl Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
+    let env = |name| env(name).filter(|v| !v.as_os_str().is_empty());
+    match os {
+        "android" => None,
+        "macos" | "ios" => env("HOME").map(|home| home.join("Library/Caches")),
+        "windows" => env("LOCALAPPDATA"),
+        _ => env("XDG_CACHE_HOME").or_else(|| env("HOME").map(|home| home.join(".cache"))),
+    }
+}
+
 #[cfg(test)]
 use crate::app_dir_name;
 
@@ -294,6 +316,41 @@ mod tests {
         assert_eq!(data_dir_in("linux", env(&[])), None);
     }
 
+    #[test]
+    fn cache_directories_follow_each_platform() {
+        let vars = |vars: &'static [(&'static str, &'static str)]| {
+            move |name: &str| {
+                vars.iter()
+                    .find(|(n, _)| *n == name)
+                    .map(|(_, v)| PathBuf::from(v))
+            }
+        };
+        let home = vars(&[
+            ("HOME", "/home/a"),
+            ("LOCALAPPDATA", r"C:\Users\a\AppData\Local"),
+        ]);
+        assert_eq!(
+            cache_dir_in("linux", home),
+            Some(PathBuf::from("/home/a/.cache"))
+        );
+        assert_eq!(
+            cache_dir_in(
+                "linux",
+                vars(&[("XDG_CACHE_HOME", "/c"), ("HOME", "/home/a")])
+            ),
+            Some(PathBuf::from("/c"))
+        );
+        assert_eq!(
+            cache_dir_in("macos", home),
+            Some(PathBuf::from("/home/a/Library/Caches"))
+        );
+        assert_eq!(
+            cache_dir_in("windows", home),
+            Some(PathBuf::from(r"C:\Users\a\AppData\Local"))
+        );
+        assert_eq!(cache_dir_in("android", home), None);
+    }
+
     #[test]
     fn apps_get_directories_of_their_own() {
         assert_eq!(app_dir_name(Some("com.example.app")), "com.example.app");
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs b/third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs
index ebbc278df1..3311864005 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs
@@ -80,6 +80,7 @@ fn start(app: &str) -> (Engine, crossbeam_channel::Receiver<MountBatch>) {
         ui_sets: vec![nsh_base::registry()],
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/compiled.rs b/third-party/nsh/crates/nsh-engine/tests/engine/compiled.rs
index 7321335f2f..65faba842b 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/compiled.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/compiled.rs
@@ -73,6 +73,7 @@ fn start() -> (Engine, Receiver<MountBatch>) {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -234,6 +235,7 @@ fn start_app_with(app: &str, header_heights: (Points, Points)) -> (Engine, Recei
         ui_sets: vec![nsh_base::registry(), crate::test_ui::registry()],
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
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 ef04c3f6f0..88d98c7e7c 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/components.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/components.rs
@@ -78,6 +78,7 @@ pub(crate) fn start_with(
         ui_sets,
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -230,6 +231,48 @@ pub(crate) fn shows(batch: &MountBatch, wanted: &str) -> bool {
     text_ids(batch, wanted).is_some()
 }
 
+const KEYED_CHILDREN: &str = r#"import { mount, signal, Text } from "nsh";
+import { Group } from "test/ui";
+
+const items = signal([{ id: "a", label: "A1" }, { id: "b", label: "B1" }]);
+// New objects, the same keys: the rows are kept.
+setTimeout(() => items.set([{ id: "a", label: "A2" }, { id: "b", label: "B2" }]), 50);
+
+mount(() => (
+  <Group>
+    <Each of={items()} key={(i: { id: string }) => i.id}>
+      {(item) => <Text>{item().label}</Text>}
+    </Each>
+  </Group>
+));
+"#;
+
+/// A component's children made by a keyed `<Each>` keep their rows when
+/// the items change: the children's expression is not made again.
+#[test]
+fn a_components_keyed_children_keep_their_rows() {
+    let (_engine, rx) = start(KEYED_CHILDREN);
+    let batches = until(&rx, |b| shows(b, "A2"));
+    let first = &batches[0];
+    let a = text_ids(first, "A1").expect("A1 shown");
+    let last = batches.last().unwrap();
+    assert_eq!(
+        text_ids(last, "A2"),
+        Some(a),
+        "the row's text is updated in place"
+    );
+    let deleted = batches[1..]
+        .iter()
+        .flat_map(|b| &b.items)
+        .any(|i| matches!(i, MountItem::Delete { id } if *id % 2 == 0));
+    assert!(!deleted, "no row of the app's is made again");
+    let removed = batches[1..]
+        .iter()
+        .flat_map(|b| &b.items)
+        .any(|i| matches!(i, MountItem::Remove { id } if *id % 2 == 0));
+    assert!(!removed, "the moved row stays in its wrapper");
+}
+
 const LAZY_SHEET: &str = r#"import { mount, signal, untracked, Text } from "nsh";
 import { Sheet } from "test/ui";
 
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/counter.rs b/third-party/nsh/crates/nsh-engine/tests/engine/counter.rs
index 47f7105580..8560df9e63 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/counter.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/counter.rs
@@ -43,6 +43,7 @@ fn config() -> EngineConfig {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/errors.rs b/third-party/nsh/crates/nsh-engine/tests/engine/errors.rs
index f0b4cda483..e6df922457 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/errors.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/errors.rs
@@ -46,6 +46,7 @@ fn config(bundle: Bundle, limits: nsh_engine::Limits) -> EngineConfig {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -128,6 +129,7 @@ fn first_error(bundle: Bundle) -> String {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs b/third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs
index 893a2ac818..c5419ccb15 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs
@@ -132,6 +132,7 @@ fn fetch_round_trips_through_the_platform_network() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -243,6 +244,7 @@ fn fetch_stays_within_the_capabilities() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -313,6 +315,7 @@ fn rust_components_fetch_through_the_platform_network() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/fs.rs b/third-party/nsh/crates/nsh-engine/tests/engine/fs.rs
index 6f53efbecb..e6895adbb6 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/fs.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/fs.rs
@@ -85,6 +85,7 @@ mount(() => <Text>{{shown()}}</Text>);
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: data,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -646,6 +647,7 @@ mount(() => <Text>{shown}</Text>);
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: Some(root.clone()),
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs b/third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs
index ca4d02b260..02e0c74bd2 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs
@@ -139,6 +139,7 @@ fn start(
         ui_sets,
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context,
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/hot.rs b/third-party/nsh/crates/nsh-engine/tests/engine/hot.rs
index 5cf2382192..801b7602c3 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/hot.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/hot.rs
@@ -172,6 +172,7 @@ fn edits_replace_modules_and_keep_unrelated_state() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -327,6 +328,7 @@ fn entry_edits_keep_navigation_and_state() {
             ui_sets: sets.to_vec(),
             runtime: None,
             data_dir: None,
+            cache: nsh_engine::CacheOptions::off(),
             storage: None,
             clock: Default::default(),
             context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/hover.rs b/third-party/nsh/crates/nsh-engine/tests/engine/hover.rs
index ea6f973d87..326fb7422c 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/hover.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/hover.rs
@@ -67,6 +67,7 @@ fn start() -> (Engine, Receiver<MountBatch>) {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs b/third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs
index 5b7dc3882c..ca9771d751 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs
@@ -79,6 +79,7 @@ fn start() -> (Engine, Receiver<MountBatch>) {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs b/third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs
index 687569a942..06b569b586 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs
@@ -59,6 +59,7 @@ fn start(app: &str) -> (Engine, Receiver<MountBatch>) {
         ui_sets,
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
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 f2ee9712b3..a001b77547 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/list.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/list.rs
@@ -123,6 +123,7 @@ fn config(app: &str) -> (EngineConfig, std::path::PathBuf) {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/native.rs b/third-party/nsh/crates/nsh-engine/tests/engine/native.rs
index 0b1fe9d5fc..132ff4423b 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/native.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/native.rs
@@ -71,6 +71,7 @@ fn start(app: &str) -> (Engine, Receiver<MountBatch>) {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/net.rs b/third-party/nsh/crates/nsh-engine/tests/engine/net.rs
index 5d89aba91d..ee0bc1bbec 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/net.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/net.rs
@@ -64,6 +64,7 @@ mount(() => <Text>{{shown()}}</Text>);
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/package.rs b/third-party/nsh/crates/nsh-engine/tests/engine/package.rs
index 46293766b2..3377d9d140 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/package.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/package.rs
@@ -53,6 +53,7 @@ fn first_texts(bundle: nsh_engine::Bundle) -> Vec<String> {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/parity.rs b/third-party/nsh/crates/nsh-engine/tests/engine/parity.rs
index 395ad83ae7..be945bb8ea 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/parity.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/parity.rs
@@ -119,6 +119,7 @@ mount(() => <Text>{{shown()}}</Text>);
             ui_sets: Vec::new(),
             runtime: None,
             data_dir: Some(root.join("data")),
+            cache: nsh_engine::CacheOptions::off(),
             storage: None,
             clock: Default::default(),
             context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/platform.rs b/third-party/nsh/crates/nsh-engine/tests/engine/platform.rs
index 0a86b0b548..09c1eb30d5 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/platform.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/platform.rs
@@ -93,6 +93,7 @@ fn start(
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/process.rs b/third-party/nsh/crates/nsh-engine/tests/engine/process.rs
index a39ccbce3f..3acec398a0 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/process.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/process.rs
@@ -89,6 +89,7 @@ mount(() => <Text>{{shown()}}</Text>);
             ui_sets: Vec::new(),
             runtime: None,
             data_dir: Some(root.join("data")),
+            cache: nsh_engine::CacheOptions::off(),
             storage: None,
             clock: Default::default(),
             context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs
index 718413ae6d..03fa366a4d 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs
@@ -106,6 +106,7 @@ impl Platform {
             ui_sets: Vec::new(),
             runtime: None,
             data_dir: None,
+            cache: nsh_engine::CacheOptions::off(),
             storage: None,
             clock: Default::default(),
             context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs
index 55000d6500..d42dfe25f5 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs
@@ -56,6 +56,7 @@ fn start() -> (Engine, Receiver<MountBatch>) {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -129,6 +130,7 @@ fn a_rust_app_starts_in_its_appearance() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -185,6 +187,7 @@ fn rust_components_navigate_with_native_stacks() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -275,6 +278,7 @@ fn rust_components_pass_content_to_rust_components() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -375,6 +379,7 @@ fn rust_components_take_the_back_button() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -528,6 +533,7 @@ fn rust_components_hear_layouts_and_scrolls() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -604,6 +610,7 @@ fn nested_components_in_list_rows_reach_the_list_owner() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -667,6 +674,7 @@ fn content_a_component_does_not_place_is_not_shown() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs
index 7822116c72..fd0f468378 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs
@@ -521,6 +521,7 @@ pub(crate) fn start_with(
         ui_sets,
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs
index 52bb5692c6..0167a0d1b7 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs
@@ -33,6 +33,7 @@ fn start(root: Root) -> (Engine, Receiver<MountBatch>) {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -396,6 +397,7 @@ fn start_js(app: &str) -> (Engine, Receiver<MountBatch>) {
         ui_sets,
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs
index 0990483c29..2583ea57ad 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs
@@ -54,6 +54,7 @@ fn config(app: App) -> EngineConfig {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs
index d6f494e34e..ef1c63d9d8 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs
@@ -1,11 +1,12 @@
 //! `nsh/base`'s popups used from Rust: a `Popover` filled with a trigger
 //! and content shows its content in an overlay of the surface's root view,
-//! placed against the trigger, outside the box that clips it.
+//! placed against the trigger, outside the box that clips it; a
+//! `ToastStack` of `Toast`s lays them out as layers.
 
 use std::time::{Duration, Instant};
 
 use crossbeam_channel::Receiver;
-use nsh_base::popover;
+use nsh_base::{Anchor, ToastStackState, ToastTransitionStatus, popover, toast, toast_stack};
 use nsh_core::{
     ApproxTextEngine, Bounds, MountBatch, MountItem, NodeId, Points, ROOT_ID, Size,
     ui::{Root, prelude::*},
@@ -32,10 +33,9 @@ fn Screen(_props: ScreenProps) -> impl IntoElement {
     )
 }
 
-fn start() -> (Engine, Receiver<MountBatch>) {
-    let (tx, rx) = crossbeam_channel::unbounded();
-    let config = EngineConfig {
-        app: App::Rust(Root::new::<Screen>(|| ScreenProps {})),
+fn config(app: App) -> EngineConfig {
+    EngineConfig {
+        app,
         viewport: Size::new(px(390.0), px(844.0)),
         trace: false,
         network: None,
@@ -48,6 +48,7 @@ fn start() -> (Engine, Receiver<MountBatch>) {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -55,7 +56,12 @@ fn start() -> (Engine, Receiver<MountBatch>) {
         clipboard: None,
         host: Default::default(),
         picker: None,
-    };
+    }
+}
+
+fn start() -> (Engine, Receiver<MountBatch>) {
+    let (tx, rx) = crossbeam_channel::unbounded();
+    let config = config(App::Rust(Root::new::<Screen>(|| ScreenProps {})));
     (
         Engine::start(config, || ApproxTextEngine, ChannelSink(tx)).unwrap(),
         rx,
@@ -109,3 +115,101 @@ fn a_popover_shows_its_content_over_the_box_that_clips_its_trigger() {
     assert_eq!(frame.origin, point(px(40.0), px(134.0)));
     assert_eq!(frame.size, Size::new(px(120.0), px(50.0)));
 }
+
+#[derive(Clone, PartialEq, Props)]
+struct ToastScreenProps {}
+
+/// Two toasts in a stack anchored to the bottom, from Rust: each a `Toast`
+/// read by the screen reader, the newest at the bottom and the older one a
+/// collapsed layer above it.
+#[component]
+fn ToastScreen(_props: ToastScreenProps) -> impl IntoElement {
+    let state = ToastStackState::new();
+    let toasts = signal(vec![("Older", 40.0), ("Saved", 60.0)]);
+    view().pt(px(400.0)).child(
+        toast_stack()
+            .placement(Anchor::BottomCenter)
+            .state(state)
+            .w(px(300.0))
+            // A region of toasts, keyed: each is an item of the stack.
+            .child(each(
+                toasts,
+                |(label, _)| *label,
+                |item, _| {
+                    let (label, height) = item.get_untracked();
+                    toast()
+                        .transition_status(ToastTransitionStatus::Present)
+                        .accessibility_label(label)
+                        .h(px(height))
+                        .child(text(label))
+                },
+            )),
+    )
+}
+
+#[test]
+fn a_toast_stack_from_rust_lays_its_toasts_out_as_layers() {
+    let (tx, rx) = crossbeam_channel::unbounded();
+    let config = config(App::Rust(Root::new::<ToastScreen>(|| ToastScreenProps {})));
+    let _engine = Engine::start(config, || ApproxTextEngine, ChannelSink(tx)).unwrap();
+    let deadline = Instant::now() + Duration::from_secs(5);
+    let mut announced = Vec::new();
+    let mut parents: Vec<(NodeId, NodeId)> = Vec::new();
+    let mut frames: Vec<(NodeId, Bounds<Points>)> = Vec::new();
+    let mut texts: Vec<(NodeId, String)> = Vec::new();
+    while let Ok(batch) = rx.recv_deadline(deadline) {
+        for item in &batch.items {
+            match item {
+                MountItem::Command {
+                    command: nsh_core::Command::Announce(m),
+                    ..
+                } => announced.push(m.clone()),
+                MountItem::Insert { parent, child, .. } => parents.push((*parent, *child)),
+                MountItem::Frame { id, frame } => frames.push((*id, *frame)),
+                MountItem::Text { id, text, .. } => texts.push((*id, text.text.to_string())),
+                _ => {}
+            }
+        }
+        if announced.len() == 2 && texts.len() >= 2 {
+            break;
+        }
+    }
+    announced.sort();
+    assert_eq!(
+        announced,
+        ["Older", "Saved"],
+        "each toast is read as it shows"
+    );
+    let parent = |id: NodeId| {
+        parents
+            .iter()
+            .rev()
+            .find(|(_, c)| *c == id)
+            .map(|(p, _)| *p)
+    };
+    let frame = |id: NodeId| frames.iter().rev().find(|(i, _)| *i == id).map(|(_, f)| *f);
+    let text = |t: &str| {
+        texts
+            .iter()
+            .find(|(_, s)| s == t)
+            .map(|(id, _)| *id)
+            .unwrap()
+    };
+    // Text → toast → the stack's wrapper → the stack.
+    let wrapper = |t: &str| parent(parent(text(t)).unwrap()).unwrap();
+    let (older, saved) = (wrapper("Older"), wrapper("Saved"));
+    let stack = parent(saved).unwrap();
+    assert_eq!(
+        parent(older),
+        Some(stack),
+        "the toasts are items of one stack"
+    );
+    // Anchored to the bottom: both wrappers end at the stack's bottom (their
+    // layers are translations), and collapsed it is as tall as the front
+    // toast plus a peek.
+    let (o, s) = (frame(older).unwrap(), frame(saved).unwrap());
+    assert_eq!(o.size.height, px(40.0));
+    assert_eq!(s.size.height, px(60.0));
+    assert_eq!(s.origin.y + s.size.height, o.origin.y + o.size.height);
+    assert_eq!(frame(stack).unwrap().size.height, px(74.0));
+}
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs
index a03d82a007..f1039b808f 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs
@@ -81,6 +81,7 @@ fn start_root(root: Root) -> (Engine, Receiver<MountBatch>) {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs
index 46979671b0..1d76f7391c 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs
@@ -131,6 +131,7 @@ fn a_rust_element_claims_touches_in_a_scroll_view() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs b/third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs
index ba05d0606f..39d7d074da 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs
@@ -38,6 +38,7 @@ fn stop_spinning(code: &str) -> (Duration, Vec<String>) {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/split.rs b/third-party/nsh/crates/nsh-engine/tests/engine/split.rs
index 5a83c9f512..28c901f190 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/split.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/split.rs
@@ -102,6 +102,7 @@ fn config(bundle: Bundle) -> EngineConfig {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/storage.rs b/third-party/nsh/crates/nsh-engine/tests/engine/storage.rs
index f7a7836138..dd5de499d0 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/storage.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/storage.rs
@@ -73,6 +73,7 @@ fn launch(
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: data_dir.map(Into::into),
+        cache: nsh_engine::CacheOptions::off(),
         storage: storage.map(|data| FileStorage::open(data) as _),
         clock: Default::default(),
         context: Default::default(),
@@ -178,6 +179,7 @@ fn rust_shares_the_entries_of_js() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: Some(FileStorage::open(&data)),
         clock: Default::default(),
         context: Default::default(),
@@ -274,6 +276,7 @@ fn rust_edits_the_storage() {
         runtime: None,
         // In memory: the platform gave none.
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -348,6 +351,7 @@ fn a_rust_root_keeps_its_data_under_its_id() {
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: Some(root.clone()),
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/system.rs b/third-party/nsh/crates/nsh-engine/tests/engine/system.rs
index 4df0096772..038ae6bbce 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/system.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/system.rs
@@ -63,6 +63,7 @@ mount(() => <Text>{status()}</Text>);
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: Some(dir.join("data")),
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -151,6 +152,7 @@ mount(() => <Text>{status()}</Text>);"#,
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: Some(root.join("data")),
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
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 9d0c9827f2..031664c856 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/theme.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/theme.rs
@@ -52,6 +52,7 @@ fn start(app_theme: Option<Theme>, host_theme: Option<Theme>) -> (Engine, Receiv
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -151,6 +152,7 @@ fn a_tsx_apps_token_classes_follow_the_host_theme() {
         ui_sets: vec![nsh_base::registry()],
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
@@ -240,6 +242,7 @@ fn a_hosts_theme_overrides_win_over_the_apps_tokens_they_set_and_keep_the_rest()
         clipboard: None,
         host: Default::default(),
         picker: None,
+        cache: Default::default(),
     };
     // A brand's primary, light and dark, set before the app runs.
     let overrides = nsh_base::theme_overrides(
diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/websocket.rs b/third-party/nsh/crates/nsh-engine/tests/engine/websocket.rs
index af1e354a2b..3b473a1620 100644
--- a/third-party/nsh/crates/nsh-engine/tests/engine/websocket.rs
+++ b/third-party/nsh/crates/nsh-engine/tests/engine/websocket.rs
@@ -66,6 +66,7 @@ mount(() => <Text>{{shown()}}</Text>);
         ui_sets: Vec::new(),
         runtime: None,
         data_dir: None,
+        cache: nsh_engine::CacheOptions::off(),
         storage: None,
         clock: Default::default(),
         context: Default::default(),
diff --git a/third-party/nsh/crates/nsh-ios-host/src/lib.rs b/third-party/nsh/crates/nsh-ios-host/src/lib.rs
index 25e1e3d600..77bc69bdd9 100644
--- a/third-party/nsh/crates/nsh-ios-host/src/lib.rs
+++ b/third-party/nsh/crates/nsh-ios-host/src/lib.rs
@@ -86,6 +86,10 @@ pub fn main() {
         AppOptions {
             trace,
             asset_base,
+            // Its caches too (downloaded images): a test's app starts with none.
+            cache: data_dir.as_ref().map_or_else(Default::default, |dir| {
+                nsh_engine::CacheOptions::at(nsh_engine::CacheLocation::Dir(dir.join("cache")))
+            }),
             data_dir,
         },
     );
diff --git a/third-party/nsh/crates/nsh-js/src/animated.rs b/third-party/nsh/crates/nsh-js/src/animated.rs
index 8393761b7e..b451426c12 100644
--- a/third-party/nsh/crates/nsh-js/src/animated.rs
+++ b/third-party/nsh/crates/nsh-js/src/animated.rs
@@ -1,7 +1,9 @@
 //! `nsh/animated`: animated values and the animation graph (`AnimatedNode`,
-//! `AnimatedValue`, `Animated`'s drivers and composites), `Easing` and
-//! `LayoutAnimation`. Everything runs on the UI thread, JS only describes
-//! it.
+//! `AnimatedValue`, `Animated`'s drivers and composites, keyframes),
+//! motions (`createMotion`, `motions`), `Easing` and `LayoutAnimation`.
+//! Everything runs on the UI thread, JS only describes it: keyframes and
+//! motions are composites whose trees the core makes from their configs
+//! (`nsh_core::animation::motion`).
 //!
 //! Operations are encoded as a few values (`ANIMATION_*`) and decoded into
 //! `AnimationOp`s (`protocol::decode_animation_op`). Nodes and composites are
@@ -15,7 +17,10 @@ use std::{
     ptr,
 };
 
-use nsh_core::{animation::AnimationOp, js::protocol::*};
+use nsh_core::{
+    animation::{AnimationOp, MotionChannel},
+    js::protocol::*,
+};
 use rquickjs::{Ctx, Value, qjs};
 use rustc_hash::FxHashMap;
 
@@ -266,6 +271,7 @@ fn classes(cx: *mut JsContext) -> Classes {
                 (c"timing", value_timing, 2),
                 (c"spring", value_spring, 2),
                 (c"decay", value_decay, 1),
+                (c"keyframes", value_keyframes, 2),
             ],
         );
         let composite_proto = qjs::JS_NewObject(cx);
@@ -1256,7 +1262,8 @@ unsafe extern "C" fn animated_delay(
     composite(cx, spec, Vec::new())
 }
 
-/// `stagger(ms, list)`: the animations starting `ms` apart.
+/// `stagger(ms, list, { from })`: the animations starting `ms` apart,
+/// counted from the first (by default), the last, the center or an index.
 unsafe extern "C" fn animated_stagger(
     cx: *mut JsContext,
     _this: JsValue,
@@ -1267,13 +1274,16 @@ unsafe extern "C" fn animated_stagger(
     // SAFETY: a conversion, reads of a live array; values are freed.
     unsafe { qjs::JS_ToFloat64(cx, &mut ms, arg(argc, argv, 0)) };
     let list = arg(argc, argv, 1);
+    let from = stagger_from(cx, arg(argc, argv, 2));
     let mut len: i64 = 0;
     let mut staged = Vec::new();
     unsafe {
         if qjs::JS_IsArray(list) && qjs::JS_GetLength(cx, list, &mut len) == 0 {
-            for i in 0..len.max(0) {
-                let a = qjs::JS_GetPropertyInt64(cx, list, i);
-                let mut delay_args = [qjs::JS_NewFloat64(ms * i as f64)];
+            let count = len.max(0) as usize;
+            for i in 0..count {
+                let a = qjs::JS_GetPropertyInt64(cx, list, i as i64);
+                let steps = nsh_core::animation::stagger_delay(i, count, from) as f64;
+                let mut delay_args = [qjs::JS_NewFloat64(ms * steps)];
                 let delay = animated_delay(cx, UNDEFINED, 1, delay_args.as_mut_ptr());
                 let mut pair = [delay, a];
                 let seq_list = qjs::JS_NewArrayFrom(cx, 2, pair.as_mut_ptr());
@@ -1288,6 +1298,347 @@ unsafe extern "C" fn animated_stagger(
     r
 }
 
+/// `{ from }` of a stagger: `"first"`, `"last"`, `"center"` or an index.
+fn stagger_from(cx: *mut JsContext, config: JsValue) -> nsh_core::animation::StaggerFrom {
+    use nsh_core::animation::StaggerFrom;
+    let from = get(cx, config, c"from");
+    let mut index = 0f64;
+    // SAFETY: checks and a conversion of a live value.
+    let r = unsafe {
+        if qjs::JS_IsNumber(from) && qjs::JS_ToFloat64(cx, &mut index, from) == 0 {
+            StaggerFrom::Index(index.max(0.0) as usize)
+        } else {
+            match dom::string_arg(cx, from).as_deref() {
+                Some("last") => StaggerFrom::Last,
+                Some("center") => StaggerFrom::Center,
+                _ => StaggerFrom::First,
+            }
+        }
+    };
+    free(cx, from);
+    r
+}
+
+// --- Keyframes and motions ---------------------------------------------------------
+
+/// A keyframes composite on value `value`: `config` is `{ keyframes,
+/// duration, delay, easing, iterations, direction }` (copied).
+fn keyframes(cx: *mut JsContext, value: JsValue, config: JsValue) -> JsValue {
+    let Some(node) = node_id(cx, value) else {
+        // SAFETY: throws on a live context.
+        return unsafe { qjs::JS_ThrowTypeError(cx, c"not an animated value".as_ptr()) };
+    };
+    let spec = driver_config(cx, "keyframes", None, config);
+    // SAFETY: the spec takes the field.
+    unsafe { qjs::JS_SetPropertyStr(cx, spec, c"node".as_ptr(), int(node)) };
+    composite(cx, spec, vec![dup(cx, value)])
+}
+
+/// `Animated.keyframes(value, config)`.
+unsafe extern "C" fn animated_keyframes(
+    cx: *mut JsContext,
+    _this: JsValue,
+    argc: c_int,
+    argv: *mut JsValue,
+) -> JsValue {
+    keyframes(cx, arg(argc, argv, 0), arg(argc, argv, 1))
+}
+
+/// Starts composite `c` (taken): a promise of whether it finished.
+fn start_composite(cx: *mut JsContext, c: JsValue) -> JsValue {
+    // SAFETY: a check of a live value, then its method.
+    if unsafe { qjs::JS_IsException(c) } {
+        return c;
+    }
+    let p = unsafe { composite_start(cx, c, 0, ptr::null_mut()) };
+    free(cx, c);
+    p
+}
+
+/// `value.keyframes(keyframes, options)`: plays them on the value; a
+/// promise of whether they finished.
+unsafe extern "C" fn value_keyframes(
+    cx: *mut JsContext,
+    this: JsValue,
+    argc: c_int,
+    argv: *mut JsValue,
+) -> JsValue {
+    let config = driver_config(cx, "keyframes", None, arg(argc, argv, 1));
+    // SAFETY: the config takes the field.
+    unsafe {
+        qjs::JS_SetPropertyStr(
+            cx,
+            config,
+            c"keyframes".as_ptr(),
+            dup(cx, arg(argc, argv, 0)),
+        );
+    }
+    let c = keyframes(cx, this, config);
+    free(cx, config);
+    start_composite(cx, c)
+}
+
+/// Whether field `key` of `obj` is truthy.
+fn flag(cx: *mut JsContext, obj: JsValue, key: &CStr) -> bool {
+    let v = get(cx, obj, key);
+    // SAFETY: a conversion of an owned value, freed.
+    let on = unsafe { qjs::JS_ToBool(cx, v) } == 1;
+    free(cx, v);
+    on
+}
+
+/// Motion `motion` (a name or a spec) with `options` (its timing, ...)
+/// over it, owned: a preset named with options is the preset's data
+/// (`motions`) with them.
+fn motion_with(cx: *mut JsContext, motion: JsValue, options: JsValue) -> JsValue {
+    // SAFETY: checks of live values; new values owned.
+    unsafe {
+        if !qjs::JS_IsObject(options) {
+            return dup(cx, motion);
+        }
+        let name = qjs::JS_IsString(motion)
+            .then(|| dom::string_arg(cx, motion))
+            .flatten();
+        let base = match name {
+            Some(name) => {
+                let motions = table(cx, "motions");
+                let name = CString::new(name).unwrap_or_default();
+                let preset = qjs::JS_GetPropertyStr(cx, motions, name.as_ptr());
+                free(cx, motions);
+                if qjs::JS_IsUndefined(preset) {
+                    return dup(cx, motion);
+                }
+                preset
+            }
+            None => dup(cx, motion),
+        };
+        let mut args = [base, options];
+        let merged = dom::merge_values(cx, &mut args);
+        free(cx, base);
+        merged
+    }
+}
+
+/// A composite of `motion` (with `options`) on the channels of motion
+/// object `object`, not started.
+fn motion_composite(
+    cx: *mut JsContext,
+    object: JsValue,
+    motion: JsValue,
+    options: JsValue,
+) -> JsValue {
+    let mut ids = Vec::with_capacity(MotionChannel::ALL.len());
+    let mut values = Vec::with_capacity(MotionChannel::ALL.len());
+    for channel in MotionChannel::ALL {
+        let name = CString::new(channel.name()).unwrap_or_default();
+        let v = get(cx, object, &name);
+        ids.push(node_id(cx, v).unwrap_or(0) as f32);
+        values.push(v);
+    }
+    // Less motion, asked for by the user: it lands where it ends.
+    let reduced = crate::env::current(cx).reduce_motion
+        && !flag(cx, options, c"ignoreReduceMotion")
+        && !flag(cx, motion, c"ignoreReduceMotion");
+    let spec = object_spec(cx, motion, options, &ids, reduced);
+    composite(cx, spec, values)
+}
+
+/// Plays `motion` (with `options`) on the channels of motion object
+/// `object`: a promise of whether it finished.
+fn play(cx: *mut JsContext, object: JsValue, motion: JsValue, options: JsValue) -> JsValue {
+    start_composite(cx, motion_composite(cx, object, motion, options))
+}
+
+/// The composite spec of a motion's `play`.
+fn object_spec(
+    cx: *mut JsContext,
+    motion: JsValue,
+    options: JsValue,
+    ids: &[f32],
+    reduced: bool,
+) -> JsValue {
+    object(
+        cx,
+        vec![
+            (c"type", string(cx, "motion")),
+            (c"nodes", number_array(cx, ids)),
+            (c"motion", motion_with(cx, motion, options)),
+            (
+                c"reduced",
+                if reduced { qjs::JS_TRUE } else { qjs::JS_FALSE },
+            ),
+        ],
+    )
+}
+
+/// A motion object's method: `magic` 0 `play`, 1 `stop`, 2 `reset`, 3
+/// `animation`; its data is the object.
+unsafe extern "C" fn motion_method(
+    cx: *mut JsContext,
+    _this: JsValue,
+    argc: c_int,
+    argv: *mut JsValue,
+    magic: c_int,
+    data: *mut JsValue,
+) -> JsValue {
+    // SAFETY: one data value: the motion object.
+    let object = unsafe { *data };
+    match magic {
+        0 => return play(cx, object, arg(argc, argv, 0), arg(argc, argv, 1)),
+        3 => return motion_composite(cx, object, arg(argc, argv, 0), arg(argc, argv, 1)),
+        _ => {}
+    }
+    for channel in MotionChannel::ALL {
+        let name = CString::new(channel.name()).unwrap_or_default();
+        let v = get(cx, object, &name);
+        let id = node_id(cx, v);
+        free(cx, v);
+        let Some(id) = id else { continue };
+        let mut op = vec![int(ANIMATION_STOP), int(id)];
+        if magic == 2 {
+            op = vec![int(ANIMATION_SET), int(id), int(channel.identity())];
+        }
+        if let Err(e) = send(cx, op) {
+            return e;
+        }
+    }
+    UNDEFINED
+}
+
+/// A motion's transform steps (owned) over its channels' `values`: the
+/// translations and rotations, then the scales with `scale` folded into
+/// `scaleX` and `scaleY` (multiplied natively), so a motion takes 7 of a
+/// view's 8 transform steps and leaves one for the app (a `perspective`).
+fn motion_transform(cx: *mut JsContext, values: &[JsValue]) -> Vec<JsValue> {
+    use MotionChannel::*;
+    let at = |c: MotionChannel| values[c.index()];
+    let mut steps = Vec::with_capacity(7);
+    for channel in [TranslateX, TranslateY, Rotate, RotateX, RotateY] {
+        let name = CString::new(channel.name()).unwrap_or_default();
+        steps.push(object(cx, vec![(name.as_c_str(), dup(cx, at(channel)))]));
+    }
+    steps.push(object(
+        cx,
+        vec![(c"scaleX", math(cx, "mul", at(Scale), at(ScaleX)))],
+    ));
+    steps.push(object(
+        cx,
+        vec![(c"scaleY", math(cx, "mul", at(Scale), at(ScaleY)))],
+    ));
+    steps
+}
+
+/// `createMotion(options)`: an animated value per channel at rest (or at
+/// `options.initial`, or where `options.enter` starts), their style
+/// bindings, and `play` / `stop` / `reset` / `animation`; `enter` plays at
+/// once, with the options as its timing.
+unsafe extern "C" fn create_motion(
+    cx: *mut JsContext,
+    _this: JsValue,
+    argc: c_int,
+    argv: *mut JsValue,
+) -> JsValue {
+    let options = arg(argc, argv, 0);
+    let enter = get(cx, options, c"enter");
+    let has_enter = !unsafe { qjs::JS_IsUndefined(enter) };
+    let mut initial = [None; 9];
+    let given = get(cx, options, c"initial");
+    for channel in MotionChannel::ALL {
+        let name = CString::new(channel.name()).unwrap_or_default();
+        let v = get(cx, given, &name);
+        let mut x = 0f64;
+        // SAFETY: a check and a conversion of an owned value, freed.
+        if unsafe { qjs::JS_IsNumber(v) && qjs::JS_ToFloat64(cx, &mut x, v) == 0 } {
+            initial[channel.index()] = Some(x as f32);
+        }
+        free(cx, v);
+    }
+    free(cx, given);
+    let starts = if has_enter {
+        // SAFETY: a context reference for the decoder, which takes a dup.
+        let js_cx = unsafe { Ctx::from_raw(ptr::NonNull::new_unchecked(cx)) };
+        let value = unsafe { Value::from_raw(js_cx, dup(cx, enter)) };
+        crate::protocol::motion_start(value)
+    } else {
+        [None; 9]
+    };
+    // SAFETY: new objects taking the values set on them.
+    unsafe {
+        let m = qjs::JS_NewObject(cx);
+        // The channels' values (owned), by `MotionChannel::ALL`.
+        let mut values = Vec::with_capacity(MotionChannel::ALL.len());
+        for channel in MotionChannel::ALL {
+            let start = starts[channel.index()]
+                .or(initial[channel.index()])
+                .unwrap_or(channel.identity());
+            let v = match new_value(cx, start as f64) {
+                Ok(v) => v,
+                Err(e) => {
+                    free(cx, m);
+                    for v in values {
+                        free(cx, v);
+                    }
+                    free(cx, enter);
+                    return e;
+                }
+            };
+            let name = CString::new(channel.name()).unwrap_or_default();
+            qjs::JS_SetPropertyStr(cx, m, name.as_ptr(), dup(cx, v));
+            values.push(v);
+        }
+        let opacity = dup(cx, values[MotionChannel::Opacity.index()]);
+        let transform = motion_transform(cx, &values);
+        for v in values {
+            free(cx, v);
+        }
+        let list = |items: &[JsValue]| {
+            let mut items: Vec<JsValue> = items.iter().map(|&t| dup(cx, t)).collect();
+            qjs::JS_NewArrayFrom(cx, items.len() as c_int, items.as_mut_ptr())
+        };
+        let style = object(
+            cx,
+            vec![(c"opacity", opacity), (c"transform", list(&transform))],
+        );
+        qjs::JS_SetPropertyStr(cx, m, c"transform".as_ptr(), list(&transform));
+        qjs::JS_SetPropertyStr(cx, m, c"style".as_ptr(), style);
+        for t in transform {
+            free(cx, t);
+        }
+        for (name, magic, length) in [
+            (c"play", 0, 2),
+            (c"stop", 1, 0),
+            (c"reset", 2, 0),
+            (c"animation", 3, 2),
+        ] {
+            let mut data = [m];
+            let f = qjs::JS_NewCFunctionData(
+                cx,
+                Some(motion_method),
+                length,
+                magic,
+                1,
+                data.as_mut_ptr(),
+            );
+            qjs::JS_SetPropertyStr(cx, m, name.as_ptr(), f);
+        }
+        if has_enter {
+            let p = play(cx, m, enter, options);
+            free(cx, enter);
+            if qjs::JS_IsException(p) {
+                free(cx, m);
+                return p;
+            }
+            free(cx, p);
+        }
+        m
+    }
+}
+
+/// `motions`: the presets as data.
+fn motions(cx: *mut JsContext) -> JsValue {
+    table(cx, "motions")
+}
+
 /// `loop(animation, { iterations, resetBeforeIteration })`.
 unsafe extern "C" fn animated_loop(
     cx: *mut JsContext,
@@ -1378,9 +1729,10 @@ fn animated(cx: *mut JsContext) -> JsValue {
             (c"decay", animated_decay, 2),
             (c"sequence", animated_sequence, 1),
             (c"parallel", animated_parallel, 2),
-            (c"stagger", animated_stagger, 2),
+            (c"stagger", animated_stagger, 3),
             (c"delay", animated_delay, 1),
             (c"loop", animated_loop, 2),
+            (c"keyframes", animated_keyframes, 2),
             (c"add", animated_add, 2),
             (c"subtract", animated_subtract, 2),
             (c"multiply", animated_multiply, 2),
@@ -1499,6 +1851,22 @@ unsafe extern "C" fn easing_mode(
     }
 }
 
+/// `Easing.steps(count, position)`: CSS `steps()`.
+unsafe extern "C" fn easing_steps(
+    cx: *mut JsContext,
+    _this: JsValue,
+    argc: c_int,
+    argv: *mut JsValue,
+) -> JsValue {
+    let position = arg(argc, argv, 1);
+    let mut fields = vec![(c"steps", dup(cx, arg(argc, argv, 0)))];
+    // SAFETY: a check of a live value.
+    if !unsafe { qjs::JS_IsUndefined(position) } {
+        fields.push((c"position", dup(cx, position)));
+    }
+    object(cx, fields)
+}
+
 unsafe extern "C" fn easing_bezier(
     cx: *mut JsContext,
     _this: JsValue,
@@ -1551,6 +1919,12 @@ fn easing(cx: *mut JsContext) -> JsValue {
             c"bezier".as_ptr(),
             reactive::new_function(cx, c"bezier", easing_bezier, 4),
         );
+        qjs::JS_SetPropertyStr(
+            cx,
+            o,
+            c"steps".as_ptr(),
+            reactive::new_function(cx, c"steps", easing_steps, 2),
+        );
         qjs::JS_SetPropertyStr(
             cx,
             o,
@@ -1778,6 +2152,10 @@ pub(crate) const EXPORTS: &[Item] = &[
    * `clamp` it stops at a bound, or bounces back keeping `bounce` (0..1) of
    * its speed. */
   decay(options?: DecayConfig): Promise<boolean>;
+  /** Plays `keyframes` (numbers spread evenly, or `{ offset, value, easing }`)
+   * as CSS keyframes play: each segment with its easing (CSS `ease` by
+   * default), `iterations` times in `direction`. Resolves with `finished`. */
+  keyframes(keyframes: KeyframeValue[], options?: KeyframeTiming): Promise<boolean>;
 }"#,
         ),
     ),
@@ -1789,6 +2167,22 @@ pub(crate) const EXPORTS: &[Item] = &[
             &["(value?: number): AnimatedValue"],
         ),
     ),
+    (
+        "createMotion",
+        Export::Function(create_motion, 1),
+        Ts::function(
+            "Creates a motion: an animated value for each channel (`opacity`, `translateX`,\n`translateY`, `rotate`, `rotateX`, `rotateY`, `scale`, `scaleX`, `scaleY`) at rest, bound\nwith `style={motion.style}` (or `opacity` and `transform` spread into a style), and\n`play(motion, options)` to run a preset (`\"shake\"`, `\"fadeInUp\"`, ...) or keyframes of\nyour own on them, natively. `enter` starts the values where that motion starts and\nplays it at once (the options also take its tim [...]
+            &["(options?: MotionOptions): Motion"],
+        ),
+    ),
+    (
+        "motions",
+        Export::Value(motions),
+        Ts::value(
+            "The ready-made motions as data (`motion.play(\"shake\")` plays `motions.shake`):\nentrances, exits, attention seekers and loops. Spread one to change it:\n`motion.play({ ...motions.fadeInUp, duration: 600 })`.",
+            "Readonly<Record<MotionName, MotionPreset>>",
+        ),
+    ),
     (
         "Animated",
         Export::Value(animated),
@@ -1846,6 +2240,8 @@ export interface EasingModule extends EasingCurves {
   linear: Easing;
   ease: Easing;
   bezier(x1: number, y1: number, x2: number, y2: number): Easing;
+  /** CSS `steps(count, position)`: jumps between `count` levels. */
+  steps(count: number, position?: StepPosition): EasingSteps;
   in(e: Easing): Easing;
   out(e: Easing): Easing;
   inOut(e: Easing): Easing;
@@ -1855,6 +2251,120 @@ export interface EasingModule extends EasingCurves {
   sample(easing: Easing, count: number): number[];
 }
 
+/** A keyframe of one value: where it is at `offset` (0..1 of an iteration;
+ * missing offsets spread evenly, as in the Web Animations API) and the easing
+ * of the segment that starts there. Values are numbers, or angles
+ * (`"45deg"`, `"0.5turn"`) in degrees. */
+export interface Keyframe {
+  offset?: number;
+  value: number | string;
+  easing?: Easing;
+}
+
+/** A keyframe, or only its value (spread evenly with the others). */
+export type KeyframeValue = number | string | Keyframe;
+
+/** Which way iterations of keyframes play (CSS `animation-direction`). */
+export type PlaybackDirection = "normal" | "reverse" | "alternate" | "alternate-reverse";
+
+/** How keyframes play, as CSS animations do. */
+export interface KeyframeTiming {
+  /** One iteration, in milliseconds (300). */
+  duration?: number;
+  /** Before the first iteration, in milliseconds. */
+  delay?: number;
+  /** The curve of each segment without one of its own (CSS `ease`). */
+  easing?: Easing;
+  /** How many iterations (1), or `"infinite"`. */
+  iterations?: number | "infinite";
+  /** Which way they play (`"normal"`). */
+  direction?: PlaybackDirection;
+}
+
+/** `Animated.keyframes`' config. */
+export interface KeyframesConfig extends KeyframeTiming {
+  keyframes: KeyframeValue[];
+}
+
+/** One frame of several channels, as the Web Animations API writes them:
+ * `{ opacity: 0, translateY: 24 }`, with its `offset` and `easing`. */
+export type MotionFrame = { [C in MotionChannel]?: number | string } & { offset?: number; easing?: Easing };
+
+/** A motion of your own: each channel's keyframes (`{ opacity: [0, 1],
+ * translateY: [24, 0] }`) or a list of frames (`[{ opacity: 0 }, { opacity: 1
+ * }]`), with how they play. */
+export interface MotionSpec extends KeyframeTiming {
+  keyframes: { [C in MotionChannel]?: KeyframeValue[] } | MotionFrame[];
+  /** Plays even when the user asks for less motion. */
+  ignoreReduceMotion?: boolean;
+}
+
+/** A ready-made motion (`motions.<name>`). */
+export interface MotionPreset extends MotionSpec {
+  /** What it is for: it brings a view in, takes it out, draws the eye or
+   * repeats. */
+  kind: "enter" | "exit" | "attention" | "loop";
+}
+
+/** Options of `play`: the motion's timing changed, and more. */
+export interface MotionPlayOptions extends KeyframeTiming {
+  /** Plays even when the user asks for less motion. */
+  ignoreReduceMotion?: boolean;
+}
+
+/** `createMotion`'s options: where channels start, a motion to enter with,
+ * and its timing. */
+export interface MotionOptions extends MotionPlayOptions {
+  /** Where channels start (the others at rest): `{ opacity: 0 }`. */
+  initial?: { [C in MotionChannel]?: number };
+  /** Starts where this motion starts and plays it at once. */
+  enter?: MotionName | MotionSpec;
+}
+
+/** A motion's transform: its channels in the order they apply, `scale`
+ * folded into `scaleX` and `scaleY` (7 steps; a view takes 8, so one is left
+ * for yours, such as a `perspective` before them for 3D flips). */
+export type MotionTransform = [
+  { translateX: AnimatedValue },
+  { translateY: AnimatedValue },
+  { rotate: AnimatedValue },
+  { rotateX: AnimatedValue },
+  { rotateY: AnimatedValue },
+  { scaleX: AnimatedNode },
+  { scaleY: AnimatedNode },
+];
+
+/** Animated values for a view's opacity and transform, and motions to play
+ * on them (`createMotion`). */
+export interface Motion {
+  readonly opacity: AnimatedValue;
+  readonly translateX: AnimatedValue;
+  readonly translateY: AnimatedValue;
+  /** Degrees. */
+  readonly rotate: AnimatedValue;
+  readonly rotateX: AnimatedValue;
+  readonly rotateY: AnimatedValue;
+  readonly scale: AnimatedValue;
+  readonly scaleX: AnimatedValue;
+  readonly scaleY: AnimatedValue;
+  /** `{ opacity, transform }`: bind it with `style={motion.style}`, or
+   * spread it into a style. */
+  readonly style: { opacity: AnimatedValue; transform: MotionTransform };
+  /** The transform alone, to spread into one with steps of your own. */
+  readonly transform: MotionTransform;
+  /** Plays a preset or a motion of your own; a new one interrupts the one
+   * playing. Resolves with whether it finished. */
+  play(motion: MotionName | MotionSpec, options?: MotionPlayOptions): Promise<boolean>;
+  /** The same motion as an animation to compose, not started:
+   * `Animated.stagger(60, cards.map((m) => m.animation("fadeInUp")), { from:
+   * "center" })`. */
+  animation(motion: MotionName | MotionSpec, options?: MotionPlayOptions): CompositeAnimation;
+  /** Stops it where it is. */
+  stop(): void;
+  /** Stops it and puts every channel back at rest. */
+  reset(): void;
+}
+
 /** An animation built with `Animated`: runs natively, steps and all. */
 export interface CompositeAnimation {
   /** Runs it (again). Resolves (and calls `callback`) with whether it
@@ -1873,9 +2383,13 @@ export interface AnimatedModule {
   decay(value: AnimatedValue, config?: DecayConfig): CompositeAnimation;
   sequence(list: CompositeAnimation[]): CompositeAnimation;
   parallel(list: CompositeAnimation[], config?: { stopTogether?: boolean }): CompositeAnimation;
-  stagger(ms: number, list: CompositeAnimation[]): CompositeAnimation;
+  /** The animations all at once, each starting `ms` after the one before,
+   * counted from the first, the last, the center item or an index. */
+  stagger(ms: number, list: CompositeAnimation[], config?: { from?: "first" | "last" | "center" | number }): CompositeAnimation;
   delay(ms: number): CompositeAnimation;
   loop(animation: CompositeAnimation, config?: { iterations?: number; resetBeforeIteration?: boolean }): CompositeAnimation;
+  /** `value` through keyframes, as CSS keyframes play (see `KeyframeTiming`). */
+  keyframes(value: AnimatedValue, config: KeyframesConfig): CompositeAnimation;
   add(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
   subtract(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
   multiply(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
@@ -2163,4 +2677,104 @@ mod tests {
         animation_ops(&host);
         assert_eq!(report(&host).as_deref(), Some("0 0.25 1,0,1,false,100,0"));
     }
+
+    #[test]
+    fn keyframes_and_motions_become_native_trees() {
+        use nsh_core::animation::{AnimationOp as A, Composite, Driver};
+        let host = Host::new().unwrap();
+        host.eval_source(
+            r#"
+            import { Animated, AnimatedValue, Easing, createMotion, motions } from "nsh/animated";
+            import { text } from "nsh:rt";
+            const v = new AnimatedValue(0);
+            v.keyframes([0, { value: 10, offset: 0.25, easing: Easing.steps(2) }, 0], { duration: 400 });
+            const m = createMotion({ enter: "fadeInUp", delay: 100 });
+            m.play({ keyframes: [{ rotate: "0deg" }, { rotate: "0.5turn" }], iterations: "infinite" });
+            const staggered = Animated.stagger(10, [0, 1, 2].map(() => Animated.timing(v, { toValue: 1 })), { from: "center" });
+            globalThis.report = () =>
+                text([
+                    Easing.sample(Easing.steps(2), 3).join(" "),
+                    motions.shake.kind,
+                    motions.fadeInUp.keyframes.translateY[0].value,
+                    m.style.transform.length,
+                    m.style.opacity === m.opacity,
+                    typeof m.play,
+                ].join(","));
+            globalThis.keep = [v, m, staggered];
+            "#,
+        )
+        .unwrap();
+        let ops = animation_ops(&host);
+        let composites: Vec<&Composite> = ops
+            .iter()
+            .filter_map(|o| match o {
+                A::StartComposite { spec, .. } => Some(spec),
+                _ => None,
+            })
+            .collect();
+        assert_eq!(composites.len(), 3, "{ops:?}");
+        // The keyframes: a jump to 0, 0 -> 10 over 100 ms, then back in
+        // steps (a keyframe's easing shapes the segment starting there).
+        let Composite::Sequence(steps) = composites[0] else {
+            panic!("{:?}", composites[0])
+        };
+        assert!(matches!(
+            steps[1],
+            Composite::Run {
+                driver: Driver::Timing {
+                    to: 10.0,
+                    duration_ms: 100.0,
+                    ..
+                },
+                ..
+            }
+        ));
+        assert!(matches!(
+            steps[2],
+            Composite::Run {
+                driver: Driver::Timing {
+                    to: 0.0,
+                    duration_ms: 300.0,
+                    easing: nsh_core::animation::Easing::Steps(2, _),
+                    ..
+                },
+                ..
+            }
+        ));
+        // The motion's values start where `fadeInUp` starts.
+        let created: Vec<f32> = ops
+            .iter()
+            .filter_map(|o| match o {
+                A::CreateValue { value, .. } => Some(*value),
+                _ => None,
+            })
+            .collect();
+        assert_eq!(created, [0.0, 0.0, 0.0, 24.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
+        // Its enter: two tracks holding their first keyframe through the
+        // delay; the play: one endless track in degrees.
+        let Composite::Parallel { children, .. } = composites[1] else {
+            panic!("{:?}", composites[1])
+        };
+        assert_eq!(children.len(), 2);
+        assert!(matches!(&children[0], Composite::Sequence(s) if s[1] == Composite::Delay(100.0)));
+        let Composite::Parallel { children, .. } = composites[2] else {
+            panic!("{:?}", composites[2])
+        };
+        let Composite::Loop {
+            child,
+            iterations: None,
+            ..
+        } = &children[0]
+        else {
+            panic!("{children:?}")
+        };
+        assert!(matches!(
+            &**child,
+            Composite::Sequence(s) if matches!(s[1], Composite::Run { driver: Driver::Timing { to: 180.0, .. }, .. })
+        ));
+        assert_eq!(
+            report(&host).as_deref(),
+            Some("0 0.5 1,attention,24,7,true,function")
+        );
+    }
 }
diff --git a/third-party/nsh/crates/nsh-js/src/components.rs b/third-party/nsh/crates/nsh-js/src/components.rs
index 1aef74884d..448a9c5513 100644
--- a/third-party/nsh/crates/nsh-js/src/components.rs
+++ b/third-party/nsh/crates/nsh-js/src/components.rs
@@ -810,7 +810,14 @@ fn render_component(
                 }
                 c
             }
-            None => reader,
+            // A memo of the getter: it runs again only when what the
+            // children's expression itself reads changes, not each time
+            // what they show does, so the regions it makes (an `<Each>`'s
+            // rows) are made once and kept, as a host element's are.
+            None => {
+                let memo = reactive::memo(cx, reader.take(), false);
+                Owned(cx, reactive::finish_value(cx, memo))
+            }
         };
         dom::insert_value_at(cx, node, children.1, anchor.as_ref().map(|a| a.1))?;
     }
diff --git a/third-party/nsh/crates/nsh-js/src/protocol.rs b/third-party/nsh/crates/nsh-js/src/protocol.rs
index 9abea7fcec..0ffa3474e4 100644
--- a/third-party/nsh/crates/nsh-js/src/protocol.rs
+++ b/third-party/nsh/crates/nsh-js/src/protocol.rs
@@ -812,6 +812,18 @@ fn decode_driver(cx: &Ctx<'_>, config: &Value<'_>) -> rquickjs::Result<Driver> {
     })
 }
 
+/// Where the channels of motion `value` (a preset's name or a spec) start,
+/// by `MotionChannel::ALL` (`None` for those it does not move).
+pub(crate) fn motion_start(value: Value<'_>) -> [Option<f32>; 9] {
+    let mut starts = [None; 9];
+    if let Some(spec) = nsh_core::js::animation::motion_spec(&Js(value)) {
+        for (channel, start) in spec.start_values() {
+            starts[channel.index()] = Some(start);
+        }
+    }
+    starts
+}
+
 /// A composite animation: `{ type, ... }` with `node` on the runs.
 fn decode_composite(
     r: &Reader<'_, '_>,
@@ -848,6 +860,24 @@ fn decode_composite(
                 .map(|n| n as u32),
             reset: o.get::<_, Option<bool>>("reset")?.unwrap_or(true),
         },
+        // `Animated.keyframes`: the tree is made natively from the config.
+        "keyframes" => nsh_core::js::animation::keyframes_composite(
+            &Js(spec.clone()),
+            o.get::<_, f64>("node")? as nsh_core::animation::NodeId,
+        ),
+        // A motion's `play`: its channels' nodes (by `MotionChannel::ALL`) and
+        // the motion; `reduced` lands it at its end without moving.
+        "motion" => {
+            let nodes: Vec<f64> = o.get("nodes")?;
+            let nodes: [nsh_core::animation::NodeId; 9] =
+                std::array::from_fn(|i| nodes.get(i).copied().unwrap_or(0.0) as _);
+            let motion = nsh_core::js::animation::motion_spec(&Js(o.get("motion")?))
+                .ok_or_else(|| Exception::throw_type(r.cx, "motion: unknown motion"))?;
+            motion.composite(
+                &nodes,
+                o.get::<_, Option<bool>>("reduced")?.unwrap_or(false),
+            )
+        }
         _ => Composite::Run {
             node: o.get::<_, f64>("node")? as nsh_core::animation::NodeId,
             driver: decode_driver(r.cx, spec)?,
diff --git a/third-party/nsh/crates/nsh-linux-host/src/lib.rs b/third-party/nsh/crates/nsh-linux-host/src/lib.rs
index bbbdef6370..91fa10b032 100644
--- a/third-party/nsh/crates/nsh-linux-host/src/lib.rs
+++ b/third-party/nsh/crates/nsh-linux-host/src/lib.rs
@@ -62,7 +62,15 @@ pub fn main() -> std::process::ExitCode {
             }
             // Where apps keep their data (`nsh test --target` gives each app
             // a fresh one); the system's data directory otherwise.
-            "--data-dir" => options.data_dir = args.next().map(PathBuf::from),
+            "--data-dir" => {
+                options.data_dir = args.next().map(PathBuf::from);
+                // Its caches too (downloaded images): a test's app starts with none.
+                if let Some(dir) = &options.data_dir {
+                    options.cache = nsh_engine::CacheOptions::at(nsh_engine::CacheLocation::Dir(
+                        dir.join("cache"),
+                    ));
+                }
+            }
             "--window-size" => {
                 if let Some((w, h)) = args.next().as_deref().and_then(|s| s.split_once('x')) {
                     options.size = (w.parse().unwrap_or(390), h.parse().unwrap_or(844));
diff --git a/third-party/nsh/crates/nsh-macos-host/src/lib.rs b/third-party/nsh/crates/nsh-macos-host/src/lib.rs
index cbdbcf8325..1aab57c79d 100644
--- a/third-party/nsh/crates/nsh-macos-host/src/lib.rs
+++ b/third-party/nsh/crates/nsh-macos-host/src/lib.rs
@@ -76,7 +76,15 @@ pub fn main() -> std::process::ExitCode {
             "--fps" => options.fps = true,
             // Where apps keep their data (`nsh test --target` gives each app
             // a fresh one); the system's data directory otherwise.
-            "--data-dir" => options.data_dir = args.next().map(std::path::PathBuf::from),
+            "--data-dir" => {
+                options.data_dir = args.next().map(std::path::PathBuf::from);
+                // Its caches too (downloaded images): a test's app starts with none.
+                if let Some(dir) = &options.data_dir {
+                    options.cache = nsh_engine::CacheOptions::at(nsh_engine::CacheLocation::Dir(
+                        dir.join("cache"),
+                    ));
+                }
+            }
             "--window-size" => {
                 if let Some((w, h)) = args.next().as_deref().and_then(|s| s.split_once('x')) {
                     options.size = (w.parse().unwrap_or(1200), h.parse().unwrap_or(800));
diff --git a/third-party/nsh/crates/nsh-platform-android/build.rs b/third-party/nsh/crates/nsh-platform-android/build.rs
new file mode 100644
index 0000000000..8651441201
--- /dev/null
+++ b/third-party/nsh/crates/nsh-platform-android/build.rs
@@ -0,0 +1,11 @@
+//! Android's shared runtime supports both 4 KB and 16 KB page sizes.
+
+fn main() {
+    println!("cargo:rerun-if-changed=build.rs");
+    if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
+        // Both LOAD segments and the end of RELRO must support 16 KB pages,
+        // including builds with older NDKs whose linker defaults to 4 KB.
+        println!("cargo:rustc-link-arg-cdylib=-Wl,-z,max-page-size=16384");
+        println!("cargo:rustc-link-arg-cdylib=-Wl,-z,common-page-size=16384");
+    }
+}
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/DevActivity.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/DevActivity.java
index dc2bb8632f..40bd715605 100644
--- a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/DevActivity.java
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/DevActivity.java
@@ -29,7 +29,8 @@ public final class DevActivity extends Activity {
         if (data != null) {
             java.io.File dir = new java.io.File(new java.io.File(getCacheDir(), "nsh-test"), data);
             deleteTree(dir);
-            view = new NshView(this, trace, dir);
+            // Its caches too (downloaded images): the app starts with none.
+            view = new NshView(this, trace, dir, new java.io.File(dir, "cache"));
         } else {
             view = new NshView(this, trace);
         }
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Images.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Images.java
index 43e4d8d96a..83474f35a6 100644
--- a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Images.java
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Images.java
@@ -85,6 +85,23 @@ final class Images {
     /** Set by the mounter; until then in-memory images fail. */
     volatile Inline inline = uri -> null;
 
+    /** The running app's disk cache of downloaded images ({@link Native#imageCacheLookup}). */
+    interface DiskCache {
+        /** Fresh bytes ({@code byte[]}), a stale copy's conditional headers ({@code String[]}), or null. */
+        Object lookup(String url);
+
+        /** Ends a download (status 0: failed); the cached bytes to show, or null. */
+        byte[] complete(String url, int status, String[] headers, byte[] body);
+    }
+
+    /** Set by the mounter; until then nothing is kept on disk. */
+    volatile DiskCache disk = null;
+
+    /** The response headers the disk cache reads (lower case). */
+    private static final String[] CACHE_HEADERS = {
+        "cache-control", "expires", "date", "age", "etag", "last-modified", "vary", "pragma"
+    };
+
     private static final int MAX_REDIRECTS = 20;
 
     private static final ExecutorService POOL = Executors.newFixedThreadPool(4, r -> {
@@ -394,6 +411,12 @@ final class Images {
             return bytes;
         }
         if (uri.startsWith("http://") || uri.startsWith("https://")) {
+            // Through the disk cache: a fresh copy is all it takes, a stale
+            // one is revalidated (its conditions sent).
+            DiskCache disk = this.disk;
+            Object cached = disk != null ? disk.lookup(uri) : null;
+            if (cached instanceof byte[]) return (byte[]) cached;
+            String[] conditions = cached instanceof String[] ? (String[]) cached : null;
             HttpURLConnection c = null;
             String url = uri;
             try {
@@ -404,7 +427,18 @@ final class Images {
                     c.setInstanceFollowRedirects(false);
                     c.setConnectTimeout(30_000);
                     c.setReadTimeout(60_000);
-                    int code = c.getResponseCode();
+                    if (conditions != null) {
+                        for (int i = 0; i + 1 < conditions.length; i += 2) c.setRequestProperty(conditions[i], conditions[i + 1]);
+                    }
+                    int code;
+                    try {
+                        code = c.getResponseCode();
+                    } catch (java.io.IOException e) {
+                        // Offline: a stale copy may serve.
+                        byte[] stale = disk != null ? disk.complete(uri, 0, null, null) : null;
+                        if (stale != null) return stale;
+                        throw e;
+                    }
                     if (code != 301 && code != 302 && code != 303 && code != 307 && code != 308) break;
                     String location = c.getHeaderField("Location");
                     c.disconnect();
@@ -414,7 +448,11 @@ final class Images {
                     url = next;
                 }
                 int status = c.getResponseCode();
-                if (status >= 400) throw new java.io.IOException("HTTP " + status);
+                if (status == 304 || status >= 400) {
+                    byte[] served = disk != null ? disk.complete(uri, status, cacheHeaders(c), null) : null;
+                    if (served != null) return served;
+                    throw new java.io.IOException("HTTP " + status);
+                }
                 long total = Math.max(0, c.getContentLengthLong());
                 InputStream in = c.getInputStream();
                 java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(total > 0 ? (int) Math.min(total, 1 << 26) : 1 << 16);
@@ -429,7 +467,9 @@ final class Images {
                         main.post(() -> progress(download, loaded, total));
                     }
                 }
-                return out.toByteArray();
+                byte[] bytes = out.toByteArray();
+                if (disk != null) disk.complete(uri, status, cacheHeaders(c), bytes);
+                return bytes;
             } finally {
                 if (c != null) c.disconnect();
             }
@@ -440,6 +480,23 @@ final class Images {
         }
     }
 
+    /** The headers of {@code c}'s response the disk cache reads, as names and values. */
+    private static String[] cacheHeaders(HttpURLConnection c) {
+        List<String> pairs = new ArrayList<>();
+        for (java.util.Map.Entry<String, List<String>> e : c.getHeaderFields().entrySet()) {
+            if (e.getKey() == null) continue;
+            String name = e.getKey().toLowerCase(java.util.Locale.ROOT);
+            for (String wanted : CACHE_HEADERS) {
+                if (!wanted.equals(name)) continue;
+                for (String value : e.getValue()) {
+                    pairs.add(name);
+                    pairs.add(value);
+                }
+            }
+        }
+        return pairs.toArray(new String[0]);
+    }
+
     private static Image decode(byte[] bytes, int maxPx, int blurPx) {
         BitmapFactory.Options bounds = new BitmapFactory.Options();
         bounds.inJustDecodeBounds = true;
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/InputBox.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/InputBox.java
index c584c2bbeb..fa64908c4c 100644
--- a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/InputBox.java
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/InputBox.java
@@ -54,6 +54,17 @@ final class InputBox extends FrameLayout implements Node {
         this.id = id;
         setBackground(decor);
         edit = new EditText(context) {
+            @Override
+            protected void onAttachedToWindow() {
+                super.onAttachedToWindow();
+                // A detached navigation screen may already have a text layout,
+                // with Editor's selection controllers disabled for lack of a window.
+                // Reapplying the current break strategy discards that layout so the
+                // next measure refreshes the controllers under the attached window,
+                // without replacing editable text or changing textIsSelectable.
+                if (getLayout() != null) setBreakStrategy(getBreakStrategy());
+            }
+
             @Override
             public boolean onKeyUp(int code, android.view.KeyEvent event) {
                 // The up of an Enter whose down submitted another field (focus
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java
index 0c85c9041a..a7f7bed474 100644
--- a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java
@@ -196,6 +196,19 @@ final class Mounter {
             long handle = handle();
             return handle == 0 ? null : Native.imageBytes(handle, uri);
         };
+        images.disk = new Images.DiskCache() {
+            @Override
+            public Object lookup(String url) {
+                long handle = handle();
+                return handle == 0 ? null : Native.imageCacheLookup(handle, url);
+            }
+
+            @Override
+            public byte[] complete(String url, int status, String[] headers, byte[] body) {
+                long handle = handle();
+                return handle == 0 ? null : Native.imageCacheComplete(handle, url, status, headers, body);
+            }
+        };
         root = new Box(context, this, 1);
         views.put(1, root);
     }
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 237a0d34fe..b2c35fe96c 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
@@ -86,8 +86,11 @@ final class Native {
      */
     static native byte[] svg(byte[] bytes, int maxPx, float density, int[] size, float[] intrinsic);
 
-    /** A host whose apps keep their data in {@code dataDir}, each in a directory named by its id. */
-    static native long create(NshView view, float density, boolean trace, String dataDir);
+    /**
+     * A host whose apps keep their data in {@code dataDir} and the images they download in {@code cacheDir} (null:
+     * not on disk), each in a directory named by its id.
+     */
+    static native long create(NshView view, float density, boolean trace, String dataDir, String cacheDir);
 
     static native void destroy(long handle);
 
@@ -103,6 +106,25 @@ 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);
 
+    /**
+     * Where the apps loaded next keep their caches ({@code location}: 0 the system's, 1 {@code dir}, 2 none) and how
+     * much each keeps ({@code images}: 0 its default, 1 {@code imagesMaxMb} megabytes, 2 none).
+     */
+    static native void setCache(long handle, int location, String dir, int images, int imagesMaxMb);
+
+    /**
+     * What the running app's disk cache holds for {@code url}: its bytes ({@code byte[]}) when fresh, the conditional
+     * request headers to revalidate a stale copy with ({@code String[]}, names and values), or null. Any thread.
+     */
+    static native Object imageCacheLookup(long handle, String url);
+
+    /**
+     * Ends a download of {@code url} through the disk cache: {@code status} 0 when the request failed, else the
+     * response's status, {@code headers} (names and values) and body. Returns the cached bytes when they are what to
+     * show (a 304; a stale copy when the request failed), or null. Any thread.
+     */
+    static native byte[] imageCacheComplete(long handle, String url, int status, String[] headers, byte[] body);
+
     /**
      * 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.
diff --git a/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshCacheOptions.java b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshCacheOptions.java
new file mode 100644
index 0000000000..78aa19c397
--- /dev/null
+++ b/third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshCacheOptions.java
@@ -0,0 +1,129 @@
+package com.longbridge.nsh;
+
+import java.io.File;
+
+/**
+ * Where the apps an {@link NshView} loads keep their caches, and how much each
+ * keeps: today the images they download, kept as the server sent them so they
+ * show again without a download (and offline). Immutable: make one with
+ * {@link #builder()}.
+ *
+ * <pre>{@code
+ * nsh.setCacheOptions(NshCacheOptions.builder()
+ *         .location(new File(getCacheDir(), "nsh"))
+ *         .imagesMaxMegabytes(64)
+ *         .build());
+ * }</pre>
+ */
+public final class NshCacheOptions {
+    /** The system's cache directory ({@code nsh} in the Context's), each cache at its default size. */
+    public static final NshCacheOptions DEFAULT = builder().build();
+
+    static final int LOCATION_SYSTEM = 0, LOCATION_DIRECTORY = 1, LOCATION_OFF = 2;
+    static final int POLICY_DEFAULT = 0, POLICY_MAX_SIZE = 1, POLICY_OFF = 2;
+
+    final int location;
+    final File directory;
+    final int images;
+    final int imagesMaxMegabytes;
+
+    private NshCacheOptions(Builder b) {
+        location = b.location;
+        directory = b.directory;
+        images = b.images;
+        imagesMaxMegabytes = b.imagesMaxMegabytes;
+    }
+
+    /** A builder starting from {@link #DEFAULT}. */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    /** A builder starting from these options. */
+    public Builder toBuilder() {
+        Builder b = new Builder();
+        b.location = location;
+        b.directory = directory;
+        b.images = images;
+        b.imagesMaxMegabytes = imagesMaxMegabytes;
+        return b;
+    }
+
+    /** Builds {@link NshCacheOptions}. */
+    public static final class Builder {
+        private int location = LOCATION_SYSTEM;
+        private File directory;
+        private int images = POLICY_DEFAULT;
+        private int imagesMaxMegabytes;
+
+        private Builder() {}
+
+        /**
+         * Keeps the caches in {@code dir}, each app's in {@code <dir>/<app id>}.
+         *
+         * @param dir the directory
+         * @return this builder
+         */
+        public Builder location(File dir) {
+            if (dir == null) throw new IllegalArgumentException("dir");
+            location = LOCATION_DIRECTORY;
+            directory = dir;
+            return this;
+        }
+
+        /**
+         * Keeps the caches in the system's cache directory ({@code nsh} in the Context's): the default.
+         *
+         * @return this builder
+         */
+        public Builder systemLocation() {
+            location = LOCATION_SYSTEM;
+            directory = null;
+            return this;
+        }
+
+        /**
+         * Keeps nothing on disk.
+         *
+         * @return this builder
+         */
+        public Builder noDiskCache() {
+            location = LOCATION_OFF;
+            directory = null;
+            return this;
+        }
+
+        /**
+         * Keeps at most {@code megabytes} MiB of downloaded images per app (256 by default); the least recently used
+         * go first.
+         *
+         * @param megabytes the size in mebibytes
+         * @return this builder
+         */
+        public Builder imagesMaxMegabytes(int megabytes) {
+            if (megabytes < 0) throw new IllegalArgumentException("megabytes");
+            images = POLICY_MAX_SIZE;
+            imagesMaxMegabytes = megabytes;
+            return this;
+        }
+
+        /**
+         * Keeps no downloaded image on disk.
+         *
+         * @return this builder
+         */
+        public Builder imagesOff() {
+            images = POLICY_OFF;
+            return this;
+        }
+
+        /**
+         * The options.
+         *
+         * @return the options
+         */
+        public NshCacheOptions build() {
+            return new NshCacheOptions(this);
+        }
+    }
+}
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 c6d5844e8f..9df170edac 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
@@ -168,11 +168,14 @@ public class NshView extends FrameLayout {
      * @param trace log per-commit timings
      */
     public NshView(Context context, boolean trace) {
-        this(context, trace, new File(context.getFilesDir(), "nsh"));
+        this(context, trace, new File(context.getFilesDir(), "nsh"), new File(context.getCacheDir(), "nsh"));
     }
 
-    /** As {@link #NshView(Context, boolean)}, the apps keeping their data in {@code dataDir}. */
-    NshView(Context context, boolean trace, File dataDir) {
+    /**
+     * As {@link #NshView(Context, boolean)}, the apps keeping their data in {@code dataDir} and the images they
+     * download in {@code cacheDir}.
+     */
+    NshView(Context context, boolean trace, File dataDir, File cacheDir) {
         super(context);
         density = getResources().getDisplayMetrics().density;
         TextLayouts.cacheDir = context.getCacheDir();
@@ -197,7 +200,9 @@ public class NshView extends FrameLayout {
         addOnUnhandledKeyEventListener((v, e) -> onBackKey(e));
         // Apps keep their data in <dataDir>/<app id>: nsh in the files
         // directory unless the development host's test session says.
-        handle = Native.create(this, density, trace, dataDir.getPath());
+        // ... and the images they download in <cacheDir>/<app id>/images: nsh
+        // in the cache directory.
+        handle = Native.create(this, density, trace, dataDir.getPath(), cacheDir.getPath());
     }
 
     /**
@@ -255,6 +260,20 @@ public class NshView extends FrameLayout {
         Native.setStorage(handle, dir == null ? null : dir.getPath());
     }
 
+    /**
+     * Where the apps this view loads from now on keep their caches (the
+     * images they download, so they show again without a download and
+     * offline) and how much each keeps. The default
+     * ({@code NshCacheOptions.DEFAULT}): {@code nsh} in the Context's cache
+     * directory, each app in {@code <dir>/<app id>}, 256 MiB of images.
+     *
+     * @param options the options
+     */
+    public void setCacheOptions(NshCacheOptions options) {
+        Native.setCache(handle, options.location, options.directory == null ? null : options.directory.getPath(),
+                options.images, options.imagesMaxMegabytes);
+    }
+
     /**
      * Overrides the app's theme with the host's: a theme file of shadcn/ui's
      * ({@code { "cssVars": { "theme": {…}, "light": {…}, "dark": {…} } }},
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 aaa2e67069..58b3a83bef 100644
--- a/third-party/nsh/crates/nsh-platform-android/src/host.rs
+++ b/third-party/nsh/crates/nsh-platform-android/src/host.rs
@@ -13,7 +13,7 @@ use std::{
 
 use jni::{
     JNIEnv,
-    objects::{GlobalRef, JByteArray, JClass, JFloatArray, JObject, JString, JValue},
+    objects::{GlobalRef, JByteArray, JClass, JFloatArray, JObject, JObjectArray, JString, JValue},
     signature::{Primitive, ReturnType},
     sys::{jboolean, jfloat, jfloatArray, jint, jlong},
 };
@@ -70,6 +70,14 @@ pub(crate) struct Host {
     /// 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>,
+    /// Where loaded apps keep their caches (`NshView.setCacheOptions`).
+    cache: Mutex<nsh_engine::CacheOptions>,
+    /// What [`nsh_engine::CacheLocation::System`] is here: `nsh` in the
+    /// Context's cache directory (only the platform knows it).
+    system_cache_dir: Option<std::path::PathBuf>,
+    /// The running app's disk cache of downloaded images, for
+    /// `Images.java` (`Native.imageCacheLookup` / `imageCacheComplete`).
+    image_cache: Mutex<Option<nsh_core::image::DiskCache>>,
     /// The embedding app's channel with the apps (`nsh/host`; Java
     /// `NshView.setParams`, `send`, `handle`).
     host: nsh_engine::HostChannel,
@@ -156,6 +164,13 @@ impl Host {
             .unwrap_or_else(|e| e.into_inner())
             .clear();
         *self.ui.lock().expect("ui") = UiAnimation::default();
+        let mut cache = self.cache.lock().expect("cache options").clone();
+        if cache.location == nsh_engine::CacheLocation::System {
+            cache.location = match &self.system_cache_dir {
+                Some(dir) => nsh_engine::CacheLocation::Dir(dir.clone()),
+                None => nsh_engine::CacheLocation::Off,
+            };
+        }
         #[allow(unused_mut)]
         let mut config = EngineConfig {
             app,
@@ -172,6 +187,7 @@ impl Host {
             ui_sets: crate::ui_sets(),
             runtime: None,
             data_dir: self.data_dir.clone(),
+            cache,
             storage: self.storage.lock().expect("storage").as_ref().map(|dir| {
                 nsh_engine::FileStorage::open(dir) as Arc<dyn nsh_engine::StorageBackend>
             }),
@@ -198,6 +214,8 @@ impl Host {
         };
         match Engine::start_with(config, move || AndroidTextEngine { density }, sink, setup) {
             Ok(engine) => {
+                // The app's downloaded images go through its disk cache.
+                *self.image_cache.lock().expect("image cache") = engine.image_cache();
                 // The test moves its clock through the engine.
                 #[cfg(feature = "dev")]
                 if let Some(model) = &*self.test_model.lock().unwrap_or_else(|e| e.into_inner()) {
@@ -502,6 +520,7 @@ extern "system" fn Java_com_longbridge_nsh_Native_create(
     density: jfloat,
     trace: jboolean,
     data_dir: JString<'_>,
+    cache_dir: JString<'_>,
 ) -> jlong {
     let Ok(view) = env.new_global_ref(view) else {
         return 0;
@@ -510,6 +529,10 @@ extern "system" fn Java_com_longbridge_nsh_Native_create(
         .get_string(&data_dir)
         .ok()
         .map(|dir| std::path::PathBuf::from(String::from(dir)));
+    let cache_dir = (!cache_dir.is_null())
+        .then(|| env.get_string(&cache_dir).ok())
+        .flatten()
+        .map(|dir| std::path::PathBuf::from(String::from(dir)));
     let host = Arc::new(Host {
         view,
         density,
@@ -530,6 +553,9 @@ extern "system" fn Java_com_longbridge_nsh_Native_create(
         access: Mutex::new(Arc::default()),
         host_tokens: Mutex::new(None),
         inline_images: Mutex::default(),
+        cache: Mutex::default(),
+        system_cache_dir: cache_dir,
+        image_cache: Mutex::new(None),
         host: nsh_engine::HostChannel::new(),
         keymap: Mutex::default(),
         #[cfg(feature = "dev")]
@@ -633,6 +659,152 @@ extern "system" fn Java_com_longbridge_nsh_Native_setStorage(
     *host.storage.lock().expect("storage") = dir;
 }
 
+/// Where the apps loaded next keep their caches and how much each keeps
+/// (`NshView.setCacheOptions`): `location` 0 the system's (`nsh` in the
+/// Context's cache directory), 1 `dir`, 2 none; `images` 0 its default
+/// size, 1 `images_max_mb` megabytes, 2 none.
+#[unsafe(no_mangle)]
+extern "system" fn Java_com_longbridge_nsh_Native_setCache(
+    mut env: JNIEnv<'_>,
+    _: JClass<'_>,
+    handle: jlong,
+    location: jint,
+    dir: JString<'_>,
+    images: jint,
+    images_max_mb: jint,
+) {
+    use nsh_engine::{ByteSize, CacheLocation, CacheOptions, CachePolicy};
+    // SAFETY: Java passes its live handle.
+    let host = unsafe { host(handle) };
+    let location = match location {
+        0 => CacheLocation::System,
+        1 => match (!dir.is_null()).then(|| env.get_string(&dir)) {
+            Some(Ok(dir)) => CacheLocation::Dir(String::from(dir).into()),
+            _ => return log::error!("invalid cache directory"),
+        },
+        _ => CacheLocation::Off,
+    };
+    let images = match images {
+        0 => CachePolicy::Default,
+        1 => CachePolicy::MaxSize(ByteSize::mib(images_max_mb.max(0) as u64)),
+        _ => CachePolicy::Off,
+    };
+    *host.cache.lock().expect("cache options") = CacheOptions::at(location).with_images(images);
+}
+
+/// What the running app's disk cache holds for `url`: its bytes (`byte[]`)
+/// when they are fresh, the conditional request headers to revalidate a
+/// stale copy with (`String[]`, names and values; empty: none), or null (a
+/// miss, or no disk cache). Called on the image threads.
+#[unsafe(no_mangle)]
+extern "system" fn Java_com_longbridge_nsh_Native_imageCacheLookup<'local>(
+    mut env: JNIEnv<'local>,
+    _: JClass<'local>,
+    handle: jlong,
+    url: JString<'local>,
+) -> JObject<'local> {
+    use nsh_core::image::Lookup;
+    // SAFETY: Java passes its live handle.
+    let host = unsafe { host(handle) };
+    let Some(cache) = host.image_cache.lock().expect("image cache").clone() else {
+        return JObject::null();
+    };
+    let Ok(url) = env.get_string(&url).map(String::from) else {
+        return JObject::null();
+    };
+    match cache.lookup(&url) {
+        Lookup::Fresh(bytes) => env
+            .byte_array_from_slice(&bytes)
+            .map(JObject::from)
+            .unwrap_or_else(|_| JObject::null()),
+        lookup @ Lookup::Stale(_) => {
+            let headers = lookup.request_headers();
+            let strings: Vec<&str> = headers
+                .iter()
+                .flat_map(|(name, value)| [*name, value.as_str()])
+                .collect();
+            string_array(&mut env, &strings).unwrap_or_else(|| JObject::null())
+        }
+        Lookup::Miss => JObject::null(),
+    }
+}
+
+/// `strings` as a Java `String[]`.
+fn string_array<'local>(env: &mut JNIEnv<'local>, strings: &[&str]) -> Option<JObject<'local>> {
+    let array = env
+        .new_object_array(strings.len() as i32, "java/lang/String", JObject::null())
+        .ok()?;
+    for (i, s) in strings.iter().enumerate() {
+        let s = env.new_string(s).ok()?;
+        env.set_object_array_element(&array, i as i32, &s).ok()?;
+    }
+    Some(array.into())
+}
+
+/// Ends the download of `url` that `imageCacheLookup` started: `status` 0
+/// when the request failed, else the response's status, `headers` (names
+/// and values) and body (null without one). Stores, updates or drops the
+/// cached copy, and returns the cached bytes when they are what to show
+/// (a `304`; a stale copy when the request failed or the server erred), or
+/// null: the response's own body (a `2xx`), or an error. Called on the
+/// image threads.
+#[unsafe(no_mangle)]
+extern "system" fn Java_com_longbridge_nsh_Native_imageCacheComplete<'local>(
+    mut env: JNIEnv<'local>,
+    _: JClass<'local>,
+    handle: jlong,
+    url: JString<'local>,
+    status: jint,
+    headers: JObjectArray<'local>,
+    body: JByteArray<'local>,
+) -> JByteArray<'local> {
+    // SAFETY: Java passes its live handle.
+    let host = unsafe { host(handle) };
+    let Some(cache) = host.image_cache.lock().expect("image cache").clone() else {
+        return JByteArray::default();
+    };
+    let Ok(url) = env.get_string(&url).map(String::from) else {
+        return JByteArray::default();
+    };
+    let response = if status <= 0 {
+        Err("the request failed".to_owned())
+    } else {
+        let mut strings = Vec::new();
+        if !headers.is_null() {
+            let len = env.get_array_length(&headers).unwrap_or(0);
+            for i in 0..len {
+                let value = env
+                    .get_object_array_element(&headers, i)
+                    .ok()
+                    .filter(|o| !o.is_null())
+                    .and_then(|o| env.get_string(&JString::from(o)).ok().map(String::from));
+                strings.push(value.unwrap_or_default());
+            }
+        }
+        let pairs = strings
+            .chunks_exact(2)
+            .map(|pair| (pair[0].as_str(), pair[1].as_str()));
+        let body = if body.is_null() {
+            Vec::new()
+        } else {
+            env.convert_byte_array(&body).unwrap_or_default()
+        };
+        Ok(nsh_core::image::Fetched::new(
+            status as u16,
+            nsh_core::image::CacheHeaders::from_pairs(pairs),
+            body,
+        ))
+    };
+    // A 2xx's bytes are the body Java has.
+    let own = (200..300).contains(&status);
+    match cache.complete(&url, response) {
+        Ok(bytes) if !own => env
+            .byte_array_from_slice(&bytes)
+            .unwrap_or_else(|_| JByteArray::default()),
+        _ => JByteArray::default(),
+    }
+}
+
 /// 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
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 060cab9930..f162d2740d 100644
--- a/third-party/nsh/crates/nsh-platform-ios/include/nsh.h
+++ b/third-party/nsh/crates/nsh-platform-ios/include/nsh.h
@@ -58,6 +58,47 @@ void nsh_app_set_limits(NshApp *app, uint32_t memory_mb, uint32_t stack_kb, uint
 /// or NULL.
 char *_Nullable nsh_app_set_storage_directory(NshApp *app, const char *_Nullable path);
 
+/// Where an app's caches are kept (`NshCacheOptions.location`).
+enum {
+    /// The system's cache directory: `nsh` in the app's Caches.
+    NSH_CACHE_LOCATION_SYSTEM = 0,
+    /// `NshCacheOptions.directory`.
+    NSH_CACHE_LOCATION_DIRECTORY = 1,
+    /// Nothing on disk.
+    NSH_CACHE_LOCATION_OFF = 2,
+};
+
+/// How much one cache keeps (`NshCacheOptions.images`).
+enum {
+    /// Its default size (images: 256 MiB).
+    NSH_CACHE_POLICY_DEFAULT = 0,
+    /// At most its `_max_mb` megabytes (MiB).
+    NSH_CACHE_POLICY_MAX_SIZE = 1,
+    /// Not kept on disk.
+    NSH_CACHE_POLICY_OFF = 2,
+};
+
+/// Where the apps keep their caches and how much each keeps. Zeroed, it is
+/// the default (the system's directory, default sizes). Set `size` to
+/// `sizeof(NshCacheOptions)`: fields added later read as zero for callers
+/// built against this header.
+typedef struct {
+    uint32_t size;
+    /// `NSH_CACHE_LOCATION_*`.
+    uint32_t location;
+    /// For `NSH_CACHE_LOCATION_DIRECTORY`: each app's caches in
+    /// `<directory>/<app id>`.
+    const char *_Nullable directory;
+    /// `NSH_CACHE_POLICY_*` of the images the apps download.
+    uint32_t images;
+    /// For `NSH_CACHE_POLICY_MAX_SIZE`.
+    uint32_t images_max_mb;
+} NshCacheOptions;
+
+/// Where the apps `app` loads keep their caches and how much each keeps.
+/// Applies from the next load. Returns why `options` are invalid, or NULL.
+char *_Nullable nsh_app_set_cache(NshApp *app, const NshCacheOptions *options);
+
 /// The embedding app's locale, standing in for the user's: a BCP 47 tag or a
 /// platform identifier (`zh-Hans-CN`, `en_US`); NULL follows the system's
 /// again. Applies at once and to every app loaded later. Returns why `tag` is
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/app.rs b/third-party/nsh/crates/nsh-platform-ios/src/app.rs
index c3ea409795..2666d62bf3 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/app.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/app.rs
@@ -50,6 +50,9 @@ pub struct AppOptions {
     /// `None` uses the app's own data directory
     /// ([`SurfaceOptions::data_dir`]'s default).
     pub data_dir: Option<std::path::PathBuf>,
+    /// Where the apps keep their caches (the images they downloaded) and
+    /// how much each keeps ([`SurfaceOptions::cache`]).
+    pub cache: nsh_engine::CacheOptions,
 }
 
 /// Whether `path` is inside the app's own bundle (which the system verifies
@@ -192,6 +195,7 @@ fn connect(mtm: MainThreadMarker, scene: &UIWindowScene) {
             data_dir: options
                 .data_dir
                 .or_else(|| SurfaceOptions::default().data_dir),
+            cache: options.cache,
             // An app on its own (the development host, the release runner),
             // not a module embedded in another app.
             limits: nsh_engine::Limits::standalone(),
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 2b123a3b5d..1902ebe66c 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/embed.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/embed.rs
@@ -254,6 +254,98 @@ pub unsafe extern "C" fn nsh_app_set_storage_directory(
     status(dir.map(|dir| app.view.set_storage_directory(dir)))
 }
 
+/// [`NshCacheOptions::location`]: the system's cache directory (`nsh` in
+/// the app's Caches).
+pub const NSH_CACHE_LOCATION_SYSTEM: u32 = 0;
+/// [`NshCacheOptions::location`]: [`NshCacheOptions::directory`].
+pub const NSH_CACHE_LOCATION_DIRECTORY: u32 = 1;
+/// [`NshCacheOptions::location`]: nothing on disk.
+pub const NSH_CACHE_LOCATION_OFF: u32 = 2;
+/// A cache's policy ([`NshCacheOptions::images`]): its default size.
+pub const NSH_CACHE_POLICY_DEFAULT: u32 = 0;
+/// A cache's policy: at most its `_max_mb` megabytes.
+pub const NSH_CACHE_POLICY_MAX_SIZE: u32 = 1;
+/// A cache's policy: not kept on disk.
+pub const NSH_CACHE_POLICY_OFF: u32 = 2;
+
+/// Where the apps an `NshApp` loads keep their caches and how much each
+/// keeps (`nsh.h`'s `NshCacheOptions`). `size` is the struct's size as the
+/// caller knows it: fields past it read as zero (their defaults), so the
+/// struct can grow.
+#[repr(C)]
+#[derive(Debug, Clone, Copy)]
+pub struct NshCacheOptions {
+    /// `sizeof(NshCacheOptions)`.
+    pub size: u32,
+    /// `NSH_CACHE_LOCATION_*`.
+    pub location: u32,
+    /// The directory, for `NSH_CACHE_LOCATION_DIRECTORY`: NUL-terminated
+    /// UTF-8.
+    pub directory: *const c_char,
+    /// `NSH_CACHE_POLICY_*` of the images the apps download.
+    pub images: u32,
+    /// Megabytes (MiB) of images kept, for `NSH_CACHE_POLICY_MAX_SIZE`.
+    pub images_max_mb: u32,
+}
+
+/// Where the apps `app` loads keep their caches and how much each keeps.
+/// Applies from the next load. Returns why `options` are invalid (nothing
+/// changes), or `NULL`.
+///
+/// # Safety
+///
+/// `app` must come from [`nsh_app_create`]; `options` must point to at
+/// least `options->size` readable bytes (and at least its `size` field),
+/// its `directory` `NULL` or a NUL-terminated string.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn nsh_app_set_cache(
+    app: *mut NshApp,
+    options: *const NshCacheOptions,
+) -> *mut c_char {
+    use nsh_engine::{ByteSize, CacheLocation, CacheOptions, CachePolicy};
+    // SAFETY: guaranteed by the caller.
+    let app = unsafe { &*app };
+    if options.is_null() {
+        return status(Err("no cache options".to_owned()));
+    }
+    // Read what the caller's struct has; the rest stays zero (defaults).
+    // SAFETY: `options` points to at least its `size` field.
+    let given = unsafe { (*options).size } as usize;
+    let mut read = std::mem::MaybeUninit::<NshCacheOptions>::zeroed();
+    let len = given.min(std::mem::size_of::<NshCacheOptions>());
+    // SAFETY: the caller's struct has `len` readable bytes; `read` is as
+    // large; all-zero bytes are a valid `NshCacheOptions`.
+    let options = unsafe {
+        std::ptr::copy_nonoverlapping(options as *const u8, read.as_mut_ptr() as *mut u8, len);
+        read.assume_init()
+    };
+    let location = match options.location {
+        NSH_CACHE_LOCATION_SYSTEM => Ok(CacheLocation::System),
+        NSH_CACHE_LOCATION_DIRECTORY if options.directory.is_null() => {
+            Err("NSH_CACHE_LOCATION_DIRECTORY without a directory".to_owned())
+        }
+        // SAFETY: guaranteed by the caller.
+        NSH_CACHE_LOCATION_DIRECTORY => unsafe { utf8(options.directory, "the cache directory") }
+            .map(|dir| CacheLocation::Dir(PathBuf::from(dir))),
+        NSH_CACHE_LOCATION_OFF => Ok(CacheLocation::Off),
+        other => Err(format!("unknown cache location {other}")),
+    };
+    let images = match options.images {
+        NSH_CACHE_POLICY_DEFAULT => Ok(CachePolicy::Default),
+        NSH_CACHE_POLICY_MAX_SIZE => Ok(CachePolicy::MaxSize(ByteSize::mib(u64::from(
+            options.images_max_mb,
+        )))),
+        NSH_CACHE_POLICY_OFF => Ok(CachePolicy::Off),
+        other => Err(format!("unknown cache policy {other}")),
+    };
+    status(location.and_then(|location| {
+        images.map(|images| {
+            app.view
+                .set_cache(CacheOptions::at(location).with_images(images))
+        })
+    }))
+}
+
 /// The embedding app's locale, which stands in for the user's: a BCP 47
 /// tag or a platform identifier (`zh-Hans-CN`, `en_US`); `NULL` follows the
 /// system's again. Applies at once and to every app loaded later. Returns
diff --git a/third-party/nsh/crates/nsh-platform-ios/src/image.rs b/third-party/nsh/crates/nsh-platform-ios/src/image.rs
index 0ef4d348af..7655d3d881 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/image.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/image.rs
@@ -4,7 +4,10 @@
 //! requested pixel size, cache decoded images in an `NSCache` (purged under
 //! memory pressure), coalesce concurrent requests and cancel downloads
 //! nobody waits for. `Image.prefetch` / `getSize` keep the bytes they
-//! fetched for the views that show them next.
+//! fetched for the views that show them next. Remote images go through the
+//! surface's disk cache ([`nsh_core::image::DiskCache`]): it is read on a
+//! background queue, and a download starts (revalidating a stale copy)
+//! only when it holds no fresh one.
 //!
 //! SVG images (sniffed from their bytes) are drawn by [`nsh_core::svg`] at
 //! the requested pixel size instead, then blurred, filtered, tinted and
@@ -15,6 +18,7 @@ use dispatch2::{DispatchQueue, DispatchQueueGlobalPriority, GlobalQueueIdentifie
 use nsh_core::{
     ImageData, NodeId,
     geometry::{Points, Size},
+    image::{CacheHeaders, DiskCache, Fetched, Lookup},
 };
 use objc2::{
     AnyThread, DefinedClass, Message, define_class, msg_send, rc::Retained, runtime::ProtocolObject,
@@ -24,10 +28,10 @@ use objc2_core_foundation::{
 };
 use objc2_core_graphics::CGImage;
 use objc2_foundation::{
-    NSCache, NSData, NSDataReadingOptions, NSError, NSHTTPURLResponse, NSObject, NSObjectProtocol,
-    NSString, NSURL, NSURLRequest, NSURLResponse, NSURLSession, NSURLSessionDataDelegate,
-    NSURLSessionDataTask, NSURLSessionDelegate, NSURLSessionResponseDisposition, NSURLSessionTask,
-    NSURLSessionTaskDelegate,
+    NSCache, NSData, NSDataReadingOptions, NSError, NSHTTPURLResponse, NSMutableURLRequest,
+    NSObject, NSObjectProtocol, NSString, NSURL, NSURLRequest, NSURLResponse, NSURLSession,
+    NSURLSessionDataDelegate, NSURLSessionDataTask, NSURLSessionDelegate,
+    NSURLSessionResponseDisposition, NSURLSessionTask, NSURLSessionTaskDelegate,
 };
 use objc2_image_io::{
     CGImageSource, kCGImageSourceCreateThumbnailFromImageAlways,
@@ -142,6 +146,13 @@ pub(crate) enum LoadEvent {
         id: u32,
         result: Result<(u32, u32), String>,
     },
+    /// The disk cache holds no fresh copy of `uri`: download it with
+    /// `headers` (a stale copy's validators), passed back to
+    /// [`ImageLoader::download`].
+    Download {
+        uri: String,
+        headers: Vec<(&'static str, String)>,
+    },
 }
 
 // SAFETY: NSData is immutable and thread-safe; the rest is plain data.
@@ -195,6 +206,8 @@ pub(crate) struct ImageLoader {
     notify: Notify,
     /// What the app may load, checked at every redirect.
     access: std::sync::Arc<nsh_engine::Access>,
+    /// The disk cache remote images go through.
+    disk: Option<DiskCache>,
 }
 
 impl ImageLoader {
@@ -220,9 +233,15 @@ impl ImageLoader {
             asset_base,
             notify,
             access,
+            disk: None,
         }
     }
 
+    /// The disk cache remote images go through.
+    pub(crate) fn set_disk_cache(&mut self, disk: Option<DiskCache>) {
+        self.disk = disk;
+    }
+
     /// Cache key: the same URI decoded at different sizes (or blurred or
     /// filtered differently) is a different image.
     pub(crate) fn key(uri: &str, max_pixels: u32, look: Look) -> String {
@@ -313,10 +332,37 @@ impl ImageLoader {
             return;
         }
         let notify = self.notify.clone();
+        let remote = uri.starts_with("http://") || uri.starts_with("https://");
         let task = match self.resolve(uri) {
+            // The disk cache is read off the main thread; the download
+            // starts from there (`LoadEvent::Download`) unless it holds a
+            // fresh copy.
+            Some(Source::Url(_)) if remote && self.disk.is_some() => {
+                let cache = self.disk.clone().expect("a disk cache");
+                let uri = uri.to_owned();
+                DispatchQueue::global_queue(GlobalQueueIdentifier::Priority(
+                    DispatchQueueGlobalPriority::Default,
+                ))
+                .exec_async(move || {
+                    let event = match cache.lookup(&uri) {
+                        Lookup::Fresh(bytes) => LoadEvent::Bytes {
+                            uri,
+                            result: Ok(ns_data(&bytes)),
+                        },
+                        lookup => LoadEvent::Download {
+                            headers: lookup.request_headers(),
+                            uri,
+                        },
+                    };
+                    on_main(&notify, event);
+                });
+                None
+            }
             Some(Source::Url(url)) => Some(crate::image::download(
                 &url,
                 uri.to_owned(),
+                &[],
+                None,
                 notify,
                 self.access.clone(),
             )),
@@ -366,6 +412,31 @@ impl ImageLoader {
         );
     }
 
+    /// Downloads `uri` through the disk cache, with `headers` (main thread),
+    /// unless nobody waits for it any more.
+    pub(crate) fn download(&mut self, uri: &str, headers: &[(&'static str, String)]) {
+        // Gone (nobody waits), or started by an earlier lookup of a request
+        // made again.
+        if self.downloads.get(uri).is_none_or(|d| d.task.is_some()) {
+            return;
+        }
+        let Some(url) = url(uri) else {
+            self.bytes_ready(uri, Err(format!("bad URL {uri}")));
+            return;
+        };
+        let task = crate::image::download(
+            &url,
+            uri.to_owned(),
+            headers,
+            self.disk.clone(),
+            self.notify.clone(),
+            self.access.clone(),
+        );
+        if let Some(download) = self.downloads.get_mut(uri) {
+            download.task = Some(task);
+        }
+    }
+
     /// The bytes of a download arrived (main thread).
     pub(crate) fn bytes_ready(&mut self, uri: &str, result: Result<Retained<NSData>, String>) {
         let Some(download) = self.downloads.remove(uri) else {
@@ -613,6 +684,8 @@ struct DownloadIvars {
     uri: String,
     notify: Notify,
     access: std::sync::Arc<nsh_engine::Access>,
+    /// The disk cache it goes through.
+    cache: Option<DiskCache>,
     data: std::sync::Mutex<DownloadState>,
 }
 
@@ -621,6 +694,7 @@ struct DownloadState {
     bytes: Vec<u8>,
     total: u64,
     status: isize,
+    headers: CacheHeaders,
     reported: Option<std::time::Instant>,
 }
 
@@ -672,13 +746,26 @@ define_class!(
         ) {
             let ivars = self.ivars();
             let state = std::mem::take(&mut *ivars.data.lock().expect("download"));
-            let result = match error {
-                Some(e) => Err(e.localizedDescription().to_string()),
-                None if state.status >= 400 => Err(format!("HTTP {}", state.status)),
-                None if (300..400).contains(&state.status) => {
+            let result = match (&ivars.cache, error) {
+                // Delegates run on the session's queue, off the main
+                // thread: the cache's file IO is done here.
+                (Some(cache), error) => {
+                    let response = match error {
+                        Some(e) => Err(e.localizedDescription().to_string()),
+                        None => Ok(Fetched::new(
+                            state.status as u16,
+                            state.headers,
+                            state.bytes,
+                        )),
+                    };
+                    cache.complete(&ivars.uri, response).map(|b| ns_data(&b))
+                }
+                (None, Some(e)) => Err(e.localizedDescription().to_string()),
+                (None, None) if state.status >= 400 => Err(format!("HTTP {}", state.status)),
+                (None, None) if (300..400).contains(&state.status) => {
                     Err(format!("redirect refused (HTTP {})", state.status))
                 }
-                None => Ok(NSData::with_bytes(&state.bytes)),
+                (None, None) => Ok(NSData::from_vec(state.bytes)),
             };
             on_main(
                 &ivars.notify,
@@ -703,6 +790,9 @@ define_class!(
             state.total = response.expectedContentLength().max(0) as u64;
             if let Some(http) = response.downcast_ref::<NSHTTPURLResponse>() {
                 state.status = http.statusCode();
+                if self.ivars().cache.is_some() {
+                    state.headers = cache_headers(http);
+                }
             }
             drop(state);
             completion.call((NSURLSessionResponseDisposition::Allow,));
@@ -738,22 +828,57 @@ define_class!(
     }
 );
 
-/// Downloads `url`, reporting progress and then [`LoadEvent::Bytes`].
+/// The caching headers of `response`.
+fn cache_headers(response: &NSHTTPURLResponse) -> CacheHeaders {
+    let (keys, values) = response.allHeaderFields().to_vecs();
+    let pairs: Vec<(String, String)> = keys
+        .iter()
+        .zip(values.iter())
+        .filter_map(|(k, v)| {
+            let k = k.downcast_ref::<NSString>()?;
+            let v = v.downcast_ref::<NSString>()?;
+            Some((k.to_string(), v.to_string()))
+        })
+        .collect();
+    CacheHeaders::from_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
+}
+
+/// Downloads `url` with `headers` (a revalidation's conditions), through
+/// `cache` when there is one, reporting progress and then
+/// [`LoadEvent::Bytes`].
 fn download(
     url: &NSURL,
     uri: String,
+    headers: &[(&'static str, String)],
+    cache: Option<DiskCache>,
     notify: Notify,
     access: std::sync::Arc<nsh_engine::Access>,
 ) -> Retained<NSURLSessionDataTask> {
+    let cached = cache.is_some();
     let delegate = ImageDownload::alloc().set_ivars(DownloadIvars {
         uri,
         notify,
         access,
+        cache,
         data: std::sync::Mutex::new(DownloadState::default()),
     });
     // SAFETY: NSObject's designated initializer.
     let delegate: Retained<ImageDownload> = unsafe { msg_send![super(delegate), init] };
-    let task = NSURLSession::sharedSession().dataTaskWithURL(url);
+    let request = NSMutableURLRequest::requestWithURL(url);
+    if cached {
+        // The disk cache answers for these: the shared URL cache must not
+        // serve a copy it never saw (nor hide a revalidation's 304).
+        request.setCachePolicy(
+            objc2_foundation::NSURLRequestCachePolicy::ReloadIgnoringLocalCacheData,
+        );
+    }
+    for (name, value) in headers {
+        request.setValue_forHTTPHeaderField(
+            Some(&NSString::from_str(value)),
+            &NSString::from_str(name),
+        );
+    }
+    let task = NSURLSession::sharedSession().dataTaskWithRequest(&request);
     // The task keeps its delegate until it completes.
     task.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
     task.resume();
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 3085e2d8e4..256fb45e95 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/lib.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/lib.rs
@@ -53,7 +53,7 @@ mod visual;
 mod web;
 mod zoom;
 
-pub use nsh_engine::{Bundle, Root};
+pub use nsh_engine::{Bundle, ByteSize, CacheLocation, CacheOptions, CachePolicy, Root};
 
 /// The UI sets apps import ([`set_ui_sets`]; by default `nsh/base`).
 static UI_SETS: std::sync::RwLock<Option<Vec<nsh_core::ui::Registry>>> =
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 3867bfe9d1..0a8aad907c 100644
--- a/third-party/nsh/crates/nsh-platform-ios/src/mount.rs
+++ b/third-party/nsh/crates/nsh-platform-ios/src/mount.rs
@@ -2134,11 +2134,17 @@ impl Mounter {
     }
 
     /// What the image loader reports.
+    /// The disk cache the images it loads go through.
+    pub(crate) fn set_image_cache(&mut self, cache: Option<nsh_core::image::DiskCache>) {
+        self.images.set_disk_cache(cache);
+    }
+
     pub(crate) fn load_event(&mut self, event: crate::image::LoadEvent) {
         use crate::image::LoadEvent;
         match event {
             LoadEvent::Decoded { key, result } => self.image_loaded(&key, result),
             LoadEvent::Bytes { uri, result } => self.images.bytes_ready(&uri, result),
+            LoadEvent::Download { uri, headers } => self.images.download(&uri, &headers),
             LoadEvent::Progress { uri, loaded, total } => {
                 for id in self.images.waiting(&uri) {
                     let Some(s) = self.slot(id) else { continue };
@@ -2544,10 +2550,14 @@ impl Mounter {
             else {
                 continue;
             };
-            screen.apply_header(|item| match self.slot(item).map(|s| &s.native) {
-                Some(Native::HeaderItem(v)) => Some(Retained::into_super(v.clone())),
-                _ => None,
-            });
+            let press = Some(self.shared.clone());
+            screen.apply_header(
+                |item| match self.slot(item).map(|s| &s.native) {
+                    Some(Native::HeaderItem(v)) => Some(Retained::into_super(v.clone())),
+                    _ => None,
+                },
+                press,
+            );
             if screen.config.large_title {
                 // Large titles collapse as the content scrolls under them.
                 if let Some(scroll) = first_scroll_view(&screen.view) {
@@ -2822,6 +2832,45 @@ impl Mounter {
         }
     }
 
+    /// Presses the element the app put in the trailing header slot, as a tap
... 8881 lines suppressed ...


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