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 { -
+ -
+ -
+ {(rule) => ( @@ -2105,7 +2105,7 @@ function NotesEditor(props: { projectId: string }): JSX.Element { disabled={!isLive("setProjectNotes")} class="az-scroll w-full resize-y rounded-lg border border-az-hairline-strong bg-base-300 p-2.5 font-mono text-[11.5px] text-az-body leading-[1.6] placeholder:text-az-faint focus:border-primary/40 focus:outline-none disabled:opacity-50" /> -
+ {tx("Forget")} -
+
+ {(turn) => ( @@ -1390,7 +1390,7 @@ function AgentBubble(props: { -
+ ); } @@ -1653,7 +1653,7 @@ export function ReviewNote(props: { message: Message }): JSX.Element { data-selectable class={`group ${AGENT_BUBBLE} ${failed() ? "border-error/40" : "border-info/30"}`} > -
+ -
+ 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 } })} + /> +
-
+ {root().path} @@ -1110,7 +1181,7 @@ export function SettingsTab(): JSX.Element { {tx("Create it")} -
+ )} @@ -1152,7 +1223,7 @@ export function SettingsTab(): JSX.Element { -
+ -
+
-
+ -
+
); @@ -1822,7 +1893,7 @@ function ExperimentalSettings(): JSX.Element { } isLast > -
+ {(message) => {message()}} @@ -1834,7 +1905,7 @@ function ExperimentalSettings(): JSX.Element { > {busy() ? tx("Refreshing…") : tx("Refresh")} -
+
); @@ -1975,7 +2046,7 @@ function StoreBackupControls(): JSX.Element { {error()}
-
+
+ ); } @@ -2155,7 +2226,7 @@ function SourceActions(): JSX.Element { const { actions } = useWorkspace(); const openRepository = () => void actions.openExternal(AGENCYZERO_REPOSITORY); return ( -
+ -
+ ); } @@ -2209,7 +2280,7 @@ function UpdateControl(): JSX.Element { }; return ( -
+ {(text) => {text()}} @@ -2242,7 +2313,7 @@ function UpdateControl(): JSX.Element { )} -
+ ); } @@ -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")} > -
+ @@ -2550,7 +2620,7 @@ function InternalPerformance(): JSX.Element { > {tx("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")}
-
+ {(option, index) => { const selected = () => props.accent === option.value; @@ -264,7 +274,7 @@ function AccentSelector(props: { ); }} -
+ ); } @@ -298,7 +308,7 @@ function Axis(props: { {props.hint} {props.action} -
+ {(stop, index) => (
+ ); } 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