diff --git a/AGENTS.md b/AGENTS.md
index 2d39da2a..77e2a376 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -104,9 +104,9 @@ paraphrases were how the old checkbox contract created near-duplicates. Full con
scripts/local-renderer.sh check -p az-gui --features blitz-runtime
```
- It used to be a block inside `.cargo/config.toml`, defended by
- `git update-index --skip-worktree`. That failed four ways at once. The file is
- tracked, because it carries the macOS link flags and the `usvg` patch every
+ It used to be a block inside a tracked `.cargo/config.toml`, defended by
+ `git update-index --skip-worktree`. That failed four ways at once. The file was
+ tracked, because it carried the macOS link flag and the `usvg` patch every
build needs, so `.gitignore` could not protect it and the paths went in four
times. `skip-worktree` hid the file from `git status` as well and blocked
branch switches. The redirect was always on, so no ordinary build ever fetched
@@ -115,15 +115,18 @@ paraphrases were how the old checkbox contract created near-duplicates. Full con
`Cargo.lock`, replacing git revisions with paths, which reached a commit twice.
The wrapper snapshots and restores the lockfile around the build, so none of
- that is left to remember. `.cargo/config.toml` needs no flag now: its working
- copy should equal its commit, and `git status` will say so if it does not.
-
- It is also down to one setting, the macOS linker flag, which is the only thing
- in it that cannot live in a manifest. The usvg patch moved to the root
- `Cargo.toml`, where patches belong and where ps-blitz keeps the same one. That
- it ever sat in `.cargo/config.toml` is the whole story: a shared patch shared a
- `[patch.crates-io]` table with paths that existed on one machine, which is how
- a file nobody could delete became a file nobody could safely commit.
+ that is left to remember.
+
+ **There is no `.cargo/config.toml` any more**, and nothing should recreate one:
+ `.cargo/` holds only the gitignored `local-renderer.toml`. Both of its former
+ settings found a better home. The usvg patch moved to the root `Cargo.toml`,
+ where patches belong and where ps-blitz keeps the same one. The macOS linker
+ flag moved into [`apps/gui/build.rs`](apps/gui/build.rs), which emits
+ `cargo::rustc-link-arg-bins` for the one binary that wants it rather than for
+ every crate in the workspace. That the two ever shared a file is the whole
+ story: a shared patch shared a `[patch.crates-io]` table with paths that
+ existed on one machine, which is how a file nobody could delete became a file
+ nobody could safely commit.
Pins still want checking before a release, because the opt-in path skips them
by definition. `scripts/check-one-rev-per-git-source.sh` refuses a lockfile
diff --git a/Cargo.lock b/Cargo.lock
index 8bc21bf8..b66d1dc9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -72,7 +72,7 @@ dependencies = [
[[package]]
name = "agency-tools"
-version = "0.8.2"
+version = "0.8.4"
dependencies = [
"derive_more 2.1.1",
"dirs",
@@ -364,11 +364,11 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "az-core"
-version = "0.8.2"
+version = "0.8.4"
[[package]]
name = "az-gui"
-version = "0.8.2"
+version = "0.8.4"
dependencies = [
"agency-proxy-client",
"agency-proxy-protocol",
@@ -407,7 +407,7 @@ dependencies = [
[[package]]
name = "az-mcp-proxy"
-version = "0.8.2"
+version = "0.8.4"
dependencies = [
"az-core",
]
@@ -498,7 +498,7 @@ dependencies = [
[[package]]
name = "blitz-bench"
-version = "0.8.2"
+version = "0.8.4"
dependencies = [
"blitz-control-protocol",
"endpoint-libs",
@@ -511,7 +511,7 @@ dependencies = [
[[package]]
name = "blitz-control-protocol"
version = "0.1.0"
-source = "git+https://github.com/pathscale/tauri-runtime-blitz.git?rev=070ab3a2fd460d7c9b2f3f341e7d6c50af669682#070ab3a2fd460d7c9b2f3f341e7d6c50af669682"
+source = "git+https://github.com/pathscale/tauri-runtime-blitz.git?rev=9edbec23ce4ca9bcb052a364577fd9a736fd81a3#9edbec23ce4ca9bcb052a364577fd9a736fd81a3"
dependencies = [
"endpoint-libs",
"serde",
@@ -521,7 +521,7 @@ dependencies = [
[[package]]
name = "blitz-html"
version = "0.3.0-beta.3"
-source = "git+https://github.com/pathscale/ps-blitz.git?rev=a81bea599c2f67a9a1568e0f8911435c59af8874#a81bea599c2f67a9a1568e0f8911435c59af8874"
+source = "git+https://github.com/pathscale/ps-blitz.git?rev=b82e649d4b89757897a74a23274cedd2c096e557#b82e649d4b89757897a74a23274cedd2c096e557"
dependencies = [
"html5ever 0.39.0",
"ps-blitz-dom",
@@ -532,7 +532,7 @@ dependencies = [
[[package]]
name = "blitz-paint"
version = "0.3.0-beta.3"
-source = "git+https://github.com/pathscale/ps-blitz.git?rev=a81bea599c2f67a9a1568e0f8911435c59af8874#a81bea599c2f67a9a1568e0f8911435c59af8874"
+source = "git+https://github.com/pathscale/ps-blitz.git?rev=b82e649d4b89757897a74a23274cedd2c096e557#b82e649d4b89757897a74a23274cedd2c096e557"
dependencies = [
"color",
"euclid",
@@ -1056,7 +1056,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
- "unicode-width 0.1.14",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -1521,7 +1521,7 @@ checksum = "da220af51a1a335e9a930beaaef53d261e41ea9eecfb3d973a3ddae1a7284b9c"
[[package]]
name = "debug_timer"
version = "0.1.3"
-source = "git+https://github.com/pathscale/ps-blitz.git?rev=a81bea599c2f67a9a1568e0f8911435c59af8874#a81bea599c2f67a9a1568e0f8911435c59af8874"
+source = "git+https://github.com/pathscale/ps-blitz.git?rev=b82e649d4b89757897a74a23274cedd2c096e557#b82e649d4b89757897a74a23274cedd2c096e557"
[[package]]
name = "deranged"
@@ -1625,7 +1625,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1896,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2594,7 +2594,7 @@ dependencies = [
"log",
"presser",
"thiserror 2.0.19",
- "windows 0.61.3",
+ "windows 0.62.2",
]
[[package]]
@@ -2926,7 +2926,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.61.2",
+ "windows-core 0.62.2",
]
[[package]]
@@ -3231,7 +3231,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3641,7 +3641,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.19",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4608,7 +4608,7 @@ dependencies = [
[[package]]
name = "ps-blitz-debug-control"
version = "0.3.0-beta.3"
-source = "git+https://github.com/pathscale/ps-blitz.git?rev=a81bea599c2f67a9a1568e0f8911435c59af8874#a81bea599c2f67a9a1568e0f8911435c59af8874"
+source = "git+https://github.com/pathscale/ps-blitz.git?rev=b82e649d4b89757897a74a23274cedd2c096e557#b82e649d4b89757897a74a23274cedd2c096e557"
dependencies = [
"getrandom 0.4.3",
"serde",
@@ -4618,7 +4618,7 @@ dependencies = [
[[package]]
name = "ps-blitz-dom"
version = "0.3.0-beta.3"
-source = "git+https://github.com/pathscale/ps-blitz.git?rev=a81bea599c2f67a9a1568e0f8911435c59af8874#a81bea599c2f67a9a1568e0f8911435c59af8874"
+source = "git+https://github.com/pathscale/ps-blitz.git?rev=b82e649d4b89757897a74a23274cedd2c096e557#b82e649d4b89757897a74a23274cedd2c096e557"
dependencies = [
"accesskit",
"app_units",
@@ -4627,7 +4627,7 @@ dependencies = [
"color",
"cssparser 0.37.0",
"cursor-icon",
- "debug_timer 0.1.3 (git+https://github.com/pathscale/ps-blitz.git?rev=a81bea599c2f67a9a1568e0f8911435c59af8874)",
+ "debug_timer 0.1.3 (git+https://github.com/pathscale/ps-blitz.git?rev=b82e649d4b89757897a74a23274cedd2c096e557)",
"euclid",
"html-escape",
"image",
@@ -4663,7 +4663,7 @@ dependencies = [
[[package]]
name = "ps-blitz-script"
version = "0.3.0-beta.3"
-source = "git+https://github.com/pathscale/ps-blitz.git?rev=a81bea599c2f67a9a1568e0f8911435c59af8874#a81bea599c2f67a9a1568e0f8911435c59af8874"
+source = "git+https://github.com/pathscale/ps-blitz.git?rev=b82e649d4b89757897a74a23274cedd2c096e557#b82e649d4b89757897a74a23274cedd2c096e557"
dependencies = [
"base64 0.22.1",
"blitz-html",
@@ -4690,7 +4690,7 @@ dependencies = [
[[package]]
name = "ps-blitz-shell"
version = "0.3.0-beta.3"
-source = "git+https://github.com/pathscale/ps-blitz.git?rev=a81bea599c2f67a9a1568e0f8911435c59af8874#a81bea599c2f67a9a1568e0f8911435c59af8874"
+source = "git+https://github.com/pathscale/ps-blitz.git?rev=b82e649d4b89757897a74a23274cedd2c096e557#b82e649d4b89757897a74a23274cedd2c096e557"
dependencies = [
"android-activity",
"arboard",
@@ -4711,7 +4711,7 @@ dependencies = [
[[package]]
name = "ps-blitz-traits"
version = "0.3.0-beta.3"
-source = "git+https://github.com/pathscale/ps-blitz.git?rev=a81bea599c2f67a9a1568e0f8911435c59af8874#a81bea599c2f67a9a1568e0f8911435c59af8874"
+source = "git+https://github.com/pathscale/ps-blitz.git?rev=b82e649d4b89757897a74a23274cedd2c096e557#b82e649d4b89757897a74a23274cedd2c096e557"
dependencies = [
"atomic_refcell",
"bitflags 2.13.1",
@@ -4727,7 +4727,7 @@ dependencies = [
[[package]]
name = "ps-taffy"
version = "0.13.0"
-source = "git+https://github.com/pathscale/ps-taffy.git?rev=4f86730bab59a1b1910fb5adb29a5dec6415d5f7#4f86730bab59a1b1910fb5adb29a5dec6415d5f7"
+source = "git+https://github.com/pathscale/ps-taffy.git?rev=4355471d36f1939109d36197bfb580a931231738#4355471d36f1939109d36197bfb580a931231738"
dependencies = [
"arrayvec",
"serde",
@@ -5296,7 +5296,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5352,7 +5352,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5520,7 +5520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5928,7 +5928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6181,7 +6181,7 @@ dependencies = [
[[package]]
name = "stylo_taffy"
version = "0.3.0-beta.3"
-source = "git+https://github.com/pathscale/ps-blitz.git?rev=a81bea599c2f67a9a1568e0f8911435c59af8874#a81bea599c2f67a9a1568e0f8911435c59af8874"
+source = "git+https://github.com/pathscale/ps-blitz.git?rev=b82e649d4b89757897a74a23274cedd2c096e557#b82e649d4b89757897a74a23274cedd2c096e557"
dependencies = [
"ps-taffy",
"stylo",
@@ -6616,7 +6616,7 @@ dependencies = [
[[package]]
name = "tauri-runtime-blitz"
version = "0.1.0"
-source = "git+https://github.com/pathscale/tauri-runtime-blitz.git?rev=070ab3a2fd460d7c9b2f3f341e7d6c50af669682#070ab3a2fd460d7c9b2f3f341e7d6c50af669682"
+source = "git+https://github.com/pathscale/tauri-runtime-blitz.git?rev=9edbec23ce4ca9bcb052a364577fd9a736fd81a3#9edbec23ce4ca9bcb052a364577fd9a736fd81a3"
dependencies = [
"blitz-control-protocol",
"endpoint-libs",
@@ -6699,7 +6699,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.19",
- "toml 0.9.12+spec-1.1.0",
+ "toml 1.1.3+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -6727,7 +6727,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8091,7 +8091,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8978,7 +8978,7 @@ dependencies = [
[[package]]
name = "wt-migrate"
-version = "0.8.2"
+version = "0.8.4"
dependencies = [
"chrono",
"derive_more 2.1.1",
diff --git a/Cargo.toml b/Cargo.toml
index 75b6c875..7d9345a1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,7 +10,7 @@ members = [
]
[workspace.package]
-version = "0.8.2"
+version = "0.8.4"
edition = "2024"
publish = false
diff --git a/apps/gui/Cargo.toml b/apps/gui/Cargo.toml
index 12c0f433..cfcf6c78 100644
--- a/apps/gui/Cargo.toml
+++ b/apps/gui/Cargo.toml
@@ -72,9 +72,9 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "serd
tokio = { version = "1", features = ["process", "rt-multi-thread", "time"] }
# Default features are off; see `webview-runtime` for why.
tauri = { version = "2", default-features = false, features = ["compression"] }
-tauri-runtime-blitz = { git = "https://github.com/pathscale/tauri-runtime-blitz.git", rev = "070ab3a2fd460d7c9b2f3f341e7d6c50af669682", optional = true }
-blitz-dom = { package = "ps-blitz-dom", git = "https://github.com/pathscale/ps-blitz.git", rev = "a81bea599c2f67a9a1568e0f8911435c59af8874", features = ["system-fonts", "parallel-construct"], optional = true }
-blitz-script = { package = "ps-blitz-script", git = "https://github.com/pathscale/ps-blitz.git", rev = "a81bea599c2f67a9a1568e0f8911435c59af8874", features = ["system-fonts"], optional = true }
+tauri-runtime-blitz = { git = "https://github.com/pathscale/tauri-runtime-blitz.git", rev = "9edbec23ce4ca9bcb052a364577fd9a736fd81a3", optional = true }
+blitz-dom = { package = "ps-blitz-dom", git = "https://github.com/pathscale/ps-blitz.git", rev = "b82e649d4b89757897a74a23274cedd2c096e557", features = ["system-fonts", "parallel-construct"], optional = true }
+blitz-script = { package = "ps-blitz-script", git = "https://github.com/pathscale/ps-blitz.git", rev = "b82e649d4b89757897a74a23274cedd2c096e557", features = ["system-fonts"], optional = true }
brotli = { version = "8.0.4", default-features = false, features = ["std"], optional = true }
url = { version = "2.5.8", optional = true }
tauri-plugin-updater = "2"
diff --git a/apps/gui/frontend/bun.lock b/apps/gui/frontend/bun.lock
index 0b592bb5..107ec4a0 100644
--- a/apps/gui/frontend/bun.lock
+++ b/apps/gui/frontend/bun.lock
@@ -6,7 +6,7 @@
"name": "az-gui-frontend",
"dependencies": {
"@iconify/tailwind4": "^1.0.6",
- "@pathscale/ui": "^2.2.2",
+ "@pathscale/ui": "^2.5.0",
"@tauri-apps/api": "^2.1.1",
"clsx": "^2.1.1",
"popmotion": "^11.0.5",
@@ -192,7 +192,7 @@
"@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
- "@pathscale/ui": ["@pathscale/ui@2.2.2", "", { "dependencies": { "@tanstack/solid-virtual": "^3.13.27", "clsx": "^2.1.1", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@solid-primitives/event-listener": "^2.3.0", "@solid-primitives/intersection-observer": "^2.1.3", "@solid-primitives/keyboard": "^1.2.5", "@solid-primitives/media": "^2.2.5", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.22", "@solid-primitives/scheduled": "^1.4.1", "@solid-primitives/scroll": "^2.0.20", "@solid-primitives/storage": "^2.1.1", "@solid-primitives/utils": "^6.2.1", "@standard-schema/spec": "^1.0.0", "@tanstack/solid-form": "^1.29.0", "@tanstack/solid-table": "^8.0.0", "popmotion": "^11.0.5", "solid-js": "^1.9", "solid-layouts": "^0.1.3" }, "optionalPeers": ["@standard-schema/spec", "popmotion"] }, "sha512-Lv8wVYlnWe1+ggI89NGnOHs3QXt0H1rbxD+hCdjEaRVeOMGbKzxXbex8OK5KpLY3dWV9cA0Q0oxY8Q3Y7eFVHQ=="],
+ "@pathscale/ui": ["@pathscale/ui@2.5.0", "", { "dependencies": { "@tanstack/solid-virtual": "^3.13.27", "clsx": "^2.1.1", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@solid-primitives/event-listener": "^2.3.0", "@solid-primitives/intersection-observer": "^2.1.3", "@solid-primitives/keyboard": "^1.2.5", "@solid-primitives/media": "^2.2.5", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.22", "@solid-primitives/scheduled": "^1.4.1", "@solid-primitives/scroll": "^2.0.20", "@solid-primitives/storage": "^2.1.1", "@solid-primitives/utils": "^6.2.1", "@standard-schema/spec": "^1.0.0", "@tanstack/solid-form": "^1.29.0", "@tanstack/solid-table": "^8.0.0", "popmotion": "^11.0.5", "solid-js": "^1.9", "solid-layouts": "^0.1.3" }, "optionalPeers": ["@standard-schema/spec", "popmotion"] }, "sha512-CjtKk9nFZRlKjQ15W6F+6aUm77Te5XN5JnxAsNIgSTPL7Owk/NiMl4sEc2msxflzv5wCTOdeJIXvJqph0BfbIg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
diff --git a/apps/gui/frontend/package.json b/apps/gui/frontend/package.json
index 10e50994..0e1d6e70 100644
--- a/apps/gui/frontend/package.json
+++ b/apps/gui/frontend/package.json
@@ -20,7 +20,7 @@
"license": "MIT",
"dependencies": {
"@iconify/tailwind4": "^1.0.6",
- "@pathscale/ui": "^2.2.2",
+ "@pathscale/ui": "^2.5.0",
"@tauri-apps/api": "^2.1.1",
"clsx": "^2.1.1",
"popmotion": "^11.0.5",
diff --git a/apps/gui/frontend/src/App.tsx b/apps/gui/frontend/src/App.tsx
index 1c1a5509..3c9e657e 100644
--- a/apps/gui/frontend/src/App.tsx
+++ b/apps/gui/frontend/src/App.tsx
@@ -1,3 +1,4 @@
+import { Flex } from "@pathscale/ui";
import {
createEffect,
createMemo,
@@ -278,7 +279,7 @@ export function BootFailed(props: {
{props.message}
-
+
-
+
);
}
diff --git a/apps/gui/frontend/src/components/Panel.tsx b/apps/gui/frontend/src/components/Panel.tsx
index 6534ef9c..027a62a3 100644
--- a/apps/gui/frontend/src/components/Panel.tsx
+++ b/apps/gui/frontend/src/components/Panel.tsx
@@ -13,12 +13,18 @@ export type PanelProps = JSX.HTMLAttributes & {
* Radius 14 on `base-100` with a `base-content/9%` hairline — the design's
* panel token. Everything that looks like a card on screen is either this or a
* row inside it.
+ *
+ * `az-glass` is what makes that surface material rather than a flat fill: it
+ * takes the twenty-five `--glass-*` tokens `@pathscale/ui` derives from three
+ * numbers, so the Appearance sliders reach every panel at once. It is listed
+ * after `az-panel` because it replaces that opaque background — a backdrop
+ * filter behind an opaque fill blurs nothing anyone can see.
*/
export function Panel(props: PanelProps): JSX.Element {
const [own, rest] = splitProps(props, ["children", "class"]);
return (
{own.children}
diff --git a/apps/gui/frontend/src/features/onboarding/WelcomeFlow.tsx b/apps/gui/frontend/src/features/onboarding/WelcomeFlow.tsx
index c09369d8..0d8511b7 100644
--- a/apps/gui/frontend/src/features/onboarding/WelcomeFlow.tsx
+++ b/apps/gui/frontend/src/features/onboarding/WelcomeFlow.tsx
@@ -1,4 +1,4 @@
-import { Dialog, Select } from "@pathscale/ui";
+import { Dialog, Flex, Select } from "@pathscale/ui";
import { createEffect, createMemo, createSignal, For, type JSX, Show } from "solid-js";
import { Button } from "~/components/Button";
import { Icon, type IconProps } from "~/components/Icon";
@@ -264,7 +264,7 @@ export function WelcomeFlow(): JSX.Element {
{state.workspaceRoot?.path ?? tx("Not available")}
-
+
+
@@ -649,7 +649,7 @@ export function WelcomeFlow(): JSX.Element {
-
+
diff --git a/apps/gui/frontend/src/features/settings/SettingsTab.test.tsx b/apps/gui/frontend/src/features/settings/SettingsTab.test.tsx
index 182ed665..2d59400d 100644
--- a/apps/gui/frontend/src/features/settings/SettingsTab.test.tsx
+++ b/apps/gui/frontend/src/features/settings/SettingsTab.test.tsx
@@ -231,6 +231,11 @@ describe("cost warning settings", () => {
expect(slider).toHaveAttribute("aria-valuenow", "1");
expect(slider).toHaveAttribute("aria-valuetext", "$1.00");
+
+ // Releasing the key ends the keyboard interaction, which is what persists:
+ // the library raises `onChangeEnd` from `keyup` and `blur`, so holding an
+ // arrow down repeats the preview without repeating the store write.
+ fireEvent.keyUp(slider, { key: "ArrowRight" });
await waitFor(() => expect(screen.workspace.state.settings?.costWarningUsd).toBe(1));
});
@@ -264,12 +269,16 @@ describe("cost warning settings", () => {
fireEvent.pointerDown(track, { clientX: 100, pointerId: 1 });
- // Shown at once. The write waits for the knob to settle, so a drag does not
- // queue one serialized store round trip per tick.
+ // Shown at once, and not yet written: the store round trip belongs to the
+ // release, so a drag cannot queue one serialized write per tick.
expect(slider).toHaveAttribute("aria-valuenow", "10.25");
expect(slider).toHaveAttribute("aria-valuetext", "$10.25");
expect(screen.workspace.state.settings?.costWarningUsd).not.toBe(10.25);
+ // Releasing is what persists. The drag has to be finished for the value to
+ // reach the store at all, which is the whole point of `onChangeEnd`.
+ fireEvent.pointerUp(track, { clientX: 100, pointerId: 1 });
+
await waitFor(() => expect(screen.workspace.state.settings?.costWarningUsd).toBe(10.25), {
timeout: 2000,
});
@@ -301,13 +310,15 @@ describe("cost warning settings", () => {
() => new Promise((resolve) => releases.push(resolve)),
);
- // Two steps in quick succession. The display follows at once; the store
- // write waits for the knob to settle, so this is one save and not two.
+ // Two steps inside one keypress-and-release. The display follows each at
+ // once; the store write belongs to the release, so this is one save and
+ // not two.
fireEvent.keyDown(slider, { key: "ArrowRight" });
fireEvent.keyDown(slider, { key: "ArrowRight" });
expect(slider).toHaveAttribute("aria-valuenow", "1.25");
expect(releases).toHaveLength(0);
+ fireEvent.keyUp(slider, { key: "ArrowRight" });
await waitFor(() => expect(releases).toHaveLength(1), { timeout: 2000 });
// The preview is held until the save it belongs to lands, so a slow write
diff --git a/apps/gui/frontend/src/features/settings/SettingsTab.tsx b/apps/gui/frontend/src/features/settings/SettingsTab.tsx
index cff084c5..7d6bd4ec 100644
--- a/apps/gui/frontend/src/features/settings/SettingsTab.tsx
+++ b/apps/gui/frontend/src/features/settings/SettingsTab.tsx
@@ -1,4 +1,17 @@
-import { Checkbox, Input, Select, Slider, Switch, Textarea } from "@pathscale/ui";
+import {
+ applyGlassTokens,
+ Checkbox,
+ Flex,
+ GLASS_DEFAULTS,
+ GLASS_LIMITS,
+ type GlassMode,
+ type GlassTuning,
+ Input,
+ Select,
+ Slider,
+ Switch,
+ Textarea,
+} from "@pathscale/ui";
import {
createContext,
createEffect,
@@ -44,6 +57,7 @@ import type {
StudySummary,
TableSize,
TaskManagerSettings,
+ ThemeSettings,
} from "~/types";
import { ThemePicker } from "./ThemePicker";
@@ -1029,14 +1043,20 @@ export function SettingsTab(): JSX.Element {
/>
{/*
- Three axes, and every one of them moves something. There is no
- blur slider: blur belongs to the compositor's glass view and
- CSS cannot drive it on either shipping renderer, so it would
- be a control that does nothing.
-
- All three lean on the hue ladder rather than white. Frost
- needs something light beneath it to diffuse; on a dark surface
- a white film is grey haze on the colour, not material.
+ Two families of axis, and the split is real rather than
+ historical.
+
+ These three style AgencyZero's own `.az-panel`, which is
+ opaque, and they lean on the hue ladder rather than white:
+ frost needs something light beneath it to diffuse, so on a
+ dark surface a white film is grey haze on the colour, not
+ material.
+
+ The three below them are the library's, and they style
+ anything rendered with `material="glass"`. `@pathscale/ui`
+ derives twenty-five `--glass-*` tokens from exactly those
+ numbers, so they are passed straight through rather than
+ reinterpreted here.
*/}
-
+
+
+
+ `${Math.round(value)}px`}
+ onChange={(glassBlur) => void actions.saveSettings({ theme: { glassBlur } })}
+ />
+
+
+
+ `${Math.round((value / GLASS_LIMITS.refraction.max) * 100)}%`
+ }
+ onChange={(glassRefraction) =>
+ void actions.saveSettings({ theme: { glassRefraction } })
+ }
+ />
+
+
+ `${Math.round((value / GLASS_LIMITS.depth.max) * 100)}%`}
+ onChange={(glassDepth) => void actions.saveSettings({ theme: { glassDepth } })}
+ />
+
-
+
);
}
@@ -2421,13 +2492,12 @@ function CostSection(): JSX.Element {
step={0.25}
value={warningUsd()}
formatValue={(value) => `$${value.toFixed(2)}`}
- // Live while dragging, persisted when it settles. @pathscale/ui
- // 2.0.0 carries `onChangeEnd` for exactly this; 1.3.1, which is
- // what ships here, does not, so the settle is timed locally.
- onChange={(value) => {
- previewWarning(value);
- settleWarning(value);
- }}
+ // Live while dragging, persisted when the knob is released.
+ // `onChangeEnd` is the event that means exactly that, and it
+ // arrived with the 2.x line this app is now on; the local settle
+ // timer it replaces dated from the pinned 1.3.1, which had none.
+ onChange={(value) => previewWarning(value)}
+ onChangeEnd={(value) => settleWarning(value)}
size="sm"
class="w-full min-w-0 [&_[data-slot=label]]:sr-only"
/>
@@ -2536,7 +2606,7 @@ function InternalPerformance(): JSX.Element {
label={tx("Measurements")}
hint={tx("since this window opened, or since the last reset")}
>
-
+
{/*
Stacked: a full-width table beside a `flex-1` label squeezes the label to
@@ -2746,29 +2816,31 @@ function Row(props: {
);
}
-/**
- * One glass axis.
- *
- * Same slider the cost threshold uses, so these rows read as the rest of
- * Settings rather than as a debug panel. Saving on every change is deliberate:
- * the point of these is to drag them and watch the window, and a value that
- * only lands on release cannot be judged.
- */
/** How long the knob must be still before its value is written to the store. */
const SETTLE_MS = 180;
+/** Which settings field carries each of the library's three glass numbers. */
+const GLASS_SETTING_KEYS = {
+ blur: "glassBlur",
+ refraction: "glassRefraction",
+ depth: "glassDepth",
+} as const satisfies Record;
+
/**
* One appearance axis.
*
- * Dragging paints immediately and persists only when the drag settles. It used
- * to call `saveSettings` on every tick, and each of those awaited a serialized
- * store write before it even applied the CSS, then made a native window call:
- * one session's log held 75 `set_settings` and 76 `set_window_chrome` round
- * trips at 7 and 6ms, all on the window thread. That is why the knob led and
- * the panel lagged, and why dragging depth could starve paint until the window
- * went blank and only recovered once the drag stopped.
+ * Dragging paints immediately and persists once, when the knob is released. It
+ * used to call `saveSettings` on every tick, and each of those awaited a
+ * serialized store write before it even applied the CSS, then made a native
+ * window call: one session's log held 75 `set_settings` and 76
+ * `set_window_chrome` round trips at 7 and 6ms, all on the window thread. That
+ * is why the knob led and the panel lagged, and why dragging depth could starve
+ * paint until the window went blank and only recovered once the drag stopped.
*
- * The live/settled split is the slider's own now, so there is no timer here.
+ * A 180ms debounce replaced that and was a large improvement, but not a cure:
+ * a debounce fires whenever the pointer pauses, so a slow drag is still a
+ * stream of store writes. The live/settled split is the slider's own now
+ * (`onChange` paints, `onChangeEnd` persists), so there is no timer here.
*/
function GlassAxis(props: {
label: string;
@@ -2784,12 +2856,6 @@ function GlassAxis(props: {
onChange: (value: number) => void;
}): JSX.Element {
const toAxis = (value: number) => (props.toAxis ? props.toAxis(value) : value);
- let timer: ReturnType | undefined;
- onCleanup(() => clearTimeout(timer));
- const settle = (value: number): void => {
- clearTimeout(timer);
- timer = setTimeout(() => props.onChange(value), SETTLE_MS);
- };
return (
{
- writeGlassAxis(props.axis, toAxis(value));
- settle(value);
- }}
+ // Paint now: no store, no AppKit, no await.
+ onChange={(value) => writeGlassAxis(props.axis, toAxis(value))}
+ /*
+ * Persist once, when the knob is released.
+ *
+ * This used to be a 180ms debounce, because the pinned 1.3.1 had no
+ * `onChangeEnd` to hang it on. A debounce still fires mid-drag whenever
+ * the pointer pauses, and each of those is a `set_settings` round trip
+ * costing 5-6ms on the window thread: measured on the owner's live
+ * session, dragging this slider drove the profile log past 7,800 of
+ * them, paint starved, and the window blanked until the drag stopped.
+ * 2.5 has the real event, so the persist happens exactly once.
+ */
+ onChangeEnd={(value) => props.onChange(value)}
+ size="sm"
+ class="w-full min-w-0 [&_[data-slot=label]]:sr-only"
+ />
+
+ );
+}
+
+/**
+ * One of the library's three glass numbers.
+ *
+ * Separate from {@link GlassAxis} because it writes a different thing: that one
+ * sets a single `--az-glass-*` property on AgencyZero's own opaque panel, and
+ * this one hands `blur`/`refraction`/`depth` to `@pathscale/ui`, which derives
+ * twenty-five `--glass-*` tokens from the three together. A preview therefore
+ * has to re-derive the whole set from the other two axes as they stand, which
+ * is what `applyGlassTokens` does when given a complete tuning.
+ *
+ * Range and default both come from the library rather than being restated here.
+ * That is the point of the exercise: glass is the library's primitive now, so
+ * an app that hardcoded `max={0.4}` would be a second place to update when the
+ * curves are retuned.
+ */
+function GlassTuningAxis(props: {
+ label: string;
+ axis: keyof GlassTuning;
+ step: number;
+ /** Undefined means "whatever the library defaults this axis to". */
+ value: number | undefined;
+ /** The theme these three axes live on, for re-deriving the whole set. */
+ theme: ThemeSettings;
+ format: (value: number) => string;
+ onChange: (value: number) => void;
+}): JSX.Element {
+ const mode = (): GlassMode =>
+ document.documentElement.dataset.colorMode === "light" ? "light" : "dark";
+ const resolved = (axis: keyof GlassTuning): number => {
+ const value = props.theme[GLASS_SETTING_KEYS[axis]];
+ return Number.isFinite(value) ? Number(value) : GLASS_DEFAULTS[mode()][axis];
+ };
+ const limits = () => GLASS_LIMITS[props.axis];
+
+ return (
+
+
+ applyGlassTokens(
+ {
+ blur: resolved("blur"),
+ refraction: resolved("refraction"),
+ depth: resolved("depth"),
+ [props.axis]: value,
+ },
+ mode(),
+ )
+ }
+ onChangeEnd={(value) => props.onChange(value)}
size="sm"
class="w-full min-w-0 [&_[data-slot=label]]:sr-only"
/>
diff --git a/apps/gui/frontend/src/features/settings/ThemePicker.tsx b/apps/gui/frontend/src/features/settings/ThemePicker.tsx
index c84b2939..6388c1c2 100644
--- a/apps/gui/frontend/src/features/settings/ThemePicker.tsx
+++ b/apps/gui/frontend/src/features/settings/ThemePicker.tsx
@@ -1,4 +1,4 @@
-import { Radio } from "@pathscale/ui";
+import { Flex, Radio } from "@pathscale/ui";
import { createEffect, For, type JSX, Show } from "solid-js";
import { Button } from "~/components/Button";
import {
@@ -192,7 +192,17 @@ function SurfaceColorWheel(props: { value: string; onPick: (value: string) => vo
style={{ "background-color": color }}
/>
}
- class="size-7 cursor-pointer rounded-full [&_[data-slot=radio-control]]:m-0 [&_[data-slot=radio-control]]:size-7 [&_[data-slot=radio-control]]:border-2 [&_[data-slot=radio-control]]:border-az-hairline-strong [&_[data-slot=radio-indicator]]:overflow-hidden [&_[data-slot=radio-indicator]]:rounded-full"
+ /*
+ * `bg-transparent` on the control is load-bearing, not tidying.
+ *
+ * The petal's colour is the indicator, and the control is the
+ * box drawn around it. 2.x gives that box its own
+ * `background-color: var(--color-base-100)`, which is painted
+ * over the indicator and turns all 31 petals into identical
+ * empty circles. The v1 control had no fill, so this override
+ * was not needed and its absence was invisible.
+ */
+ class="size-7 cursor-pointer rounded-full [&_[data-slot=radio-control]]:m-0 [&_[data-slot=radio-control]]:size-7 [&_[data-slot=radio-control]]:border-2 [&_[data-slot=radio-control]]:border-az-hairline-strong [&_[data-slot=radio-control]]:bg-transparent [&_[data-slot=radio-indicator]]:overflow-hidden [&_[data-slot=radio-indicator]]:rounded-full"
/>
);
@@ -239,7 +249,7 @@ function AccentSelector(props: {
{t("appearance.accentColourHint")}
+
);
}
diff --git a/apps/gui/frontend/src/features/tabs/shortcuts.test.tsx b/apps/gui/frontend/src/features/tabs/shortcuts.test.tsx
new file mode 100644
index 00000000..15e8e989
--- /dev/null
+++ b/apps/gui/frontend/src/features/tabs/shortcuts.test.tsx
@@ -0,0 +1,148 @@
+import { createRoot } from "solid-js";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useTabShortcuts } from "~/features/tabs/shortcuts";
+
+/**
+ * The clipboard chords, which the shipping renderer does not deliver as events.
+ *
+ * Blitz dispatches no `copy` or `paste` event to JS, so both are answered from
+ * a plain `keydown` here. These tests drive that keydown directly, which is
+ * exactly the surface the renderer does provide.
+ */
+
+const openDraft = vi.fn();
+const cycleTab = vi.fn();
+
+vi.mock("~/stores/workspace", () => ({
+ useWorkspace: () => ({ actions: { openDraft, cycleTab } }),
+}));
+
+const writeText = vi.fn(() => Promise.resolve());
+const readText = vi.fn(() => Promise.resolve(""));
+
+vi.mock("~/lib/platform", () => ({ isTauri: () => false, isBlitz: () => false }));
+
+function mount(): () => void {
+ let dispose = (): void => {};
+ createRoot((disposer) => {
+ dispose = disposer;
+ useTabShortcuts();
+ });
+ return dispose;
+}
+
+/** Mounting runs `onMount`, which is what installs the window listener. */
+function press(key: string, target: EventTarget): void {
+ target.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key,
+ code: `Key${key.toUpperCase()}`,
+ metaKey: true,
+ bubbles: true,
+ }),
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText, readText },
+ });
+});
+
+describe("paste", () => {
+ it("inserts the clipboard at the caret and tells the field it changed", async () => {
+ readText.mockResolvedValueOnce("world");
+ const field = document.createElement("textarea");
+ field.value = "hello ";
+ field.setSelectionRange(6, 6);
+ document.body.append(field);
+ const onInput = vi.fn();
+ field.addEventListener("input", onInput);
+
+ const dispose = mount();
+ press("v", field);
+ await vi.waitFor(() => expect(field.value).toBe("hello world"));
+
+ // A controlled Solid field reads its value from a signal that only an
+ // `input` event updates, so without this the paste is invisible to the app.
+ expect(onInput).toHaveBeenCalled();
+ expect(field.selectionStart).toBe("hello world".length);
+
+ dispose();
+ field.remove();
+ });
+
+ it("replaces the selection rather than inserting beside it", async () => {
+ readText.mockResolvedValueOnce("there");
+ const field = document.createElement("input");
+ field.value = "hi world";
+ field.setSelectionRange(3, 8);
+ document.body.append(field);
+
+ const dispose = mount();
+ press("v", field);
+ await vi.waitFor(() => expect(field.value).toBe("hi there"));
+
+ dispose();
+ field.remove();
+ });
+
+ it("leaves a read-only field alone and never reads the clipboard for it", () => {
+ const field = document.createElement("textarea");
+ field.value = "untouched";
+ field.readOnly = true;
+ document.body.append(field);
+
+ const dispose = mount();
+ press("v", field);
+
+ expect(readText).not.toHaveBeenCalled();
+ expect(field.value).toBe("untouched");
+
+ dispose();
+ field.remove();
+ });
+
+ it("does not read the clipboard when the paste lands outside a field", () => {
+ const dispose = mount();
+ press("v", document.body);
+
+ expect(readText).not.toHaveBeenCalled();
+
+ dispose();
+ });
+
+ it("keeps the field's text when the clipboard is refused", async () => {
+ readText.mockRejectedValueOnce(new Error("denied"));
+ const field = document.createElement("textarea");
+ field.value = "kept";
+ document.body.append(field);
+
+ const dispose = mount();
+ press("v", field);
+ await vi.waitFor(() => expect(readText).toHaveBeenCalled());
+
+ expect(field.value).toBe("kept");
+
+ dispose();
+ field.remove();
+ });
+});
+
+describe("copy", () => {
+ it("copies a field's selection rather than the whole value", async () => {
+ const field = document.createElement("textarea");
+ field.value = "one two three";
+ field.setSelectionRange(4, 7);
+ document.body.append(field);
+
+ const dispose = mount();
+ press("c", field);
+ await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith("two"));
+
+ dispose();
+ field.remove();
+ });
+});
diff --git a/apps/gui/frontend/src/features/tabs/shortcuts.ts b/apps/gui/frontend/src/features/tabs/shortcuts.ts
index 615a515c..5368dbd5 100644
--- a/apps/gui/frontend/src/features/tabs/shortcuts.ts
+++ b/apps/gui/frontend/src/features/tabs/shortcuts.ts
@@ -1,5 +1,5 @@
import { onCleanup, onMount } from "solid-js";
-import { copyText } from "~/features/project/MessageBody";
+import { copyText, pasteText } from "~/features/project/MessageBody";
import { isBlitz, isTauri } from "~/lib/platform";
import { useWorkspace } from "~/stores/workspace";
@@ -68,6 +68,45 @@ export function useTabShortcuts(): void {
return;
}
+ /*
+ * Cmd+V and Ctrl+V into a text field, for the same reason Copy is here.
+ *
+ * Blitz dispatches no `paste` event to JS, and answers the chord itself
+ * only for a focused native text input. That covers the composer, so this
+ * is not where pasting into it comes from — but the native route writes
+ * straight into the editor's own buffer, and a controlled Solid field then
+ * holds a `value` the DOM no longer matches. Answering it here keeps the
+ * signal and the element in step.
+ *
+ * Only fields. A paste with nothing focused to receive it has no meaning,
+ * and reading the clipboard to discard it would be a permission prompt for
+ * nothing.
+ */
+ if ((event.metaKey || event.ctrlKey) && (event.key === "v" || event.code === "KeyV")) {
+ const target = event.target as HTMLElement | null;
+ const field =
+ target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement ? target : null;
+ if (!field || field.readOnly || field.disabled) return;
+
+ // Prevent the native insert, so text cannot land twice when the renderer
+ // also handles the chord.
+ event.preventDefault();
+
+ const from = field.selectionStart ?? field.value.length;
+ const to = field.selectionEnd ?? from;
+ void pasteText().then((text) => {
+ if (text === null || text.length === 0) return;
+ field.value = `${field.value.slice(0, from)}${text}${field.value.slice(to)}`;
+ const caret = from + text.length;
+ field.setSelectionRange(caret, caret);
+ // A controlled field takes its value from a signal that only an `input`
+ // event updates. Without this the character count, the autosize and the
+ // submitted text all keep the value from before the paste.
+ field.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ return;
+ }
+
if (event.altKey || event.shiftKey) return;
// ⌃T, the second way to open a new project. The native menu duplicates it
diff --git a/apps/gui/frontend/src/i18n/ui/en.ts b/apps/gui/frontend/src/i18n/ui/en.ts
index 59a0b407..e3a72458 100644
--- a/apps/gui/frontend/src/i18n/ui/en.ts
+++ b/apps/gui/frontend/src/i18n/ui/en.ts
@@ -958,6 +958,15 @@ const en = {
"strength of the hairline around a panel": "strength of the hairline around a panel",
"Panel depth": "Panel depth",
"drop shadow under a panel": "drop shadow under a panel",
+ "Glass blur": "Glass blur",
+ "how far a glass surface smears what is behind it":
+ "how far a glass surface smears what is behind it",
+ "Glass refraction": "Glass refraction",
+ "how much a glass surface asserts its own tint, border and highlight":
+ "how much a glass surface asserts its own tint, border and highlight",
+ "Glass depth": "Glass depth",
+ "how far a glass surface sits off the page: glow, sheen and shadow":
+ "how far a glass surface sits off the page: glow, sheen and shadow",
Snapshot: "Snapshot",
"a copy of the store as it stands, without closing the app":
"a copy of the store as it stands, without closing the app",
diff --git a/apps/gui/frontend/src/i18n/ui/zh.ts b/apps/gui/frontend/src/i18n/ui/zh.ts
index abd322f8..df23b2e6 100644
--- a/apps/gui/frontend/src/i18n/ui/zh.ts
+++ b/apps/gui/frontend/src/i18n/ui/zh.ts
@@ -927,6 +927,14 @@ const zh = {
"strength of the hairline around a panel": "面板细边框的强度",
"Panel depth": "面板深度",
"drop shadow under a panel": "面板下的投影",
+ "Glass blur": "玻璃模糊",
+ "how far a glass surface smears what is behind it": "玻璃表面对背后内容的模糊程度",
+ "Glass refraction": "玻璃折射",
+ "how much a glass surface asserts its own tint, border and highlight":
+ "玻璃表面自身色调、边框和高光的强度",
+ "Glass depth": "玻璃深度",
+ "how far a glass surface sits off the page: glow, sheen and shadow":
+ "玻璃表面离页面的高度:辉光、光泽和阴影",
Snapshot: "快照",
"a copy of the store as it stands, without closing the app": "在不关闭应用的情况下复制当前存储",
"Take snapshot": "创建快照",
diff --git a/apps/gui/frontend/src/lib/theme.ts b/apps/gui/frontend/src/lib/theme.ts
index de8dc3d1..5b54a1ca 100644
--- a/apps/gui/frontend/src/lib/theme.ts
+++ b/apps/gui/frontend/src/lib/theme.ts
@@ -1,3 +1,4 @@
+import { applyGlassTokens, GLASS_DEFAULTS, type GlassMode } from "@pathscale/ui";
import type { ColorValue } from "@pathscale/ui/components/color-wheel-flower";
import type { ThemeSettings } from "~/types";
@@ -225,18 +226,52 @@ export function applyTheme(
* Each default is the untouched look: no lift, the 16% hairline, no shadow.
* A property is removed rather than written at its default, so the
* stylesheet's own value applies and nothing inline needs explaining later.
- *
- * There is no blur axis. Blur belongs to the compositor's glass view, and CSS
- * `backdrop-filter` cannot drive it on either shipping renderer, so a blur
- * slider would move nothing.
*/
writeGlassAxis("lift", theme.glassLift);
writeGlassAxis("border", theme.glassBorder);
writeGlassAxis("shadow", theme.glassShadow);
+ writeGlassTuning(theme, root);
return windowChrome(accent, theme);
}
+/**
+ * The library's glass, from the three numbers it derives everything else from.
+ *
+ * `@pathscale/ui` styles `material="glass"` off a family of `--glass-*` custom
+ * properties and ships `applyGlassTokens` to write the twenty-five derived ones
+ * from `blur`, `refraction` and `depth`. Calling it here rather than keeping a
+ * local copy of the curves means the app and the library cannot disagree about
+ * what glass looks like, and a library that retunes them retunes this too.
+ *
+ * An axis the settings leave undefined falls to `GLASS_DEFAULTS` for the
+ * current colour mode, so this is always a complete set: three of the tokens
+ * are read by the component CSS without a fallback, and a partial set gives a
+ * card with no background rather than a plainer one.
+ *
+ * Blur used to be excluded here, on the grounds that neither shipping renderer
+ * could carry `backdrop-filter`. That stopped being true: vello now has a real
+ * backdrop pass (`record_backdrop`), so the blur axis reaches the screen.
+ */
+function writeGlassTuning(theme: ThemeSettings, root: HTMLElement): void {
+ // Read from the root rather than importing the prefs store: this module is a
+ // pure function of the theme it is handed, and `stores/prefs` already writes
+ // the mode here on every change.
+ const mode: GlassMode = root.dataset.colorMode === "light" ? "light" : "dark";
+ const defaults = GLASS_DEFAULTS[mode];
+ applyGlassTokens(
+ {
+ blur: Number.isFinite(theme.glassBlur) ? Number(theme.glassBlur) : defaults.blur,
+ refraction: Number.isFinite(theme.glassRefraction)
+ ? Number(theme.glassRefraction)
+ : defaults.refraction,
+ depth: Number.isFinite(theme.glassDepth) ? Number(theme.glassDepth) : defaults.depth,
+ },
+ mode,
+ root,
+ );
+}
+
/**
* The three glass axes, and their defaults.
*
diff --git a/apps/gui/frontend/src/styles/theme.css b/apps/gui/frontend/src/styles/theme.css
index 6769abac..7a5f646d 100644
--- a/apps/gui/frontend/src/styles/theme.css
+++ b/apps/gui/frontend/src/styles/theme.css
@@ -392,8 +392,16 @@
* only").
*
* The blur itself is the compositor's, through `NSGlassEffectView` behind a
- * transparent window. CSS `backdrop-filter` is not involved: it works in
- * `vello_cpu` but the shipping renderers cannot carry it yet.
+ * transparent window, and none of it is on yet: the window config carries
+ * no `transparent: true`, so `WINDOW_GLASS_ENABLED` in `lib/theme.ts` stays
+ * false and the effect view is never attached. Attaching it over an opaque
+ * window washes the whole app out under one flat colour, which is what that
+ * flag exists to prevent.
+ *
+ * CSS `backdrop-filter` is a separate route to the same look, and it is no
+ * longer the impossible one: the shipping vello backend gained a real
+ * backdrop pass (`record_backdrop` in `anyrender_vello/src/scene.rs`), so
+ * the claim that only `vello_cpu` can carry it is out of date.
*/
background-color: color-mix(
in oklab,
@@ -530,6 +538,89 @@
box-shadow: 0 8px 32px rgb(0 0 0 / var(--az-glass-shadow, 0));
}
+ /*
+ * Real glass, from the library's tokens.
+ *
+ * `@pathscale/ui` derives twenty-five `--glass-*` properties from three
+ * numbers, and every one below is read with the fallback the library's own
+ * component CSS uses. That is load-bearing rather than tidy: an undefined
+ * custom property does not fall back to its initial value, CSS drops the
+ * whole declaration, so a bare read here would give a panel with no
+ * background at all rather than a plainer one.
+ *
+ * It replaces `.az-panel`'s opaque fill rather than sitting over it, because
+ * a backdrop filter behind an opaque background blurs nothing. The hue ladder
+ * still supplies the tint: `az-badge` is a lighter rung of the same hue, and
+ * a white film on a dark surface reads as grey haze rather than as material.
+ *
+ * Blur reaches the screen now. The vello backend that ships gained a real
+ * backdrop pass, so `backdrop-filter` is no longer a property the renderer
+ * accepts and discards.
+ */
+ .az-glass {
+ background-color: color-mix(
+ in oklab,
+ var(--color-az-badge),
+ transparent calc(100% - var(--glass-background-opacity, 55%))
+ );
+ outline-color: color-mix(
+ in oklab,
+ var(--color-primary),
+ transparent calc(100% - var(--glass-border-opacity, 12%))
+ );
+ box-shadow:
+ var(--glass-shadow-depth, 0 8px 32px rgb(0 0 0 / 10%)),
+ inset 0 1px 0
+ color-mix(
+ in oklab,
+ var(--color-az-badge),
+ transparent calc(100% - var(--glass-highlight-opacity, 20%))
+ );
+ backdrop-filter: blur(var(--glass-blur, 9px)) saturate(var(--glass-saturation, 1.2))
+ brightness(var(--glass-brightness, 1));
+ -webkit-backdrop-filter: blur(var(--glass-blur, 9px)) saturate(var(--glass-saturation, 1.2))
+ brightness(var(--glass-brightness, 1));
+ }
+
+ /*
+ * Nested glass is one pane, not two.
+ *
+ * A blurred surface inside another blurred surface reads the already-blurred
+ * pixels and smears them again, which is muddy rather than deeper. The same
+ * rule the library applies to its own nested surfaces.
+ */
+ .az-glass .az-glass {
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
+ }
+
+ /*
+ * No backdrop support, or a reader who asked for less transparency: keep the
+ * surface legible by making it more opaque rather than leaving it a tint over
+ * whatever is behind it.
+ */
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
+ .az-glass {
+ background-color: color-mix(
+ in oklab,
+ var(--color-az-badge),
+ transparent calc(100% - var(--glass-fallback-background-opacity, 78%))
+ );
+ }
+ }
+
+ @media (prefers-reduced-transparency: reduce) {
+ .az-glass {
+ background-color: color-mix(
+ in oklab,
+ var(--color-az-badge),
+ transparent calc(100% - var(--glass-fallback-background-opacity, 78%))
+ );
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
+ }
+ }
+
@media (prefers-reduced-motion: reduce) {
/* biome-ignore lint/style/noDescendingSpecificity: this sets animation and
transition only, and shares no property with the selection rules above
diff --git a/apps/gui/frontend/src/types/index.ts b/apps/gui/frontend/src/types/index.ts
index f0c74c38..41fe6f8f 100644
--- a/apps/gui/frontend/src/types/index.ts
+++ b/apps/gui/frontend/src/types/index.ts
@@ -700,6 +700,24 @@ export interface ThemeSettings {
glassBorder?: number;
/** Panel drop shadow, 0 to 1. 0 is none. */
glassShadow?: number;
+ /*
+ * The library's three glass numbers, which derive twenty-five `--glass-*`
+ * tokens between them. `@pathscale/ui`'s `GLASS_LIMITS` owns the ranges and
+ * `GLASS_DEFAULTS` the values, so nothing here restates either: an axis left
+ * undefined is the library's default for the current colour mode.
+ *
+ * These sit beside the three above rather than replacing them because they
+ * answer different questions. `glassLift`/`glassBorder`/`glassShadow` style
+ * AgencyZero's own opaque `.az-panel` on the hue ladder; these style anything
+ * the library renders with `material="glass"`, which is what the app is
+ * moving to.
+ */
+ /** Backdrop blur radius in pixels, 0 to 50. */
+ glassBlur?: number;
+ /** How much a glass surface asserts itself: tint, border, highlight. 0 to 0.4. */
+ glassRefraction?: number;
+ /** How far a glass surface sits off the page: glow, sheen, shadow. 0 to 30. */
+ glassDepth?: number;
/**
* How much of the surface colour is mixed into every surface, as a percentage.
* Ignored while `surface` is empty.
diff --git a/crates/blitz-bench/Cargo.toml b/crates/blitz-bench/Cargo.toml
index 6fadd784..24ac594b 100644
--- a/crates/blitz-bench/Cargo.toml
+++ b/crates/blitz-bench/Cargo.toml
@@ -11,7 +11,7 @@ publish.workspace = true
# `tauri-runtime-blitz` for the same types would build a browser engine to send
# a wheel event. `cargo tree -p blitz-bench` is the check.
[dependencies]
-blitz-control-protocol = { git = "https://github.com/pathscale/tauri-runtime-blitz.git", rev = "070ab3a2fd460d7c9b2f3f341e7d6c50af669682" }
+blitz-control-protocol = { git = "https://github.com/pathscale/tauri-runtime-blitz.git", rev = "9edbec23ce4ca9bcb052a364577fd9a736fd81a3" }
# The same framing the server uses, from the same crate, for the same reason as
# the protocol types: a hand-rolled length-prefix reader is how the Python
# client hung on its first malformed frame.
diff --git a/docs/glass-support.md b/docs/glass-support.md
index e7b68af6..b59ace9f 100644
--- a/docs/glass-support.md
+++ b/docs/glass-support.md
@@ -77,10 +77,49 @@ The second can ship without the first and is where the existing groundwork is.
handful of properties and neither `outline` nor `box-shadow` is among them.
See `tests/blitz-tests/tests/panel_axes.rs`.
-### Not built: the blur, in the renderer that ships
-
-This is the whole remaining gap, and it is worth stating precisely because
-"backdrop-filter is implemented" and "the app cannot blur" are both true.
+### Landed 2026-08-16: in-app glass, from the library's three numbers
+
+In-app glass ships. `@pathscale/ui` 2.5 carries `styles/glass.ts`, which derives
+twenty-five `--glass-*` tokens from `blur`, `refraction` and `depth` — the
+curves lifted from the same consulting.parcle.ai tuning surface, against this
+same token vocabulary. `applyTheme` calls its `applyGlassTokens`, Settings
+exposes the three axes with `GLASS_LIMITS`/`GLASS_DEFAULTS` supplying ranges and
+defaults, and `.az-glass` on `components/Panel.tsx` is what reads the tokens, so
+every panel in the app is one glass surface.
+
+That is the **in-app** half of the split this document opens with, and it needed
+no window transparency: it is CSS `backdrop-filter`, blurring what is behind a
+panel inside an opaque window.
+
+**Window glass is still off, and the section below is still why.** The two
+switches named there are unchanged, because an `NSGlassEffectView` over an
+opaque window still flattens the app under one colour. What changed is that
+in-app glass no longer waits on them.
+
+### Superseded 2026-08-16: the blur landed, the switches did not
+
+The table below is kept because its reasoning is still useful, but its verdict
+is out of date. `anyrender_vello` — the backend that ships — now carries a real
+backdrop pass: `record_backdrop`, with a blur sigma and an expansion rect,
+reached from `push_layer` in
+[`anyrender_vello/src/scene.rs`](../../ps-anyrender/crates/anyrender_vello/src/scene.rs).
+`ce156b0` recorded that landing and this section was never revised to match.
+
+**So the remaining gap is not the renderer. It is that nothing turns glass on.**
+Three gates, and all of them are shut by choice:
+
+| gate | state | where |
+|---|---|---|
+| `transparent: true` on the window | **absent** | `apps/gui/tauri.conf.json` |
+| `WINDOW_GLASS_ENABLED` | **`false`** | `apps/gui/frontend/src/lib/theme.ts:290` |
+| renderer backdrop pass | **present** | `anyrender_vello/src/scene.rs` |
+
+The first two are a pair: attaching an `NSGlassEffectView` over an *opaque*
+window flattens every surface under one colour, which is what the flag is
+defending against, so neither moves without the other. Opening them is the
+feature landing this document plans, not a bug fix, and the risk note below
+still governs it — a black window is the shared failure mode of three separate
+stages, so change one thing at a time and confirm each.
| renderer | ships in `az-gui`? | filters |
|---|---|---|
diff --git a/scripts/local-delivery.sh b/scripts/local-delivery.sh
index 64cc97c7..12cd229c 100755
--- a/scripts/local-delivery.sh
+++ b/scripts/local-delivery.sh
@@ -112,12 +112,17 @@ if [ "$mode" != "quick" ]; then
(
cd "$repo_root"
cargo fmt --all --check
- cargo clippy --workspace --all-targets --all-features -- -D warnings
+ # Not `--all-features`. `blitz-runtime` and `webview-runtime` are mutually
+ # exclusive, and `apps/gui/src/main.rs` makes enabling both a compile error,
+ # so the flag that means "check everything" is the one flag this workspace
+ # cannot take: it fails before any lint or test runs. Name the runtime that
+ # ships, with the inspector `stable` builds against.
+ cargo clippy --workspace --all-targets --features az-gui/blitz-inspector -- -D warnings
# Store-backed tests open several files apiece. macOS's default descriptor
# ceiling can make the parallel harness fail with `Too many open files`,
# masking healthy assertions as a broken release. Serial execution adds less
# than a second here and keeps the delivery gate deterministic.
- RUST_TEST_THREADS=${RUST_TEST_THREADS:-1} cargo test --workspace --all-features
+ RUST_TEST_THREADS=${RUST_TEST_THREADS:-1} cargo test --workspace --features az-gui/blitz-inspector
)
fi