From 700eb224f99eb035c948eac4295e1f2c66770dd0 Mon Sep 17 00:00:00 2001 From: Ryan Kiley Date: Fri, 24 Jul 2026 18:57:16 -0700 Subject: [PATCH] fix: harden the degrade paths an adversarial review turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine fixes, each with a regression test that fails against the prior code. Crashes and lost state - parseColor resolved a color() space off a plain object, so `color(constructor …)` / `color(__proto__ …)` passed the unknown-space guard as an inherited member and reached the conversion maths as a non-space — a TypeError out of set() / fromJSON() / a persisted restore. Null-prototype, like the plot whitelist and the presets bag. - applySnapshot and applySet ran their entries unguarded, so one throwing control abandoned every later value AND the notify with it, leaving the panel silently half-restored. Isolate per entry, the same way createControl / metaFor / notify / applyConditionals already do. Stuck and leaked state - The header drag took no pointer capture below the 4px threshold, so a release just off the header stranded dragId — and the next plain hover then lifted the panel into a drag with nothing pressed. Bail on buttons === 0, as the slider, the interval and dragGesture all do. - The monitor poll and the FPS rAF loop deliberately idle through the "built but not appended yet" window, so a panel destroyed before it ever connected never stopped them. Controls now hand back an optional destroy() that the panel adopts into its cleanups. - Undo history grew without bound; cap it at 200 steps. Unreachable behaviour - Double-click-to-reset on a slider was shadowed by its own click-to-type: once an 800ms hover armed editing, click #1 swapped the readout for the inline input and the dblclick landed there instead. Catch it on the input too — commit, close, then reset. - reset() in the lazy window routed through the toolbar button, which is disabled until assemble(), so it vanished while set/setMany/fromJSON queued. It now queues with them, replaying in call order. - opts.filter with toolbar:false mounted a filter input nothing could reveal; refuse it with a warning instead. - Markup mode keyed its copy by label (same-labelled hosts overwrote each other) and echoed object-valued controls onto data-value as the literal "[object Object]" — the very attribute its parsers read back. Key by data-key when present, and serialise objects in the comma form. Also: null-prototype THEME_ALIASES / MODE_INTERP / INTERP_MODE (same class as the colour bug, currently harmless); cap stepPrecision at toFixed's 100-digit ceiling, so a sub-1e-100 step no longer throws the control away; route the plot's "y =" label through txt() rather than el()'s innerHTML arm; npm audit fix for the dev-only esbuild and undici advisories (lockfile only, nothing ships). --- package-lock.json | 220 ++++++++++++++++----------------- src/tweaks/controls/colour.ts | 7 +- src/tweaks/controls/monitor.ts | 25 ++-- src/tweaks/controls/plot.ts | 2 +- src/tweaks/core.ts | 116 ++++++++++++++--- src/tweaks/shared.ts | 21 +++- src/tweaks/types.ts | 3 + src/wide-gamut.ts | 7 +- test/lazy.test.mjs | 17 +++ test/panel.test.mjs | 145 +++++++++++++++++++++- 10 files changed, 417 insertions(+), 146 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1d669f0..a42547f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -219,9 +219,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -236,9 +236,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -253,9 +253,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -270,9 +270,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -287,9 +287,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -304,9 +304,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -321,9 +321,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -338,9 +338,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -355,9 +355,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -372,9 +372,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -389,9 +389,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -406,9 +406,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -423,9 +423,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -440,9 +440,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -457,9 +457,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -474,9 +474,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -491,9 +491,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -508,9 +508,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -525,9 +525,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -542,9 +542,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -559,9 +559,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -576,9 +576,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -593,9 +593,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -610,9 +610,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -627,9 +627,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -644,9 +644,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -737,9 +737,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -750,32 +750,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/html-encoding-sniffer": { @@ -980,9 +980,9 @@ } }, "node_modules/undici": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", - "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { diff --git a/src/tweaks/controls/colour.ts b/src/tweaks/controls/colour.ts index d11f452..09222b5 100644 --- a/src/tweaks/controls/colour.ts +++ b/src/tweaks/controls/colour.ts @@ -31,7 +31,12 @@ const CHECKER = "repeating-conic-gradient(#6b6b6b 0% 25%, #9a9a9a 0% 50%) 0 0 / // colours back in their own notation, and an rgb-shaped parse of that echo mangled them // (the "3" in "display-p3" read as a channel + alpha 0; lab() channels read as 0–255; // a `deg` unit or negative hue fell off the old regex onto the same path). -const COLOR_FN_SPACES = { srgb: "srgb", "display-p3": "p3", rec2020: "rec2020", "prophoto-rgb": "prophoto-rgb" }; +// Null-prototype: the space name comes straight out of the parsed string, so an inherited +// key had to miss. `color(constructor …)` / `color(__proto__ …)` resolved to a member of +// Object.prototype — truthy, so it sailed past the `if (!space)` guard below and rode into +// convert(), whose space switch matched nothing and handed `undefined` to the XYZ maths: +// a TypeError thrown straight out of panel.set() / fromJSON() / a persisted restore. +const COLOR_FN_SPACES = Object.assign(Object.create(null), { srgb: "srgb", "display-p3": "p3", rec2020: "rec2020", "prophoto-rgb": "prophoto-rgb" }); const parseAngle = (t) => { const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)(deg|grad|rad|turn)$/i.exec(t); if (!m) return num(parseFloat(t)); const n = parseFloat(m[1]); return m[2].toLowerCase() === "turn" ? n * 360 : m[2].toLowerCase() === "grad" ? n * 0.9 : m[2].toLowerCase() === "rad" ? (n * 180) / Math.PI : n; }; function parseColor(str) { str = String(str == null ? "" : str).trim(); diff --git a/src/tweaks/controls/monitor.ts b/src/tweaks/controls/monitor.ts index 392f1ea..e9f6edb 100644 --- a/src/tweaks/controls/monitor.ts +++ b/src/tweaks/controls/monitor.ts @@ -9,8 +9,13 @@ function createFps(meta) { wrap.append(txt("span", "tw-fps-label", meta.label || "FPS"), val, canvas); const ctx = canvas.getContext("2d"); const N = 80, samples = new Array(N).fill(0), MAX = 120; - let i = 0, last = 0, raf = 0, w = 0, h = 0, wasConnected = false; + let i = 0, last = 0, raf = 0, w = 0, h = 0, wasConnected = false, stopped = false; const resize = () => { [w, h] = fitCanvas(canvas, ctx, 2); }; + // Release everything: the rAF loop and its two listeners. Called on a real unmount + // (below) AND handed to the panel as the blade's `destroy`, so a panel torn down + // before it ever connected — which the "never mounted yet" branch below deliberately + // idles through, so it can never self-stop — doesn't leave the loop spinning forever. + const stop = () => { stopped = true; if (raf) cancelAnimationFrame(raf); raf = 0; window.removeEventListener("resize", resize); window.removeEventListener("tw-reflow", resize); }; const draw = () => { if (!w) return; ctx.clearRect(0, 0, w, h); @@ -27,18 +32,18 @@ function createFps(meta) { if (!canvas.isConnected) { // "Never mounted yet" (a host builds the panel eagerly, appends panel.el later) is // not "removed": idle cheaply until the first connected tick; only a real unmount - // stops the loop + its listeners for good. - if (wasConnected) { window.removeEventListener("resize", resize); window.removeEventListener("tw-reflow", resize); raf = 0; return; } + // (or panel.destroy(), via the blade's `destroy`) stops the loop + its listeners. + if (wasConnected) { stop(); return; } last = 0; raf = requestAnimationFrame(tick); return; } if (!wasConnected) { wasConnected = true; resize(); } // first connected tick → fit the canvas (it measured 0 detached) if (last) { const fps = 1000 / (now - last); samples[i] = fps; i = (i + 1) % N; val.textContent = Math.round(fps); draw(); } last = now; raf = requestAnimationFrame(tick); }; - requestAnimationFrame(() => { resize(); raf = requestAnimationFrame(tick); }); + raf = requestAnimationFrame(() => { if (stopped) return; resize(); raf = requestAnimationFrame(tick); }); // held in `raf` (and re-checked) so a destroy() before the first frame can't start the loop behind it window.addEventListener("resize", resize); window.addEventListener("tw-reflow", resize); // a tab page revealing this control re-fits the canvas (it measured 0 while hidden) - return blade(wrap); + return blade(wrap, stop); } // ── Monitor — poll any getter on an interval and show it: a number as a sparkline @@ -58,9 +63,11 @@ function createMonitor(meta) { let timer = 0, onResize = () => {}, wasConnected = false; const fmt = (v) => (typeof v === "number" ? (Number.isInteger(v) ? String(v) : v.toFixed(meta.decimals ?? 2)) : String(v)); + // Also handed to the panel as the blade's `destroy` — a panel destroyed before it ever + // connected idles below forever, so it could never clear its own interval on unmount. const stop = () => { if (timer) clearInterval(timer); timer = 0; window.removeEventListener("resize", onResize); window.removeEventListener("tw-reflow", onResize); }; // "Never mounted yet" (a host appends panel.el after building) idles the tick; only a - // panel that was mounted and then removed stops the poll for good. + // panel that was mounted and then removed — or a panel.destroy() — stops the poll. const poll = (fn) => { timer = setInterval(() => { if (!wrap.isConnected) { if (wasConnected) stop(); return; } wasConnected = true; let v; try { v = get(); } catch { return; } fn(v); }, interval); }; // String buffer (multiline) — the last `rows` values, newest at the bottom. @@ -73,10 +80,10 @@ function createMonitor(meta) { wrap.append(buf); const lines = []; poll((v) => { lines.push(fmt(v)); while (lines.length > rows) lines.shift(); buf.textContent = lines.join("\n"); }); - return blade(wrap); + return blade(wrap, stop); } // Plain readout — just the latest value, refreshed on the interval. - if (!graph) { poll((v) => { val.textContent = fmt(v); }); return blade(wrap); } + if (!graph) { poll((v) => { val.textContent = fmt(v); }); return blade(wrap, stop); } // Sparkline (numbers). const canvas = document.createElement("canvas"); canvas.className = "tw-fps-canvas"; @@ -113,7 +120,7 @@ function createMonitor(meta) { requestAnimationFrame(() => { onResize(); draw(); }); window.addEventListener("resize", onResize); window.addEventListener("tw-reflow", onResize); // a tab page revealing this control re-fits the canvas (it measured 0 while hidden) - return blade(wrap); + return blade(wrap, stop); } registerControl("fpsgraph", createFps); diff --git a/src/tweaks/controls/plot.ts b/src/tweaks/controls/plot.ts index 672b780..5f99584 100644 --- a/src/tweaks/controls/plot.ts +++ b/src/tweaks/controls/plot.ts @@ -129,7 +129,7 @@ function createPlot(meta, onChange) { const field = el("div", "tw-plot-field"); input = el("input", "tw-plot-input"); input.type = "text"; input.value = expr; input.spellcheck = false; input.autocapitalize = "off"; input.autocomplete = "off"; input.setAttribute("aria-label", `${meta.label || "Plot"} — expression in x`); - field.append(el("span", "tw-plot-fx", "y ="), input); root.append(field); + field.append(txt("span", "tw-plot-fx", "y ="), input); root.append(field); // txt, not el(…, html): el's third arg is innerHTML, and no label in the kit goes through that door } const PAD = 6; diff --git a/src/tweaks/core.ts b/src/tweaks/core.ts index e2b37f0..a21bf4a 100644 --- a/src/tweaks/core.ts +++ b/src/tweaks/core.ts @@ -791,12 +791,24 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): // value/UI collectors exist; the stubs below cover the lazy window before ready — toJSON // returns the live values (no UI state yet); fromJSON enqueues onto preSets (tagged), so it // replays interleaved with set()/setMany() in call order, after the persist/preset restore. - const FROMJSON = Symbol("fromJSON"), SETMANY = Symbol("setMany"); + const FROMJSON = Symbol("fromJSON"), SETMANY = Symbol("setMany"), RESET = Symbol("reset"); let doToJSON: () => any = () => { const v = JSON.parse(JSON.stringify(params)); delete v._last; return { values: v, ui: {} }; }; let doFromJSON: (state: any) => void = (state) => { preSets.push([FROMJSON, state]); }; // Each listener runs isolated: a throwing on() callback (or internal listener) // can't break the others, skip persist(), or bubble back out through set(). const notify = () => { listeners.forEach((fn) => { try { fn(params, params._last); } catch (e) { console.error("[tweaks] listener threw:", e); } }); persist(); }; + // The reset itself, independent of the toolbar button — so api.reset() can run it + // directly (the button is `disabled` until assemble(), and .click() is a no-op on a + // disabled control, which silently swallowed every reset() made in the lazy window) + // and so the queued replay below can too. Per-entry isolation like applySnapshot. + const doReset = () => { + if (typeof opts.onReset === "function") return opts.onReset(); + for (const e of entries) { + try { e.set(e.def); e.target[e.key] = e.get(); } + catch (err) { console.error(`[tweaks] resetting "${e.path.join(".")}" failed — control skipped:`, err); } + } + params._last = undefined; notify(); + }; // Persistence + presets storage keys — opt-in via opts.persist (a string key, or // `true` to key by the panel name). null disables both (existing callers unaffected). const persistKey = opts.persist ? `tw:${opts.persist === true ? name : opts.persist}` : null; @@ -828,7 +840,10 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): } // Filter (opts.filter): a search button swaps the title for an input; typing hides // controls whose label doesn't match (folders stay if their title or a child does). - const filterOn = !!opts.filter; + // The filter lives behind a toolbar button, so it needs a toolbar: with toolbar:false + // the button was never mounted and nothing could flip .is-searching — the input sat in + // the header, permanently invisible, with the whole search index built behind it. + const filterOn = !!opts.filter && (opts.toolbar !== false || (console.warn("[tweaks] opts.filter needs the toolbar (its search button lives there) — ignored alongside toolbar:false"), false)); const searchBtn = filterOn ? toolbarBtn("", ICON_SEARCH, "Filter controls") : null; const searchInput = filterOn ? el("input", "tw-search") : null; if (filterOn) { @@ -912,6 +927,19 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): t.addEventListener("contextmenu", (e) => { if (held) e.preventDefault(); }); // a long-press mustn't raise the text callout t.addEventListener("click", (e) => { if (held) { e.preventDefault(); e.stopImmediatePropagation(); held = false; } }, true); // a completed hold-reset swallows the trailing tap-to-edit t.addEventListener("dblclick", (e) => { e.preventDefault(); e.stopPropagation(); resetEntry(entry); }); + // The slider's readout is BOTH the reset target and the click-to-type trigger, and + // the two collided: once an 800ms hover armed editing — exactly what a deliberate + // double-click does first — click #1 swapped the readout for the inline input, so the + // dblclick landed on the input and the reset never fired. Catch it there too: commit + // and close the editor (blur), then reset. Delegated on the control root because the + // input doesn't exist until that first click creates it. + if (t.classList.contains("tw-slider-value")) root.addEventListener("dblclick", (e) => { + const inp = e.target; + if (!inp.classList || !inp.classList.contains("tw-slider-input")) return; + e.preventDefault(); e.stopPropagation(); + inp.blur(); // restores the readout via the input's own commit — a no-op value change, so it can't notify + resetEntry(entry); + }); }; // Conditional controls — `render: (get) => bool` shows/hides; `disabled` (boolean @@ -925,6 +953,12 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): const filterItems = [], filterFolders = []; // searchable index (opts.filter) keyed on each control's real label const folderEls: any[] = [], tabsCtrls: any[] = []; // folder + tabs handles keyed by path — read/restored as UI state by toJSON/fromJSON + // A control that runs its own loop (the monitor's poll, the FPS graph's rAF) hands back + // a `destroy`; take it into the panel's cleanups so destroy() releases it. Those loops + // otherwise only stop on unmount, and they deliberately idle through the "built but not + // appended yet" window — so a panel destroyed before it ever connected ran forever. + const adopt = (ctrl) => { if (typeof ctrl.destroy === "function") cleanups.push(ctrl.destroy); }; + // Build controls into a container, recursing into folders (nested params). const build = (container, ms, target, basePath = [], folderItem = null) => { for (const m of ms) { @@ -947,9 +981,10 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): build(f.body, m.children, sub, [...basePath, m.key], fi); registerCond(f.el, m); container.append(f.el); continue; } - if (VALUELESS.has(m.type)) { const ctrl = createControl(m, () => {}); if (ctrl) { if (filterOn && m.type !== "separator") filterItems.push({ el: ctrl.el, label: m.label, folder: folderItem }); registerCond(ctrl.el, m); container.append(ctrl.el); } continue; } + if (VALUELESS.has(m.type)) { const ctrl = createControl(m, () => {}); if (ctrl) { adopt(ctrl); if (filterOn && m.type !== "separator") filterItems.push({ el: ctrl.el, label: m.label, folder: folderItem }); registerCond(ctrl.el, m); container.append(ctrl.el); } continue; } const ctrl = createControl(m, (v) => { if (!valueChanged(target[m.key], v)) return; target[m.key] = v; params._last = m.key; notify(); }); // same-value emits (a discrete drag inside one detent, a re-entrant echo) don't notify if (!ctrl) continue; + adopt(ctrl); // A value a host parked on params directly before assemble ran (the lazy-load // window on the split build) wins over the schema default — apply it to the // control rather than clobbering it back with ctrl.get(). (API set() calls from @@ -1016,9 +1051,19 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): const atPath = (obj, path) => path.reduce((o, k) => (o == null ? undefined : o[k]), obj); const stripLast = function (k, v) { return k === "_last" && this === params ? undefined : v; }; // function, not arrow: `this` is the holder, so only the top-level changed-key channel strips — a folder child legitimately keyed "_last" survives const snapshot = () => JSON.parse(JSON.stringify(params, stripLast)); + // Per-entry isolation, the kit-wide degrade idiom (createControl, metaFor, notify, + // applyConditionals all do the same): a control whose set() throws on hostile stored + // data — a corrupt localStorage snapshot, a hand-edited preset, a fromJSON from + // elsewhere — must cost only its own value. Unguarded, one throw abandoned every + // remaining entry AND skipped the notify/persist below, leaving the panel silently + // half-restored with listeners none the wiser. const applySnapshot = (snap, fire = true) => { if (!snap || typeof snap !== "object") return; - for (const e of entries) { const v = atPath(snap, e.path); if (v !== undefined) { e.set(v); e.target[e.key] = e.get(); } } + for (const e of entries) { + const v = atPath(snap, e.path); if (v === undefined) continue; + try { e.set(v); e.target[e.key] = e.get(); } + catch (err) { console.error(`[tweaks] restoring "${e.path.join(".")}" failed — value skipped:`, err); } + } params._last = undefined; if (fire) notify(); }; if (persistKey) { @@ -1113,6 +1158,14 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): }); header.addEventListener("pointermove", (e) => { if (e.pointerId !== dragId) return; + // Released where we never hear it — the press hadn't crossed the 4px threshold yet, + // so no pointer was captured and a pointerup just off the header (a hair of drift + // onto the body, or the page scrolling out from under a held button) never reaches + // endDrag. The button is up but dragId is still ours, so the NEXT plain hover across + // the header would pass the threshold against the stale origin and lift the panel + // into a drag with nothing pressed. Bail the same way every other drag surface in + // the kit does (the slider, the interval, dragGesture). + if (e.buttons === 0) { endDrag(e); return; } const dx = e.clientX - sx, dy = e.clientY - sy; if (!dragMoved) { if (Math.abs(dx) + Math.abs(dy) < 4) return; // a few px of slop before it counts as a drag, not a click @@ -1191,11 +1244,7 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): // A host can supply its own reset (e.g. restore real app defaults + rebuild); // otherwise reset each control to the default it was built with. The icon spins // once on click — motion feedback to match the copy swap. - resetBtn.addEventListener("click", () => { - spinReset(resetBtn); - if (typeof opts.onReset === "function") return opts.onReset(); - entries.forEach((e) => { e.set(e.def); e.target[e.key] = e.get(); }); params._last = undefined; notify(); - }); + resetBtn.addEventListener("click", () => { spinReset(resetBtn); doReset(); }); // ── Edit lifecycle (opts.onEditStart / onEditEnd) — fired when a drag/scrub on any // in-panel control begins and ends, so a host can pause expensive work during a @@ -1224,12 +1273,17 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): // ⇧ (or Ctrl-Y) redoes, scoped to when the panel is hovered or focused so it doesn't // hijack the page's own undo. A continuous drag coalesces into one step. ── if (opts.undo) { + // Bounded: each step is a deep clone of every value, and a long tuning session + // committed one every 350ms of editing with nothing ever dropping off the back. + const HIST_MAX = 200; let history = [snapshot()], histIdx = 0, applyingHistory = false, histTimer = 0; const commit = () => { histTimer = 0; const snap = snapshot(); if (JSON.stringify(snap) === JSON.stringify(history[histIdx])) return; // unchanged - history = history.slice(0, histIdx + 1); history.push(snap); histIdx = history.length - 1; // a new edit drops the redo branch + history = history.slice(0, histIdx + 1); history.push(snap); // a new edit drops the redo branch + if (history.length > HIST_MAX) history = history.slice(history.length - HIST_MAX); // oldest steps age out + histIdx = history.length - 1; }; const record = () => { if (applyingHistory) return; clearTimeout(histTimer); histTimer = setTimeout(commit, 350); }; const flush = () => { if (histTimer) { clearTimeout(histTimer); commit(); } }; // commit a pending edit first, so ⌘Z right after a change still undoes it @@ -1252,7 +1306,7 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): // restore above, so an explicit host set() wins over a stored value the way it wins // over the schema default. Each replays through api.set, so paths resolve against // the real entries and listeners hear the changes. - for (const [k, v] of preSets.splice(0)) { if (k === FROMJSON) doFromJSON(v); else if (k === SETMANY) api.setMany(v); else api.set(k as string, v); } // tagged fromJSON/setMany entries replay through their assembled impls (one notify each); the shared queue preserves set/setMany/fromJSON call order + for (const [k, v] of preSets.splice(0)) { if (k === FROMJSON) doFromJSON(v); else if (k === SETMANY) api.setMany(v); else if (k === RESET) doReset(); else api.set(k as string, v); } // tagged fromJSON/setMany/reset entries replay through their assembled impls (one notify each); the shared queue preserves call order across all four for (const b of [copyBtn, resetBtn, presetsBtn, searchBtn]) if (b) b.disabled = false; // the toolbar's handlers are live now }; // end assemble @@ -1285,7 +1339,10 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): } const target = e ? e.target : params, leaf = e ? e.key : key; const prev = target[leaf]; - if (e) { e.set(v); target[leaf] = e.get(); } + // Same isolation as applySnapshot: a control that throws on a hostile value degrades + // to "that key didn't take" instead of throwing out of set() — and, in setMany's loop, + // instead of abandoning the rest of the batch and its single notify. + if (e) { try { e.set(v); target[leaf] = e.get(); } catch (err) { console.error(`[tweaks] set("${key}") failed — value skipped:`, err); return false; } } else if (subTrees.has(params[key])) { console.warn(`[tweaks] set("${key}") ignored — it's a folder/tabs group; set its children instead`); return false; } // overwriting the subtree would silently orphan every child value else params[key] = v; // bag passthrough — hosts park free keys on params if (!valueChanged(prev, target[leaf])) return false; // a no-change set doesn't notify — the guard that keeps a store-sync listener from echoing forever @@ -1320,7 +1377,14 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): for (const k of Object.keys(values)) { if (applySet(k, values[k])) changed = true; } if (changed) notify(); }, - reset() { if (!destroyed) resetBtn.click(); }, + // Queues in the lazy window like set()/setMany()/fromJSON(), so it replays in call + // order with them rather than vanishing (it used to route through resetBtn.click(), + // and the toolbar buttons are disabled until assemble()). + reset() { + if (destroyed) return; + if (!assembled) return void preSets.push([RESET, null]); + spinReset(resetBtn); doReset(); + }, // Whole-panel state — values + UI (open folders, active tabs) as a plain JSON-safe // object, independent of localStorage. `JSON.stringify(panel)` works too (this is the // standard toJSON hook). fromJSON applies a previously-saved object back. @@ -1390,6 +1454,15 @@ const DATA_VALUE: Record any> = { }, plot: (d) => ({ expr: d.expr, xMin: num(d.xmin), xMax: num(d.xmax), yMin: num(d.ymin), yMax: num(d.ymax), samples: num(d.samples), editable: d.editable !== "false" }), }; +// Echo a control's value back onto its host's data-value, in the comma form the parsers +// above read (interval / cubicbezier / point all split data-value on ","). An array +// already stringifies that way; a plain object — the point's component map, the spring's +// config — used to hit the default toString and write the literal "[object Object]", so +// take its values in declaration order instead. Order matches the components/channels the +// control was built from, so a point round-trips exactly. +const writeDataValue = (host, v) => { + host.dataset.value = v == null ? "" : Array.isArray(v) ? v.join(",") : typeof v === "object" ? Object.values(v).join(",") : String(v); +}; const num = (s) => (s == null ? undefined : +s); // absent attribute → undefined, so the schema default applies const flag = (s) => s === "true" || s === ""; // boolean attributes: data-x / data-x="true" const splitList = (s) => (s || "").split(",").map((t) => t.trim()).filter(Boolean); @@ -1429,14 +1502,21 @@ export async function enhance(root: Document | Element = document): Promise [...panel.querySelectorAll("[data-tw]")].map((h: any) => h._tw).filter((t: any) => t && t.ctrl.get() !== undefined); copyBtn.addEventListener("click", async () => { - const vals = {}; for (const t of live()) vals[t.key] = t.ctrl.get(); + // Two controls can legitimately share a key (a data-key repeated, or two hosts + // with the same label and no data-key at all) — suffix the duplicates instead of + // letting the later one overwrite the earlier and drop a value from the copy. + const vals = {}; + for (const t of live()) { + let k = t.key, n = 2; while (hasOwn(vals, k)) k = `${t.key}-${n++}`; + vals[k] = t.ctrl.get(); + } const ok = await copyText(JSON.stringify(vals, null, 2)); if (ok) { flashCopied(copyBtn); showToast(`${name} values copied`, panel); } else showToast("Copy failed", panel); }); resetBtn.addEventListener("click", () => { spinReset(resetBtn); - for (const t of live()) { t.ctrl.set(t.def); t.host.dataset.value = t.ctrl.get(); } + for (const t of live()) { t.ctrl.set(t.def); writeDataValue(t.host, t.ctrl.get()); } }); } }); @@ -1457,8 +1537,10 @@ export async function enhance(root: Document | Element = document): Promise x.meta)); if (pend) await pend.catch(() => {}); // a failed chunk degrades to skipping its controls (createControl finds no constructor), not an unhandled rejection out of the auto-run for (const { host, meta } of hosts) { - const ctrl = createControl(meta, (v) => (host.dataset.value = v)); - if (ctrl) { host.append(ctrl.el); if (host.dataset.hint) addHintMarker(ctrl.el, host.dataset.hint); host._tw = { ctrl, def: meta.value, key: meta.label, host }; } + const ctrl = createControl(meta, (v) => writeDataValue(host, v)); + // The copy key is the host's own data-key when it has one — the attribute the markup + // already names the control by — falling back to the label the way dataMeta does. + if (ctrl) { host.append(ctrl.el); if (host.dataset.hint) addHintMarker(ctrl.el, host.dataset.hint); host._tw = { ctrl, def: meta.value, key: host.dataset.key || meta.label, host }; } } } diff --git a/src/tweaks/shared.ts b/src/tweaks/shared.ts index c1fdc4b..cb83fbf 100644 --- a/src/tweaks/shared.ts +++ b/src/tweaks/shared.ts @@ -8,14 +8,18 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); // A colour-valued string: hex, or any CSS colour function (oklch/rgb/hsl/…). Used // to route a schema string to the colour control (a plain label stays a string). const isColorStr = (v) => typeof v === "string" && (/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v.trim()) || /^(oklch|oklab|rgba?|hsla?|hwb|lab|lch|color)\(/i.test(v.trim())); +// Capped at 100 — the ceiling toFixed() accepts. A sub-1e-100 step (finite, positive, so +// it clears every step guard) otherwise produced a digit count that threw RangeError out +// of roundToStep, which degraded the whole control to "skipped" at construction. +const MAX_FIXED = 100; const stepPrecision = (step) => { const t = String(step); // Scientific notation (e.g. 1e-7 → 7): String(1e-7) === "1e-7" has no ".", so a plain // index-of would wrongly report 0 decimals and round fine steps to whole numbers. const e = /e-(\d+)$/i.exec(t); - if (e) return Number(e[1]) + (t.split("e")[0].split(".")[1] || "").length; + if (e) return Math.min(MAX_FIXED, Number(e[1]) + (t.split("e")[0].split(".")[1] || "").length); const i = t.indexOf("."); - return i === -1 ? 0 : t.length - i - 1; + return i === -1 ? 0 : Math.min(MAX_FIXED, t.length - i - 1); }; const roundToStep = (v, min, step) => { if (!(step > 0)) return v; @@ -160,7 +164,10 @@ export const placeBelow = (trigger: any, pop: any, { width, fallbackH = 300, gap // so no theme === the default monochrome look, and partial themes only move what they name. // Friendly name → token. The full themeable surface — every lever the look runs on, // so a theme can reach all of it by readable name (raw "--tw-*" keys also pass through). -const THEME_ALIASES = { +// Null-prototype, like every other lookup table the kit indexes with caller data (the +// plot whitelist, the presets bag): a theme key of "constructor" / "toString" must MISS, +// not resolve to an inherited member and mint a garbage custom-property name. +const THEME_ALIASES = Object.assign(Object.create(null), { // colour — backdrops accent: "--tw-accent", onAccent: "--tw-on-accent", base: "--tw-base", dropdownBg: "--tw-dropdown-bg", surface: "--tw-surface", surfaceHover: "--tw-surface-hover", surfaceActive: "--tw-surface-active", @@ -173,7 +180,7 @@ const THEME_ALIASES = { shadow: "--tw-shadow-dropdown", shadowPanel: "--tw-shadow-panel", shadowPanelLifted: "--tw-shadow-panel-lifted", // type + shape font: "--tw-font-sans", fontMono: "--tw-font-mono", radius: "--tw-radius", density: "--tw-row-height", // numeric → px -}; +}); const TW_PX_ALIASES = new Set(["radius", "density"]); // On-accent text (the active segment pill / radio cell sits a label on the accent). Pick // black or white by the accent's WCAG relative luminance, whichever contrasts more — so a @@ -477,7 +484,11 @@ const ICON_GRIP = ` ({ el, set: () => {}, get: () => undefined }); +// `destroy` is optional and only the live ones supply it (the monitor's poll, the FPS +// rAF loop): build() forwards it into the panel's cleanups, so panel.destroy() stops a +// control's own loops even when the panel never connected — the case those loops +// deliberately idle through, and so could never self-stop on unmount. +const blade = (el, destroy?: () => void) => ({ el, set: () => {}, get: () => undefined, destroy }); // ── Control registry — control type → constructor. Core controls register on // load; a lazy control registers when its module is dynamically imported. build() diff --git a/src/tweaks/types.ts b/src/tweaks/types.ts index 901fd4f..25848cb 100644 --- a/src/tweaks/types.ts +++ b/src/tweaks/types.ts @@ -203,4 +203,7 @@ export interface Control { el: HTMLElement; set(value: unknown): void; get(): unknown; + /** Release any loop the control owns (the monitor's poll, the FPS graph's rAF). Only + * the live controls supply it; the panel calls it from `destroy()`. */ + destroy?(): void; } diff --git a/src/wide-gamut.ts b/src/wide-gamut.ts index 8c3e051..a2efe71 100644 --- a/src/wide-gamut.ts +++ b/src/wide-gamut.ts @@ -211,12 +211,15 @@ const modeSpaceId = (mode) => (mode === "hex" || mode === "css" ? "srgb" : mode) // mode blends through, so the editor's ramp shows what the chosen colour technology // actually does to a blend. Narrow notations (hex / css / rgb) all blend in srgb; p3's // CSS keyword is `display-p3`; every other mode interpolates in its own space. -const MODE_INTERP = { hex: "srgb", srgb: "srgb", css: "srgb", hsl: "hsl", hwb: "hwb", oklch: "oklch", oklab: "oklab", lch: "lch", lab: "lab", p3: "display-p3", rec2020: "rec2020" }; +// Null-prototype (the kit-wide rule for a table indexed with caller data): a mode or +// interpolation string of "constructor" must miss and fall to the default, not resolve +// to an inherited member. +const MODE_INTERP = Object.assign(Object.create(null), { hex: "srgb", srgb: "srgb", css: "srgb", hsl: "hsl", hwb: "hwb", oklch: "oklch", oklab: "oklab", lch: "lch", lab: "lab", p3: "display-p3", rec2020: "rec2020" }); export const modeInterpolation = (mode) => MODE_INTERP[mode] || "oklch"; // The reverse, for opening a stored gradient in the mode that produced its blend // (srgb → RGB channels, display-p3 → P3). `srgb` is the canonical mode for the three // narrow notations; an unknown / absent space falls back to OKLCH, the editor default. -const INTERP_MODE = { srgb: "srgb", hsl: "hsl", hwb: "hwb", oklch: "oklch", oklab: "oklab", lch: "lch", lab: "lab", "display-p3": "p3", rec2020: "rec2020" }; +const INTERP_MODE = Object.assign(Object.create(null), { srgb: "srgb", hsl: "hsl", hwb: "hwb", oklch: "oklch", oklab: "oklab", lch: "lch", lab: "lab", "display-p3": "p3", rec2020: "rec2020" }); export const interpolationMode = (interp) => INTERP_MODE[interp] || "oklch"; const MAX_CHROMA = 0.5; export const MODE_CHANNELS = { diff --git a/test/lazy.test.mjs b/test/lazy.test.mjs index 1a11b32..7ca7581 100644 --- a/test/lazy.test.mjs +++ b/test/lazy.test.mjs @@ -43,6 +43,23 @@ test("during the lazy window, setMany() replays as ONE batch — a single notify assert.equal(lastKey, "b"); // _last reflects the final applied key }); +test("reset() during the lazy window replays, in order with the sets around it", async () => { + // Regression: reset() forwarded to the toolbar button, and the toolbar is disabled until + // assemble() — .click() is a no-op on a disabled control, so every reset() made in the + // lazy window vanished silently while set()/setMany()/fromJSON() all queued. + const p = tweaks("Reset", { x: [1, 0, 10, 1], s: { type: "spring", value: { stiffness: 100, damping: 12, mass: 1 } } }); + p.set("x", 7); + p.reset(); // queued after the set → must win + await p.ready; + assert.equal(p.params.x, 1, "the queued reset replayed after the set it followed"); + + const q = tweaks("Reset2", { x: [1, 0, 10, 1], s: { type: "spring", value: { stiffness: 100, damping: 12, mass: 1 } } }); + q.reset(); + q.set("x", 7); // queued after the reset → must win + await q.ready; + assert.equal(q.params.x, 7, "call order is preserved both ways"); +}); + test("ready resolves with the api on the warmed-up synchronous path too", async () => { const p = tweaks("Warm", { r: { type: "interval", value: [2, 8], min: 0, max: 10, step: 1 } }); const api = await p.ready; diff --git a/test/panel.test.mjs b/test/panel.test.mjs index 0faeadf..bc7801d 100644 --- a/test/panel.test.mjs +++ b/test/panel.test.mjs @@ -7,7 +7,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import "./_setup-dom.mjs"; -const { tweaks } = await import(new URL("../dist/tweaks.js", import.meta.url)); +const { tweaks, enhance } = await import(new URL("../dist/tweaks.js", import.meta.url)); test("a malformed verbose value degrades to a skipped control, not a thrown build", () => { const p = tweaks("T", { @@ -103,6 +103,102 @@ test("monitor: a negative `rows` doesn't spin the buffer trim into an infinite l p.destroy(); }); +test("destroy() releases a monitor's poll even on a panel that never mounted", async () => { + // Regression: the poll deliberately idles through the "built but not appended yet" + // window, so it never saw an unmount to stop on — a panel destroyed before it ever + // connected left the interval running for the life of the page. The control now hands + // its teardown to the panel, so destroy() reaches it. + const p = tweaks("MD", { m: { type: "monitor", get: () => 42, interval: 30, view: "text" } }); + p.destroy(); // never mounted + document.body.append(p.el); // re-attach the dead node: a live poll would find it connected and tick + await new Promise((r) => setTimeout(r, 150)); // several intervals at the 30ms floor + assert.equal(p.el.querySelector(".tw-fps-val").textContent, "—", "the poll was released, so it never wrote a value"); + p.el.remove(); +}); + +test("a hostile colour string is skipped, not thrown, and the rest of a restore still lands", () => { + // Regression, two halves: `color(constructor …)` resolved its space off Object.prototype + // — truthy, so it slipped past the unknown-space guard and reached the conversion maths + // as a non-space, throwing a TypeError out of set(); and applySnapshot ran its entries + // unguarded, so that one throw abandoned every later value AND the notify with it. + const p = tweaks("CX", { a: [1, 0, 10, 1], c: { type: "color", value: "#ff0000" }, z: [1, 0, 10, 1] }); + p.set("c", "color(constructor 1 0 0)"); // must not throw… + p.set("c", "color(__proto__ 1 0 0)"); // …nor this: an unknown color() space + const neutral = p.params.c; // degrades to the picker's neutral default, like any other unsupported space + assert.match(String(neutral), /^oklch\(/, "an unrecognised space degrades to a real colour"); + + let calls = 0; + p.on(() => calls++); + p.fromJSON({ values: { a: 5, c: "color(constructor 1 0 0)", z: 9 } }); + assert.equal(p.params.a, 5); + assert.equal(p.params.z, 9, "a bad value mid-restore doesn't abandon the entries after it"); + assert.equal(calls, 1, "the restore still notified exactly once"); +}); + +test("a slider survives a step too fine for toFixed", () => { + // Regression: stepPrecision(1e-101) → 101 decimals, past toFixed's 100-digit ceiling — + // a RangeError at construction that degraded the whole control to "skipped". + const p = tweaks("Fine", { x: { type: "slider", value: 0.5, min: 0, max: 1, step: 1e-101 } }); + assert.ok("x" in p.params, "control built"); + p.set("x", 0.25); + assert.equal(p.params.x, 0.25); +}); + +test("markup mode echoes an object-valued control as its components, not [object Object]", async () => { + // Regression: enhance() assigned ctrl.get() straight onto data-value, so the controls + // whose value is an object (point, spring) wrote the default toString — and data-value + // is the very attribute the markup parsers read a point back out of. + const holder = document.createElement("div"); + holder.innerHTML = `
`; + document.body.append(holder); + await enhance(holder); + const host = holder.querySelector('[data-tw="point"]'); + host._tw.ctrl.set({ x: 7, y: 9 }); + host._tw.ctrl.el.querySelector(".tw-num").dispatchEvent(new Event("change", { bubbles: true })); // nudge a field so the control emits + assert.ok(!/object Object/.test(host.dataset.value), `data-value stayed parseable, got "${host.dataset.value}"`); + assert.match(host.dataset.value, /^[\d.,-]+$/, "components, comma-joined — the form the point parser reads"); + holder.remove(); +}); + +test("markup mode's copy keys by data-key and suffixes duplicates", async () => { + // Regression: the copy keyed every control by its LABEL, so two same-labelled hosts + // collided and the later one silently overwrote the earlier in the copied JSON. + const holder = document.createElement("div"); + holder.innerHTML = `
P
+
+
+
+
`; + document.body.append(holder); + await enhance(holder); + assert.deepEqual([...holder.querySelectorAll("[data-tw]")].map((h) => h._tw.key), ["Size", "Size", "depth"], "data-key wins over the label when present"); + + // Drive the real copy button and read what it actually put on the clipboard. + let copied = null; + const clip = Object.getOwnPropertyDescriptor(globalThis.navigator, "clipboard"); + Object.defineProperty(globalThis.navigator, "clipboard", { value: { writeText: async (t) => { copied = t; } }, configurable: true }); + try { + holder.querySelector(".tw-toolbar-btn--swap").click(); + await new Promise((r) => setTimeout(r, 0)); // the click handler awaits the write + } finally { + if (clip) Object.defineProperty(globalThis.navigator, "clipboard", clip); + else delete globalThis.navigator.clipboard; + } + const vals = JSON.parse(copied); + assert.deepEqual(Object.keys(vals), ["Size", "Size-2", "depth"], "all three controls survive the copy"); + assert.deepEqual(Object.values(vals), [1, 2, 3], "each keeps its own value"); + holder.remove(); +}); + +test("opts.filter without a toolbar is refused, not built invisibly", () => { + // Regression: the search button lives in the toolbar, so toolbar:false left the filter + // input mounted in the header with nothing able to reveal it. + const p = tweaks("FT", { a: [1, 0, 10, 1] }, { filter: true, toolbar: false }); + document.body.append(p.el); + assert.equal(p.el.querySelector(".tw-search"), null); + p.destroy(); +}); + test("setMany applies a batch across folders and notifies once", () => { const p = tweaks("SM", { a: [1, 0, 10, 1], b: [1, 0, 10, 1], folder: { c: [1, 0, 10, 1] } }); let calls = 0, lastKey; @@ -215,6 +311,53 @@ test("fromJSON applies known value paths, skips stale ones, and notifies once", assert.equal(calls, 1); // one notification for the whole restore }); +// Synthetic pointer event — jsdom has no PointerEvent constructor, and the drag paths +// only read pointerId / button / buttons / clientX / clientY off it. +const ptr = (type, props) => Object.assign(new Event(type, { bubbles: true, cancelable: true }), { pointerId: 1, button: 0, buttons: 1, pointerType: "mouse", ...props }); + +test("a header press released off the header can't leave the panel dragging on hover", () => { + // Regression: pointer capture is only taken once the press crosses the 4px drag + // threshold, so a release just off the header (a hair of drift onto the body, or the + // page scrolling under a held button) never reached endDrag and left dragId set. The + // next plain hover then passed the threshold against the stale origin and lifted the + // panel into a drag with no button held. + const p = tweaks("Drag", { a: [1, 0, 10, 1] }); + document.body.append(p.el); + const header = p.el.querySelector(".tw-header"), body = p.el.querySelector(".tw-body"); + header.setPointerCapture = header.releasePointerCapture = () => {}; + + header.dispatchEvent(ptr("pointerdown", { clientX: 100, clientY: 10 })); + header.dispatchEvent(ptr("pointermove", { clientX: 101, clientY: 12 })); // 3px — under the threshold, so no capture + body.dispatchEvent(ptr("pointerup", { clientX: 101, clientY: 13, buttons: 0 })); // lands off the header + header.dispatchEvent(ptr("pointermove", { clientX: 400, clientY: 10, buttons: 0 })); // a plain hover, nothing pressed + + assert.equal(p.el.dataset.mode, "inline", "the panel stayed put instead of lifting into a floating drag"); + assert.ok(!p.el.classList.contains("is-dragging")); + p.destroy(); +}); + +test("double-clicking a slider readout resets it even once the hover armed the editor", async () => { + // Regression: the readout is both the reset target and the click-to-type trigger. After + // an 800ms hover — exactly what precedes a deliberate double-click — click #1 swapped it + // for the inline input, so the dblclick landed on the input and the reset never fired. + const p = tweaks("DblEdit", { x: [5, 0, 100, 1] }); + document.body.append(p.el); + p.set("x", 42); + const val = p.el.querySelector(".tw-slider-value"); + val.dispatchEvent(new Event("mouseenter", { bubbles: true })); + await new Promise((r) => setTimeout(r, 850)); // arm the hover-to-edit gate + assert.ok(val.classList.contains("is-editable"), "editing armed"); + + val.dispatchEvent(new Event("click", { bubbles: true })); // click #1 opens the editor… + const input = p.el.querySelector(".tw-slider-input"); + assert.ok(input, "the inline editor opened"); + input.dispatchEvent(new Event("dblclick", { bubbles: true })); // …so the dblclick retargets to it + + assert.equal(p.params.x, 5, "reset to the schema default"); + assert.equal(p.el.querySelector(".tw-slider-input"), null, "the editor closed behind it"); + p.destroy(); +}); + test("text-field focus is quiet after a pointer press, ringed after a key press", () => { const p = tweaks("F", { note: "hello" }); document.body.append(p.el);