From dd8bac8aabfb39a37c0b314b065ce2a1cb319597 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 1 May 2026 15:08:04 -0500 Subject: [PATCH 1/7] refactor: extract shared viz assets and clean up python footer --- .gitignore | 4 + Makefile | 15 + js/build.mjs | 149 ++++++ js/check.mjs | 83 ++++ js/package-lock.json | 458 ++++++++++++++++++ js/package.json | 13 + js/src/viz-core.ts | 219 +++++++++ js/src/viz-py.ts | 3 + js/src/viz-r.ts | 3 + js/src/viz.css | 141 ++++++ js/tsconfig.json | 13 + pkg-py/src/querychat/_utils.py | 2 + pkg-py/src/querychat/_viz_tools.py | 19 +- pkg-py/src/querychat/static/css/viz.css | 3 +- pkg-py/src/querychat/static/js/viz.js | 281 ++++++----- pkg-py/tests/playwright/test_11_viz_footer.py | 40 ++ pkg-py/tests/test_viz_footer.py | 17 + 17 files changed, 1336 insertions(+), 127 deletions(-) create mode 100644 js/build.mjs create mode 100644 js/check.mjs create mode 100644 js/package-lock.json create mode 100644 js/package.json create mode 100644 js/src/viz-core.ts create mode 100644 js/src/viz-py.ts create mode 100644 js/src/viz-r.ts create mode 100644 js/src/viz.css create mode 100644 js/tsconfig.json diff --git a/.gitignore b/.gitignore index a06bad8c..df712fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -276,5 +276,9 @@ pkg-py/docs/_screenshots/ # Git worktrees .worktrees/ +js/node_modules/ + +# Superpowers docs (local only) +docs/superpowers/ /.luarc.json diff --git a/Makefile b/Makefile index 6e86f085..4e2ac012 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,21 @@ docs: r-docs py-docs-render ## [docs] Build the documentation # @echo "🧳 Building JS code in watch mode" # cd $(PATH_PKG_JS) && npm run watch +.PHONY: js-setup +js-setup: ## [js] Install shared web asset dependencies + @echo "🆙 Setup shared web asset dependencies" + cd $(PATH_PKG_JS) && npm ci + +.PHONY: js-build +js-build: ## [js] Build shared web assets + @echo "🧳 Building shared web assets" + cd $(PATH_PKG_JS) && npm run build + +.PHONY: js-check +js-check: ## [js] Check shared web assets + @echo "📐 Checking shared web assets" + cd $(PATH_PKG_JS) && npm run check + .PHONY: r-setup r-setup: ## [r] Install R dependencies @echo "🆙 Updating R dependencies" diff --git a/js/build.mjs b/js/build.mjs new file mode 100644 index 00000000..5aa0bff9 --- /dev/null +++ b/js/build.mjs @@ -0,0 +1,149 @@ +import { build } from "esbuild"; +import { + access, + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.dirname(fileURLToPath(import.meta.url)); +export const repoDir = path.resolve(rootDir, ".."); + +const jsTargets = [ + { + source: "src/viz-r.ts", + output: "../pkg-r/inst/htmldep/viz.js", + }, + { + source: "src/viz-py.ts", + output: "../pkg-py/src/querychat/static/js/viz.js", + }, +]; + +const cssTargets = [ + { + source: "src/viz.css", + output: "../pkg-r/inst/htmldep/viz.css", + }, + { + source: "src/viz.css", + output: "../pkg-py/src/querychat/static/css/viz.css", + }, +]; + +const ensureParentDir = async (relativePath) => { + const absolutePath = path.resolve(rootDir, relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + return absolutePath; +}; + +export const assetTargets = [...cssTargets, ...jsTargets]; + +export const resolveOutputPath = (baseDir, relativePath) => + path.resolve(baseDir, path.relative(repoDir, path.resolve(rootDir, relativePath))); + +const banner = (source) => + `/* Generated file. Source: js/${source}. Do not edit directly. */\n`; + +const uniqueSources = (targets) => [...new Set(targets.map((target) => target.source))]; + +const findMissingSources = async (targets) => { + const missingSources = []; + + for (const source of uniqueSources(targets)) { + try { + await access(path.resolve(rootDir, source)); + } catch { + missingSources.push(`js/${source}`); + } + } + + return missingSources; +}; + +const reportMissingSources = async () => { + const missingCssSources = await findMissingSources(cssTargets); + const missingJsSources = await findMissingSources(jsTargets); + + if (missingCssSources.length === 0 && missingJsSources.length === 0) { + return; + } + + const messages = []; + + if (missingCssSources.length > 0) { + messages.push(`Missing CSS source files:\n- ${missingCssSources.join("\n- ")}`); + } + + if (missingJsSources.length > 0) { + messages.push(`Missing JS source files:\n- ${missingJsSources.join("\n- ")}`); + } + + throw new Error(messages.join("\n\n")); +}; + +export const stageBuildOutputs = async (stageDir) => { + const cssSourcePath = path.resolve(rootDir, "src/viz.css"); + const cssSource = await readFile(cssSourcePath, "utf8"); + + for (const target of cssTargets) { + const outputPath = resolveOutputPath(stageDir, target.output); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${banner(target.source)}${cssSource}`, "utf8"); + } + + for (const target of jsTargets) { + const outputPath = resolveOutputPath(stageDir, target.output); + await mkdir(path.dirname(outputPath), { recursive: true }); + await build({ + bundle: true, + entryPoints: [path.resolve(rootDir, target.source)], + format: "iife", + logLevel: "info", + outfile: outputPath, + platform: "browser", + target: "es2020", + banner: { + js: banner(target.source), + }, + }); + } +}; + +export const commitBuildOutputs = async (stageDir) => { + for (const target of assetTargets) { + const stagedOutputPath = resolveOutputPath(stageDir, target.output); + await ensureParentDir(target.output); + await copyFile(stagedOutputPath, path.resolve(rootDir, target.output)); + } +}; + +export async function withStagedBuild(callback) { + await reportMissingSources(); + + const stageDir = await mkdtemp(path.join(os.tmpdir(), "querychat-build-")); + + try { + await stageBuildOutputs(stageDir); + return await callback(stageDir); + } finally { + await rm(stageDir, { force: true, recursive: true }); + } +} + +export async function buildOutputs() { + await withStagedBuild(commitBuildOutputs); +} + +const isEntrypoint = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isEntrypoint) { + await buildOutputs(); +} diff --git a/js/check.mjs b/js/check.mjs new file mode 100644 index 00000000..2600c255 --- /dev/null +++ b/js/check.mjs @@ -0,0 +1,83 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import ts from "typescript"; +import { assetTargets, repoDir, resolveOutputPath, withStagedBuild } from "./build.mjs"; + +const configPath = "tsconfig.json"; +const formatHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: ts.sys.getCurrentDirectory, + getNewLine: () => ts.sys.newLine, +}; + +const readResult = ts.readConfigFile(configPath, ts.sys.readFile); +if (readResult.error) { + console.error(ts.formatDiagnosticsWithColorAndContext([readResult.error], formatHost)); + process.exit(1); +} + +const parsedConfig = ts.parseJsonConfigFileContent( + readResult.config, + ts.sys, + process.cwd(), + undefined, + configPath, +); + +const configErrors = parsedConfig.errors.filter((error) => error.code !== 18003); +if (configErrors.length > 0) { + console.error(ts.formatDiagnosticsWithColorAndContext(configErrors, formatHost)); + process.exit(1); +} + +if (parsedConfig.fileNames.length > 0) { + const program = ts.createProgram({ + options: parsedConfig.options, + rootNames: parsedConfig.fileNames, + }); + + const diagnostics = ts.getPreEmitDiagnostics(program); + if (diagnostics.length > 0) { + console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost)); + process.exit(1); + } +} + +const staleOutputs = []; + +await withStagedBuild(async (stageDir) => { + for (const target of assetTargets) { + const stagedOutputPath = resolveOutputPath(stageDir, target.output); + const committedOutputPath = resolveOutputPath(repoDir, target.output); + const relativeOutputPath = path.relative(repoDir, committedOutputPath); + + let stagedOutput; + let committedOutput; + + try { + [stagedOutput, committedOutput] = await Promise.all([ + readFile(stagedOutputPath), + readFile(committedOutputPath), + ]); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + staleOutputs.push(relativeOutputPath); + continue; + } + + throw error; + } + + if (!stagedOutput.equals(committedOutput)) { + staleOutputs.push(relativeOutputPath); + } + } +}); + +if (staleOutputs.length > 0) { + console.error("Generated web assets are out of sync. Run `make web-build`."); + for (const outputPath of staleOutputs) { + console.error(`- ${outputPath}`); + } + process.exit(1); +} diff --git a/js/package-lock.json b/js/package-lock.json new file mode 100644 index 00000000..7953eda3 --- /dev/null +++ b/js/package-lock.json @@ -0,0 +1,458 @@ +{ + "name": "@querychat/shared-viz-assets", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@querychat/shared-viz-assets", + "devDependencies": { + "esbuild": "^0.21.5", + "typescript": "^5.5.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/js/package.json b/js/package.json new file mode 100644 index 00000000..3fd3ce29 --- /dev/null +++ b/js/package.json @@ -0,0 +1,13 @@ +{ + "name": "@querychat/shared-viz-assets", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs", + "check": "node check.mjs" + }, + "devDependencies": { + "esbuild": "^0.21.5", + "typescript": "^5.5.4" + } +} diff --git a/js/src/viz-core.ts b/js/src/viz-core.ts new file mode 100644 index 00000000..74beb222 --- /dev/null +++ b/js/src/viz-core.ts @@ -0,0 +1,219 @@ +type ExportFormat = "png" | "svg"; +type QuerychatAction = + | "show-query" + | "save-toggle" + | "save-png" + | "save-svg" + | "copy"; + +export interface VizRuntimeAdapter { + exportPlot(widgetId: string, format: ExportFormat, filename: string): void; +} + +function findWidgetContainer(widgetId: string): HTMLElement | null { + return document.getElementById(widgetId); +} + +function findVegaAction( + container: HTMLElement, + format: ExportFormat, +): HTMLAnchorElement | null { + return container.querySelector( + `.vega-actions a[download$=".${format}"]`, + ); +} + +function triggerVegaAction(link: HTMLAnchorElement, filename: string): void { + link.download = filename; + + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + link.click(); + return; + } + + const observer = new MutationObserver(() => { + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + observer.disconnect(); + clearTimeout(timeoutId); + link.click(); + } + }); + + observer.observe(link, { + attributes: true, + attributeFilter: ["href"], + }); + + const timeoutId = window.setTimeout(() => { + observer.disconnect(); + console.error("Timed out waiting for vega-embed to generate image"); + }, 5000); + + link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); +} + +let openSaveMenu: HTMLElement | null = null; + +function closeSaveMenu(menu: HTMLElement): void { + menu.classList.remove("querychat-save-menu--visible"); + + if (openSaveMenu === menu) { + openSaveMenu = null; + } +} + +function closeOpenSaveMenu(): void { + if (openSaveMenu) { + closeSaveMenu(openSaveMenu); + } +} + +function handleShowQuery(event: MouseEvent, button: HTMLElement): void { + event.stopPropagation(); + + const targetId = button.dataset.target; + if (!targetId) { + return; + } + + const section = document.getElementById(targetId); + if (!section) { + return; + } + + const isVisible = section.classList.toggle("querychat-query-section--visible"); + const label = button.querySelector(".querychat-query-label"); + const chevron = button.querySelector(".querychat-query-chevron"); + + if (label) { + label.textContent = isVisible ? "Hide Query" : "Show Query"; + } + + if (chevron) { + chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); + } +} + +function handleSaveToggle(event: MouseEvent, button: HTMLElement): void { + event.stopPropagation(); + + const menu = button.parentElement?.querySelector( + ".querychat-save-menu", + ); + + if (!menu) { + return; + } + + if (openSaveMenu && openSaveMenu !== menu) { + closeSaveMenu(openSaveMenu); + } + + if (menu.classList.contains("querychat-save-menu--visible")) { + closeSaveMenu(menu); + } else { + menu.classList.add("querychat-save-menu--visible"); + openSaveMenu = menu; + } +} + +function handleSaveExport( + event: MouseEvent, + button: HTMLElement, + format: ExportFormat, + adapter: VizRuntimeAdapter, +): void { + event.stopPropagation(); + + const widgetId = button.dataset.widgetId; + if (!widgetId) { + return; + } + + const filename = button.dataset.title || "chart"; + const menu = button.closest(".querychat-save-menu"); + if (menu) { + closeSaveMenu(menu); + } + + adapter.exportPlot(widgetId, format, filename); +} + +function handleCopy(event: MouseEvent, button: HTMLElement): void { + event.stopPropagation(); + + const query = button.dataset.query; + if (!query) { + return; + } + + navigator.clipboard + .writeText(query) + .then(() => { + const original = button.textContent; + button.textContent = "Copied!"; + setTimeout(() => { + button.textContent = original; + }, 2000); + }) + .catch((error: unknown) => { + console.error("Failed to copy:", error); + }); +} + +export function installVizFooter(adapter: VizRuntimeAdapter): void { + window.addEventListener("click", (event) => { + const target = event.target; + + if (!(target instanceof Element)) { + closeOpenSaveMenu(); + return; + } + + const actionElement = target.closest("[data-querychat-action]"); + const action = actionElement?.dataset.querychatAction as + | QuerychatAction + | undefined; + + if (!action || !actionElement) { + closeOpenSaveMenu(); + return; + } + + switch (action) { + case "show-query": + handleShowQuery(event, actionElement); + return; + case "save-toggle": + handleSaveToggle(event, actionElement); + return; + case "save-png": + handleSaveExport(event, actionElement, "png", adapter); + return; + case "save-svg": + handleSaveExport(event, actionElement, "svg", adapter); + return; + case "copy": + handleCopy(event, actionElement); + return; + } + }); +} + +export function createVegaActionAdapter(): VizRuntimeAdapter { + return { + exportPlot(widgetId, format, filename) { + const container = findWidgetContainer(widgetId); + if (!container) { + return; + } + + const link = findVegaAction(container, format); + if (!link) { + return; + } + + triggerVegaAction(link, `${filename}.${format}`); + }, + }; +} diff --git a/js/src/viz-py.ts b/js/src/viz-py.ts new file mode 100644 index 00000000..4be946cd --- /dev/null +++ b/js/src/viz-py.ts @@ -0,0 +1,3 @@ +import { createVegaActionAdapter, installVizFooter } from "./viz-core"; + +installVizFooter(createVegaActionAdapter()); diff --git a/js/src/viz-r.ts b/js/src/viz-r.ts new file mode 100644 index 00000000..4be946cd --- /dev/null +++ b/js/src/viz-r.ts @@ -0,0 +1,3 @@ +import { createVegaActionAdapter, installVizFooter } from "./viz-core"; + +installVizFooter(createVegaActionAdapter()); diff --git a/js/src/viz.css b/js/src/viz.css new file mode 100644 index 00000000..cabbc42a --- /dev/null +++ b/js/src/viz.css @@ -0,0 +1,141 @@ +/* Hide Vega's built-in action dropdown (we have our own save button) */ +.querychat-viz-container details:has(> .vega-actions) { + display: none !important; +} + +/* ---- Visualization container ---- */ + +.querychat-viz-container { + aspect-ratio: 4 / 2; + width: 100%; +} + +/* In full-screen mode, let the chart fill the available space */ +.bslib-full-screen-container .querychat-viz-container { + aspect-ratio: unset; +} + +/* ---- Visualization footer ---- */ + +.querychat-footer-buttons { + display: flex; + justify-content: space-between; + align-items: center; +} + +.querychat-footer-left, +.querychat-footer-right { + display: flex; + align-items: center; + gap: 4px; +} + +.querychat-show-query-btn, +.querychat-save-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + height: 28px; + border: none; + border-radius: var(--bs-border-radius, 4px); + background: transparent; + color: var(--bs-secondary-color, #6c757d); + font-size: 0.75rem; + cursor: pointer; + white-space: nowrap; +} + +.querychat-show-query-btn:hover, +.querychat-save-btn:hover { + color: var(--bs-body-color, #212529); + background-color: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05); +} + +.querychat-query-chevron { + font-size: 0.625rem; + transition: transform 150ms; + display: inline-block; +} + +.querychat-query-chevron--expanded { + transform: rotate(90deg); +} + +.querychat-icon { + width: 14px; + height: 14px; +} + +.querychat-dropdown-chevron { + width: 12px; + height: 12px; + margin-left: 2px; +} + +.querychat-save-dropdown { + position: relative; +} + +.querychat-save-menu { + display: none; + position: absolute; + right: 0; + bottom: 100%; + margin-bottom: 4px; + z-index: 20; + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: var(--bs-border-radius, 4px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 4px 0; + min-width: 120px; +} + +.querychat-save-menu--visible { + display: block; +} + +.querychat-save-menu button { + display: block; + width: 100%; + padding: 6px 12px; + border: none; + background: transparent; + color: var(--bs-body-color, #212529); + font-size: 0.75rem; + text-align: left; + cursor: pointer; +} + +.querychat-save-menu button:hover { + background-color: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05); +} + +.querychat-query-section { + display: none; + position: relative; + border-top: 1px solid var(--bs-border-color, #dee2e6); + margin: 8px -16px -8px; +} + +.querychat-query-section--visible { + display: block; +} + + +/* shinychat sets max-height:500px on all cards, which is too small for viz+editor */ +.shiny-tool-card:has(.querychat-viz-container) { + max-height: 700px; + overflow: hidden; +} + +.querychat-query-section bslib-code-editor .code-editor { + margin: 1em; +} + +.querychat-query-section bslib-code-editor .prism-code-editor { + background-color: var(--bs-light, #f8f8f8); + max-height: 200px; + overflow-y: auto; +} diff --git a/js/tsconfig.json b/js/tsconfig.json new file mode 100644 index 00000000..82c7ad7c --- /dev/null +++ b/js/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "ES2020"], + "strict": true, + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pkg-py/src/querychat/_utils.py b/pkg-py/src/querychat/_utils.py index 1c8f9f31..c08ed93e 100644 --- a/pkg-py/src/querychat/_utils.py +++ b/pkg-py/src/querychat/_utils.py @@ -25,6 +25,8 @@ ) +# Altair/Vega validation errors can append the entire Vega schema after the +# useful first lines, which overwhelms the tool result shown to the model. def truncate_error(error_msg: str, max_chars: int = 500) -> str: if len(error_msg) <= max_chars: return error_msg diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index 118ff2e4..9b06dbf6 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -13,6 +13,7 @@ from shinychat.types import ToolResultDisplay from shiny import ui +from shiny.module import resolve_id from .__version import __version__ from ._icons import bs_icon @@ -114,7 +115,12 @@ def __init__( else: value = text - footer = build_viz_footer(ggsql_str, title, widget_id) + footer = build_viz_footer( + ggsql_str, + title, + widget_id, + dom_widget_id=str(resolve_id(widget_id)), + ) widget_html = output_widget(widget_id, fill=True, fillable=True) widget_html.add_class("querychat-viz-container") @@ -237,6 +243,7 @@ def build_viz_footer( ggsql_str: str, title: str, widget_id: str, + dom_widget_id: str, ) -> TagList: """Build footer HTML for visualization tool results.""" footer_id = f"querychat_footer_{uuid4().hex[:8]}" @@ -269,6 +276,7 @@ def build_viz_footer( tags.button( { "class": "querychat-show-query-btn", + "data-querychat-action": "show-query", "data-target": query_section_id, }, tags.span({"class": "querychat-query-chevron"}, "\u25b6"), @@ -283,7 +291,8 @@ def build_viz_footer( tags.button( { "class": "querychat-save-btn", - "data-widget-id": widget_id, + "data-querychat-action": "save-toggle", + "data-widget-id": dom_widget_id, }, bs_icon("download", cls="querychat-icon"), "Save", @@ -294,7 +303,8 @@ def build_viz_footer( tags.button( { "class": "querychat-save-png-btn", - "data-widget-id": widget_id, + "data-querychat-action": "save-png", + "data-widget-id": dom_widget_id, "data-title": title, }, "Save as PNG", @@ -302,7 +312,8 @@ def build_viz_footer( tags.button( { "class": "querychat-save-svg-btn", - "data-widget-id": widget_id, + "data-querychat-action": "save-svg", + "data-widget-id": dom_widget_id, "data-title": title, }, "Save as SVG", diff --git a/pkg-py/src/querychat/static/css/viz.css b/pkg-py/src/querychat/static/css/viz.css index 1b5812bc..5e95c6d0 100644 --- a/pkg-py/src/querychat/static/css/viz.css +++ b/pkg-py/src/querychat/static/css/viz.css @@ -1,3 +1,4 @@ +/* Generated file. Source: js/src/viz.css. Do not edit directly. */ /* Hide Vega's built-in action dropdown (we have our own save button) */ .querychat-viz-container details:has(> .vega-actions) { display: none !important; @@ -138,4 +139,4 @@ background-color: var(--bs-light, #f8f8f8); max-height: 200px; overflow-y: auto; -} \ No newline at end of file +} diff --git a/pkg-py/src/querychat/static/js/viz.js b/pkg-py/src/querychat/static/js/viz.js index a0447517..e611b80a 100644 --- a/pkg-py/src/querychat/static/js/viz.js +++ b/pkg-py/src/querychat/static/js/viz.js @@ -1,129 +1,166 @@ -// Helper: find a native vega-embed action link inside a widget container. -// vega-embed renders a hidden
with tags for "Save as SVG", -// "Save as PNG", etc. We find them by matching the download attribute suffix. -// -// Why not use the Vega View API (view.toSVG(), view.toImageURL()) directly? -// Altair renders charts via its anywidget ESM, which calls vegaEmbed() and -// stores the resulting View in a closure — it's never exposed on the DOM or -// any accessible object. vega-embed v7 also doesn't set __vega_embed__ on -// the element. The only code with access to the View is vega-embed's own -// action handlers, so we delegate to them. -function findVegaAction(container, extension) { - return container.querySelector( - '.vega-actions a[download$=".' + extension + '"]' - ); -} +/* Generated file. Source: js/src/viz-py.ts. Do not edit directly. */ -// Helper: find a widget container by its base ID. -// Shiny module namespacing may prefix the ID (e.g. "mod-querychat_viz_abc"), -// so we match elements whose ID ends with the base widget ID. -function findWidgetContainer(widgetId) { - return document.getElementById(widgetId) - || document.querySelector('[id$="' + CSS.escape(widgetId) + '"]'); -} - -// Helper: trigger a vega-embed export action link. -// vega-embed attaches an async mousedown handler that calls -// view.toImageURL() and sets the link's href to a data URL. -// We dispatch mousedown, then use a MutationObserver to detect -// when href changes from "#" to a data URL, and click the link. -function triggerVegaAction(link, filename) { - link.download = filename; - - // If href is already a data URL (unlikely but possible), click immediately. - if (link.href && link.href !== "#" && !link.href.endsWith("#")) { - link.click(); - return; +"use strict"; +(() => { + // src/viz-core.ts + function findWidgetContainer(widgetId) { + return document.getElementById(widgetId); } - - var observer = new MutationObserver(function () { + function findVegaAction(container, format) { + return container.querySelector( + `.vega-actions a[download$=".${format}"]` + ); + } + function triggerVegaAction(link, filename) { + link.download = filename; if (link.href && link.href !== "#" && !link.href.endsWith("#")) { - observer.disconnect(); - clearTimeout(timeout); link.click(); + return; } - }); - - observer.observe(link, { attributes: true, attributeFilter: ["href"] }); - - var timeout = setTimeout(function () { - observer.disconnect(); - console.error("Timed out waiting for vega-embed to generate image"); - }, 5000); - - link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); -} - -function closeAllSaveMenus() { - document.querySelectorAll(".querychat-save-menu--visible").forEach(function (menu) { + const observer = new MutationObserver(() => { + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + observer.disconnect(); + clearTimeout(timeoutId); + link.click(); + } + }); + observer.observe(link, { + attributes: true, + attributeFilter: ["href"] + }); + const timeoutId = window.setTimeout(() => { + observer.disconnect(); + console.error("Timed out waiting for vega-embed to generate image"); + }, 5e3); + link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + } + var openSaveMenu = null; + function closeSaveMenu(menu) { menu.classList.remove("querychat-save-menu--visible"); - }); -} - -function handleShowQuery(event, btn) { - event.stopPropagation(); - var targetId = btn.dataset.target; - var section = document.getElementById(targetId); - if (!section) return; - var isVisible = section.classList.toggle("querychat-query-section--visible"); - var label = btn.querySelector(".querychat-query-label"); - var chevron = btn.querySelector(".querychat-query-chevron"); - if (label) label.textContent = isVisible ? "Hide Query" : "Show Query"; - if (chevron) chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); -} - -function handleSaveToggle(event, btn) { - event.stopPropagation(); - var menu = btn.parentElement.querySelector(".querychat-save-menu"); - if (menu) menu.classList.toggle("querychat-save-menu--visible"); -} - -function handleSaveExport(event, btn, extension) { - event.stopPropagation(); - var widgetId = btn.dataset.widgetId; - var title = btn.dataset.title || "chart"; - var menu = btn.closest(".querychat-save-menu"); - if (menu) menu.classList.remove("querychat-save-menu--visible"); - - var container = findWidgetContainer(widgetId); - if (!container) return; - var link = findVegaAction(container, extension); - if (!link) return; - triggerVegaAction(link, title + "." + extension); -} - -function handleCopy(event, btn) { - event.stopPropagation(); - var query = btn.dataset.query; - if (!query) return; - navigator.clipboard.writeText(query).then(function () { - var original = btn.textContent; - btn.textContent = "Copied!"; - setTimeout(function () { btn.textContent = original; }, 2000); - }).catch(function (err) { - console.error("Failed to copy:", err); - }); -} - -// Single delegated click handler for all querychat viz footer buttons. -window.addEventListener("click", function (event) { - var target = event.target; - - var btn = target.closest(".querychat-show-query-btn"); - if (btn) { handleShowQuery(event, btn); return; } - - btn = target.closest(".querychat-save-png-btn"); - if (btn) { handleSaveExport(event, btn, "png"); return; } - - btn = target.closest(".querychat-save-svg-btn"); - if (btn) { handleSaveExport(event, btn, "svg"); return; } - - btn = target.closest(".querychat-copy-btn"); - if (btn) { handleCopy(event, btn); return; } - - btn = target.closest(".querychat-save-btn"); - if (btn) { handleSaveToggle(event, btn); return; } + if (openSaveMenu === menu) { + openSaveMenu = null; + } + } + function closeOpenSaveMenu() { + if (openSaveMenu) { + closeSaveMenu(openSaveMenu); + } + } + function handleShowQuery(event, button) { + event.stopPropagation(); + const targetId = button.dataset.target; + if (!targetId) { + return; + } + const section = document.getElementById(targetId); + if (!section) { + return; + } + const isVisible = section.classList.toggle("querychat-query-section--visible"); + const label = button.querySelector(".querychat-query-label"); + const chevron = button.querySelector(".querychat-query-chevron"); + if (label) { + label.textContent = isVisible ? "Hide Query" : "Show Query"; + } + if (chevron) { + chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); + } + } + function handleSaveToggle(event, button) { + event.stopPropagation(); + const menu = button.parentElement?.querySelector( + ".querychat-save-menu" + ); + if (!menu) { + return; + } + if (openSaveMenu && openSaveMenu !== menu) { + closeSaveMenu(openSaveMenu); + } + if (menu.classList.contains("querychat-save-menu--visible")) { + closeSaveMenu(menu); + } else { + menu.classList.add("querychat-save-menu--visible"); + openSaveMenu = menu; + } + } + function handleSaveExport(event, button, format, adapter) { + event.stopPropagation(); + const widgetId = button.dataset.widgetId; + if (!widgetId) { + return; + } + const filename = button.dataset.title || "chart"; + const menu = button.closest(".querychat-save-menu"); + if (menu) { + closeSaveMenu(menu); + } + adapter.exportPlot(widgetId, format, filename); + } + function handleCopy(event, button) { + event.stopPropagation(); + const query = button.dataset.query; + if (!query) { + return; + } + navigator.clipboard.writeText(query).then(() => { + const original = button.textContent; + button.textContent = "Copied!"; + setTimeout(() => { + button.textContent = original; + }, 2e3); + }).catch((error) => { + console.error("Failed to copy:", error); + }); + } + function installVizFooter(adapter) { + window.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) { + closeOpenSaveMenu(); + return; + } + const actionElement = target.closest("[data-querychat-action]"); + const action = actionElement?.dataset.querychatAction; + if (!action || !actionElement) { + closeOpenSaveMenu(); + return; + } + switch (action) { + case "show-query": + handleShowQuery(event, actionElement); + return; + case "save-toggle": + handleSaveToggle(event, actionElement); + return; + case "save-png": + handleSaveExport(event, actionElement, "png", adapter); + return; + case "save-svg": + handleSaveExport(event, actionElement, "svg", adapter); + return; + case "copy": + handleCopy(event, actionElement); + return; + } + }); + } + function createVegaActionAdapter() { + return { + exportPlot(widgetId, format, filename) { + const container = findWidgetContainer(widgetId); + if (!container) { + return; + } + const link = findVegaAction(container, format); + if (!link) { + return; + } + triggerVegaAction(link, `${filename}.${format}`); + } + }; + } - // Click outside any button — close open save menus - closeAllSaveMenus(); -}); + // src/viz-py.ts + installVizFooter(createVegaActionAdapter()); +})(); diff --git a/pkg-py/tests/playwright/test_11_viz_footer.py b/pkg-py/tests/playwright/test_11_viz_footer.py index 2cd58695..2e8e5e36 100644 --- a/pkg-py/tests/playwright/test_11_viz_footer.py +++ b/pkg-py/tests/playwright/test_11_viz_footer.py @@ -9,6 +9,7 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -23,6 +24,19 @@ TOOL_RESULT_TIMEOUT = 90_000 +def _download_from_save_menu(page: Page, format: str): + """Open the save menu, click the requested format, and capture the download.""" + page.locator(".querychat-save-btn").click() + + option = page.locator(f".querychat-save-{format}-btn") + title = option.get_attribute("data-title") or "chart" + + with page.expect_download(timeout=30_000) as download_info: + option.click() + + return download_info.value, title + + @pytest.fixture(autouse=True) def _send_viz_prompt( page: Page, app_10_viz: str, chat_10_viz: ChatController @@ -152,3 +166,29 @@ def test_toggle_save_menu(self, page: Page) -> None: btn.click() expect(menu).not_to_have_class("querychat-save-menu--visible") + + def test_save_as_png_downloads_png(self, page: Page) -> None: + """Clicking 'Save as PNG' should download a PNG file.""" + download, title = _download_from_save_menu(page, "png") + + assert download.suggested_filename == f"{title}.png" + + download_path = download.path() + assert download_path is not None + + content = Path(download_path).read_bytes() + assert content.startswith(b"\x89PNG\r\n\x1a\n") + assert len(content) > 100 + + def test_save_as_svg_downloads_svg(self, page: Page) -> None: + """Clicking 'Save as SVG' should download an SVG file.""" + download, title = _download_from_save_menu(page, "svg") + + assert download.suggested_filename == f"{title}.svg" + + download_path = download.path() + assert download_path is not None + + content = Path(download_path).read_text(encoding="utf-8") + assert "" in content diff --git a/pkg-py/tests/test_viz_footer.py b/pkg-py/tests/test_viz_footer.py index 7051fec4..12b287b2 100644 --- a/pkg-py/tests/test_viz_footer.py +++ b/pkg-py/tests/test_viz_footer.py @@ -107,3 +107,20 @@ def test_preload_markup_has_no_inline_script(self): assert "hidden" in rendered["html"] assert " Date: Fri, 1 May 2026 15:12:14 -0500 Subject: [PATCH 2/7] build: check in shared viz outputs --- pkg-py/src/querychat/_viz_tools.py | 2 +- pkg-r/inst/htmldep/viz.css | 142 ++++++++++++++++++++++++ pkg-r/inst/htmldep/viz.js | 166 +++++++++++++++++++++++++++++ 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 pkg-r/inst/htmldep/viz.css create mode 100644 pkg-r/inst/htmldep/viz.js diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index 9b06dbf6..db28acf7 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -10,10 +10,10 @@ from chatlas import ContentToolResult, Tool, content_image_url from htmltools import HTMLDependency, TagList, tags +from shiny.module import resolve_id from shinychat.types import ToolResultDisplay from shiny import ui -from shiny.module import resolve_id from .__version import __version__ from ._icons import bs_icon diff --git a/pkg-r/inst/htmldep/viz.css b/pkg-r/inst/htmldep/viz.css new file mode 100644 index 00000000..5e95c6d0 --- /dev/null +++ b/pkg-r/inst/htmldep/viz.css @@ -0,0 +1,142 @@ +/* Generated file. Source: js/src/viz.css. Do not edit directly. */ +/* Hide Vega's built-in action dropdown (we have our own save button) */ +.querychat-viz-container details:has(> .vega-actions) { + display: none !important; +} + +/* ---- Visualization container ---- */ + +.querychat-viz-container { + aspect-ratio: 4 / 2; + width: 100%; +} + +/* In full-screen mode, let the chart fill the available space */ +.bslib-full-screen-container .querychat-viz-container { + aspect-ratio: unset; +} + +/* ---- Visualization footer ---- */ + +.querychat-footer-buttons { + display: flex; + justify-content: space-between; + align-items: center; +} + +.querychat-footer-left, +.querychat-footer-right { + display: flex; + align-items: center; + gap: 4px; +} + +.querychat-show-query-btn, +.querychat-save-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + height: 28px; + border: none; + border-radius: var(--bs-border-radius, 4px); + background: transparent; + color: var(--bs-secondary-color, #6c757d); + font-size: 0.75rem; + cursor: pointer; + white-space: nowrap; +} + +.querychat-show-query-btn:hover, +.querychat-save-btn:hover { + color: var(--bs-body-color, #212529); + background-color: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05); +} + +.querychat-query-chevron { + font-size: 0.625rem; + transition: transform 150ms; + display: inline-block; +} + +.querychat-query-chevron--expanded { + transform: rotate(90deg); +} + +.querychat-icon { + width: 14px; + height: 14px; +} + +.querychat-dropdown-chevron { + width: 12px; + height: 12px; + margin-left: 2px; +} + +.querychat-save-dropdown { + position: relative; +} + +.querychat-save-menu { + display: none; + position: absolute; + right: 0; + bottom: 100%; + margin-bottom: 4px; + z-index: 20; + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: var(--bs-border-radius, 4px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 4px 0; + min-width: 120px; +} + +.querychat-save-menu--visible { + display: block; +} + +.querychat-save-menu button { + display: block; + width: 100%; + padding: 6px 12px; + border: none; + background: transparent; + color: var(--bs-body-color, #212529); + font-size: 0.75rem; + text-align: left; + cursor: pointer; +} + +.querychat-save-menu button:hover { + background-color: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05); +} + +.querychat-query-section { + display: none; + position: relative; + border-top: 1px solid var(--bs-border-color, #dee2e6); + margin: 8px -16px -8px; +} + +.querychat-query-section--visible { + display: block; +} + + +/* shinychat sets max-height:500px on all cards, which is too small for viz+editor */ +.shiny-tool-card:has(.querychat-viz-container) { + max-height: 700px; + overflow: hidden; +} + +.querychat-query-section bslib-code-editor .code-editor { + margin: 1em; +} + +.querychat-query-section bslib-code-editor .prism-code-editor { + background-color: var(--bs-light, #f8f8f8); + max-height: 200px; + overflow-y: auto; +} diff --git a/pkg-r/inst/htmldep/viz.js b/pkg-r/inst/htmldep/viz.js new file mode 100644 index 00000000..d2798028 --- /dev/null +++ b/pkg-r/inst/htmldep/viz.js @@ -0,0 +1,166 @@ +/* Generated file. Source: js/src/viz-r.ts. Do not edit directly. */ + +"use strict"; +(() => { + // src/viz-core.ts + function findWidgetContainer(widgetId) { + return document.getElementById(widgetId); + } + function findVegaAction(container, format) { + return container.querySelector( + `.vega-actions a[download$=".${format}"]` + ); + } + function triggerVegaAction(link, filename) { + link.download = filename; + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + link.click(); + return; + } + const observer = new MutationObserver(() => { + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + observer.disconnect(); + clearTimeout(timeoutId); + link.click(); + } + }); + observer.observe(link, { + attributes: true, + attributeFilter: ["href"] + }); + const timeoutId = window.setTimeout(() => { + observer.disconnect(); + console.error("Timed out waiting for vega-embed to generate image"); + }, 5e3); + link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + } + var openSaveMenu = null; + function closeSaveMenu(menu) { + menu.classList.remove("querychat-save-menu--visible"); + if (openSaveMenu === menu) { + openSaveMenu = null; + } + } + function closeOpenSaveMenu() { + if (openSaveMenu) { + closeSaveMenu(openSaveMenu); + } + } + function handleShowQuery(event, button) { + event.stopPropagation(); + const targetId = button.dataset.target; + if (!targetId) { + return; + } + const section = document.getElementById(targetId); + if (!section) { + return; + } + const isVisible = section.classList.toggle("querychat-query-section--visible"); + const label = button.querySelector(".querychat-query-label"); + const chevron = button.querySelector(".querychat-query-chevron"); + if (label) { + label.textContent = isVisible ? "Hide Query" : "Show Query"; + } + if (chevron) { + chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); + } + } + function handleSaveToggle(event, button) { + event.stopPropagation(); + const menu = button.parentElement?.querySelector( + ".querychat-save-menu" + ); + if (!menu) { + return; + } + if (openSaveMenu && openSaveMenu !== menu) { + closeSaveMenu(openSaveMenu); + } + if (menu.classList.contains("querychat-save-menu--visible")) { + closeSaveMenu(menu); + } else { + menu.classList.add("querychat-save-menu--visible"); + openSaveMenu = menu; + } + } + function handleSaveExport(event, button, format, adapter) { + event.stopPropagation(); + const widgetId = button.dataset.widgetId; + if (!widgetId) { + return; + } + const filename = button.dataset.title || "chart"; + const menu = button.closest(".querychat-save-menu"); + if (menu) { + closeSaveMenu(menu); + } + adapter.exportPlot(widgetId, format, filename); + } + function handleCopy(event, button) { + event.stopPropagation(); + const query = button.dataset.query; + if (!query) { + return; + } + navigator.clipboard.writeText(query).then(() => { + const original = button.textContent; + button.textContent = "Copied!"; + setTimeout(() => { + button.textContent = original; + }, 2e3); + }).catch((error) => { + console.error("Failed to copy:", error); + }); + } + function installVizFooter(adapter) { + window.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) { + closeOpenSaveMenu(); + return; + } + const actionElement = target.closest("[data-querychat-action]"); + const action = actionElement?.dataset.querychatAction; + if (!action || !actionElement) { + closeOpenSaveMenu(); + return; + } + switch (action) { + case "show-query": + handleShowQuery(event, actionElement); + return; + case "save-toggle": + handleSaveToggle(event, actionElement); + return; + case "save-png": + handleSaveExport(event, actionElement, "png", adapter); + return; + case "save-svg": + handleSaveExport(event, actionElement, "svg", adapter); + return; + case "copy": + handleCopy(event, actionElement); + return; + } + }); + } + function createVegaActionAdapter() { + return { + exportPlot(widgetId, format, filename) { + const container = findWidgetContainer(widgetId); + if (!container) { + return; + } + const link = findVegaAction(container, format); + if (!link) { + return; + } + triggerVegaAction(link, `${filename}.${format}`); + } + }; + } + + // src/viz-r.ts + installVizFooter(createVegaActionAdapter()); +})(); From 2c894fd4d9a2cfca86f187f99c52ca096185dd14 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 1 May 2026 15:43:03 -0500 Subject: [PATCH 3/7] refactor: scope shared assets PR to python --- js/build.mjs | 10 +- js/check.mjs | 2 +- js/package.json | 3 +- js/src/viz-r.ts | 3 - js/src/{viz-py.ts => viz.ts} | 0 js/tests/asset-targets.test.mjs | 22 ++++ pkg-py/src/querychat/static/js/viz.js | 4 +- pkg-r/inst/htmldep/viz.css | 142 ---------------------- pkg-r/inst/htmldep/viz.js | 166 -------------------------- 9 files changed, 28 insertions(+), 324 deletions(-) delete mode 100644 js/src/viz-r.ts rename js/src/{viz-py.ts => viz.ts} (100%) create mode 100644 js/tests/asset-targets.test.mjs delete mode 100644 pkg-r/inst/htmldep/viz.css delete mode 100644 pkg-r/inst/htmldep/viz.js diff --git a/js/build.mjs b/js/build.mjs index 5aa0bff9..a07f8384 100644 --- a/js/build.mjs +++ b/js/build.mjs @@ -17,20 +17,12 @@ export const repoDir = path.resolve(rootDir, ".."); const jsTargets = [ { - source: "src/viz-r.ts", - output: "../pkg-r/inst/htmldep/viz.js", - }, - { - source: "src/viz-py.ts", + source: "src/viz.ts", output: "../pkg-py/src/querychat/static/js/viz.js", }, ]; const cssTargets = [ - { - source: "src/viz.css", - output: "../pkg-r/inst/htmldep/viz.css", - }, { source: "src/viz.css", output: "../pkg-py/src/querychat/static/css/viz.css", diff --git a/js/check.mjs b/js/check.mjs index 2600c255..8bfefa3a 100644 --- a/js/check.mjs +++ b/js/check.mjs @@ -75,7 +75,7 @@ await withStagedBuild(async (stageDir) => { }); if (staleOutputs.length > 0) { - console.error("Generated web assets are out of sync. Run `make web-build`."); + console.error("Generated web assets are out of sync. Run `make js-build`."); for (const outputPath of staleOutputs) { console.error(`- ${outputPath}`); } diff --git a/js/package.json b/js/package.json index 3fd3ce29..fb6e6eb9 100644 --- a/js/package.json +++ b/js/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "build": "node build.mjs", - "check": "node check.mjs" + "check": "node check.mjs && node --test tests/*.test.mjs", + "test": "node --test tests/*.test.mjs" }, "devDependencies": { "esbuild": "^0.21.5", diff --git a/js/src/viz-r.ts b/js/src/viz-r.ts deleted file mode 100644 index 4be946cd..00000000 --- a/js/src/viz-r.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createVegaActionAdapter, installVizFooter } from "./viz-core"; - -installVizFooter(createVegaActionAdapter()); diff --git a/js/src/viz-py.ts b/js/src/viz.ts similarity index 100% rename from js/src/viz-py.ts rename to js/src/viz.ts diff --git a/js/tests/asset-targets.test.mjs b/js/tests/asset-targets.test.mjs new file mode 100644 index 00000000..413c3b5f --- /dev/null +++ b/js/tests/asset-targets.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assetTargets } from "../build.mjs"; + +test("shared asset targets stay scoped to Python outputs on this branch", () => { + const outputs = assetTargets.map((target) => target.output); + + assert(outputs.length > 0); + assert(outputs.every((output) => output.startsWith("../pkg-py/"))); +}); + +test("shared viz JavaScript uses a single entrypoint", () => { + const jsSources = [ + ...new Set( + assetTargets + .filter((target) => target.output.endsWith(".js")) + .map((target) => target.source), + ), + ]; + + assert.deepEqual(jsSources, ["src/viz.ts"]); +}); diff --git a/pkg-py/src/querychat/static/js/viz.js b/pkg-py/src/querychat/static/js/viz.js index e611b80a..4721c158 100644 --- a/pkg-py/src/querychat/static/js/viz.js +++ b/pkg-py/src/querychat/static/js/viz.js @@ -1,4 +1,4 @@ -/* Generated file. Source: js/src/viz-py.ts. Do not edit directly. */ +/* Generated file. Source: js/src/viz.ts. Do not edit directly. */ "use strict"; (() => { @@ -161,6 +161,6 @@ }; } - // src/viz-py.ts + // src/viz.ts installVizFooter(createVegaActionAdapter()); })(); diff --git a/pkg-r/inst/htmldep/viz.css b/pkg-r/inst/htmldep/viz.css deleted file mode 100644 index 5e95c6d0..00000000 --- a/pkg-r/inst/htmldep/viz.css +++ /dev/null @@ -1,142 +0,0 @@ -/* Generated file. Source: js/src/viz.css. Do not edit directly. */ -/* Hide Vega's built-in action dropdown (we have our own save button) */ -.querychat-viz-container details:has(> .vega-actions) { - display: none !important; -} - -/* ---- Visualization container ---- */ - -.querychat-viz-container { - aspect-ratio: 4 / 2; - width: 100%; -} - -/* In full-screen mode, let the chart fill the available space */ -.bslib-full-screen-container .querychat-viz-container { - aspect-ratio: unset; -} - -/* ---- Visualization footer ---- */ - -.querychat-footer-buttons { - display: flex; - justify-content: space-between; - align-items: center; -} - -.querychat-footer-left, -.querychat-footer-right { - display: flex; - align-items: center; - gap: 4px; -} - -.querychat-show-query-btn, -.querychat-save-btn { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - height: 28px; - border: none; - border-radius: var(--bs-border-radius, 4px); - background: transparent; - color: var(--bs-secondary-color, #6c757d); - font-size: 0.75rem; - cursor: pointer; - white-space: nowrap; -} - -.querychat-show-query-btn:hover, -.querychat-save-btn:hover { - color: var(--bs-body-color, #212529); - background-color: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05); -} - -.querychat-query-chevron { - font-size: 0.625rem; - transition: transform 150ms; - display: inline-block; -} - -.querychat-query-chevron--expanded { - transform: rotate(90deg); -} - -.querychat-icon { - width: 14px; - height: 14px; -} - -.querychat-dropdown-chevron { - width: 12px; - height: 12px; - margin-left: 2px; -} - -.querychat-save-dropdown { - position: relative; -} - -.querychat-save-menu { - display: none; - position: absolute; - right: 0; - bottom: 100%; - margin-bottom: 4px; - z-index: 20; - background: var(--bs-body-bg, #fff); - border: 1px solid var(--bs-border-color, #dee2e6); - border-radius: var(--bs-border-radius, 4px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - padding: 4px 0; - min-width: 120px; -} - -.querychat-save-menu--visible { - display: block; -} - -.querychat-save-menu button { - display: block; - width: 100%; - padding: 6px 12px; - border: none; - background: transparent; - color: var(--bs-body-color, #212529); - font-size: 0.75rem; - text-align: left; - cursor: pointer; -} - -.querychat-save-menu button:hover { - background-color: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05); -} - -.querychat-query-section { - display: none; - position: relative; - border-top: 1px solid var(--bs-border-color, #dee2e6); - margin: 8px -16px -8px; -} - -.querychat-query-section--visible { - display: block; -} - - -/* shinychat sets max-height:500px on all cards, which is too small for viz+editor */ -.shiny-tool-card:has(.querychat-viz-container) { - max-height: 700px; - overflow: hidden; -} - -.querychat-query-section bslib-code-editor .code-editor { - margin: 1em; -} - -.querychat-query-section bslib-code-editor .prism-code-editor { - background-color: var(--bs-light, #f8f8f8); - max-height: 200px; - overflow-y: auto; -} diff --git a/pkg-r/inst/htmldep/viz.js b/pkg-r/inst/htmldep/viz.js deleted file mode 100644 index d2798028..00000000 --- a/pkg-r/inst/htmldep/viz.js +++ /dev/null @@ -1,166 +0,0 @@ -/* Generated file. Source: js/src/viz-r.ts. Do not edit directly. */ - -"use strict"; -(() => { - // src/viz-core.ts - function findWidgetContainer(widgetId) { - return document.getElementById(widgetId); - } - function findVegaAction(container, format) { - return container.querySelector( - `.vega-actions a[download$=".${format}"]` - ); - } - function triggerVegaAction(link, filename) { - link.download = filename; - if (link.href && link.href !== "#" && !link.href.endsWith("#")) { - link.click(); - return; - } - const observer = new MutationObserver(() => { - if (link.href && link.href !== "#" && !link.href.endsWith("#")) { - observer.disconnect(); - clearTimeout(timeoutId); - link.click(); - } - }); - observer.observe(link, { - attributes: true, - attributeFilter: ["href"] - }); - const timeoutId = window.setTimeout(() => { - observer.disconnect(); - console.error("Timed out waiting for vega-embed to generate image"); - }, 5e3); - link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); - } - var openSaveMenu = null; - function closeSaveMenu(menu) { - menu.classList.remove("querychat-save-menu--visible"); - if (openSaveMenu === menu) { - openSaveMenu = null; - } - } - function closeOpenSaveMenu() { - if (openSaveMenu) { - closeSaveMenu(openSaveMenu); - } - } - function handleShowQuery(event, button) { - event.stopPropagation(); - const targetId = button.dataset.target; - if (!targetId) { - return; - } - const section = document.getElementById(targetId); - if (!section) { - return; - } - const isVisible = section.classList.toggle("querychat-query-section--visible"); - const label = button.querySelector(".querychat-query-label"); - const chevron = button.querySelector(".querychat-query-chevron"); - if (label) { - label.textContent = isVisible ? "Hide Query" : "Show Query"; - } - if (chevron) { - chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); - } - } - function handleSaveToggle(event, button) { - event.stopPropagation(); - const menu = button.parentElement?.querySelector( - ".querychat-save-menu" - ); - if (!menu) { - return; - } - if (openSaveMenu && openSaveMenu !== menu) { - closeSaveMenu(openSaveMenu); - } - if (menu.classList.contains("querychat-save-menu--visible")) { - closeSaveMenu(menu); - } else { - menu.classList.add("querychat-save-menu--visible"); - openSaveMenu = menu; - } - } - function handleSaveExport(event, button, format, adapter) { - event.stopPropagation(); - const widgetId = button.dataset.widgetId; - if (!widgetId) { - return; - } - const filename = button.dataset.title || "chart"; - const menu = button.closest(".querychat-save-menu"); - if (menu) { - closeSaveMenu(menu); - } - adapter.exportPlot(widgetId, format, filename); - } - function handleCopy(event, button) { - event.stopPropagation(); - const query = button.dataset.query; - if (!query) { - return; - } - navigator.clipboard.writeText(query).then(() => { - const original = button.textContent; - button.textContent = "Copied!"; - setTimeout(() => { - button.textContent = original; - }, 2e3); - }).catch((error) => { - console.error("Failed to copy:", error); - }); - } - function installVizFooter(adapter) { - window.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof Element)) { - closeOpenSaveMenu(); - return; - } - const actionElement = target.closest("[data-querychat-action]"); - const action = actionElement?.dataset.querychatAction; - if (!action || !actionElement) { - closeOpenSaveMenu(); - return; - } - switch (action) { - case "show-query": - handleShowQuery(event, actionElement); - return; - case "save-toggle": - handleSaveToggle(event, actionElement); - return; - case "save-png": - handleSaveExport(event, actionElement, "png", adapter); - return; - case "save-svg": - handleSaveExport(event, actionElement, "svg", adapter); - return; - case "copy": - handleCopy(event, actionElement); - return; - } - }); - } - function createVegaActionAdapter() { - return { - exportPlot(widgetId, format, filename) { - const container = findWidgetContainer(widgetId); - if (!container) { - return; - } - const link = findVegaAction(container, format); - if (!link) { - return; - } - triggerVegaAction(link, `${filename}.${format}`); - } - }; - } - - // src/viz-r.ts - installVizFooter(createVegaActionAdapter()); -})(); From 07ea2112b8134315390dee2a5390304e7a355bb5 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 1 May 2026 15:47:51 -0500 Subject: [PATCH 4/7] chore: remove js config test --- js/package.json | 3 +-- js/tests/asset-targets.test.mjs | 22 ---------------------- 2 files changed, 1 insertion(+), 24 deletions(-) delete mode 100644 js/tests/asset-targets.test.mjs diff --git a/js/package.json b/js/package.json index fb6e6eb9..3fd3ce29 100644 --- a/js/package.json +++ b/js/package.json @@ -4,8 +4,7 @@ "type": "module", "scripts": { "build": "node build.mjs", - "check": "node check.mjs && node --test tests/*.test.mjs", - "test": "node --test tests/*.test.mjs" + "check": "node check.mjs" }, "devDependencies": { "esbuild": "^0.21.5", diff --git a/js/tests/asset-targets.test.mjs b/js/tests/asset-targets.test.mjs deleted file mode 100644 index 413c3b5f..00000000 --- a/js/tests/asset-targets.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { assetTargets } from "../build.mjs"; - -test("shared asset targets stay scoped to Python outputs on this branch", () => { - const outputs = assetTargets.map((target) => target.output); - - assert(outputs.length > 0); - assert(outputs.every((output) => output.startsWith("../pkg-py/"))); -}); - -test("shared viz JavaScript uses a single entrypoint", () => { - const jsSources = [ - ...new Set( - assetTargets - .filter((target) => target.output.endsWith(".js")) - .map((target) => target.source), - ), - ]; - - assert.deepEqual(jsSources, ["src/viz.ts"]); -}); From 33751a80350e6b4b8ec4f0417717f196be4de702 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 1 May 2026 15:55:53 -0500 Subject: [PATCH 5/7] refactor(pkg-py): tighten viz review follow-ups --- pkg-py/src/querychat/_viz_tools.py | 2 -- pkg-py/tests/playwright/test_11_viz_footer.py | 8 +++++--- pkg-py/tests/test_viz_footer.py | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index db28acf7..8000aa46 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -118,7 +118,6 @@ def __init__( footer = build_viz_footer( ggsql_str, title, - widget_id, dom_widget_id=str(resolve_id(widget_id)), ) @@ -242,7 +241,6 @@ def viz_dep() -> HTMLDependency: def build_viz_footer( ggsql_str: str, title: str, - widget_id: str, dom_widget_id: str, ) -> TagList: """Build footer HTML for visualization tool results.""" diff --git a/pkg-py/tests/playwright/test_11_viz_footer.py b/pkg-py/tests/playwright/test_11_viz_footer.py index 2e8e5e36..46c887fa 100644 --- a/pkg-py/tests/playwright/test_11_viz_footer.py +++ b/pkg-py/tests/playwright/test_11_viz_footer.py @@ -16,7 +16,7 @@ from playwright.sync_api import expect if TYPE_CHECKING: - from playwright.sync_api import Page + from playwright.sync_api import Download, Page from shinychat.playwright import ChatController @@ -24,11 +24,13 @@ TOOL_RESULT_TIMEOUT = 90_000 -def _download_from_save_menu(page: Page, format: str): +def _download_from_save_menu( + page: Page, export_format: str +) -> tuple[Download, str]: """Open the save menu, click the requested format, and capture the download.""" page.locator(".querychat-save-btn").click() - option = page.locator(f".querychat-save-{format}-btn") + option = page.locator(f".querychat-save-{export_format}-btn") title = option.get_attribute("data-title") or "chart" with page.expect_download(timeout=30_000) as download_info: diff --git a/pkg-py/tests/test_viz_footer.py b/pkg-py/tests/test_viz_footer.py index 12b287b2..1f52aaf5 100644 --- a/pkg-py/tests/test_viz_footer.py +++ b/pkg-py/tests/test_viz_footer.py @@ -117,7 +117,6 @@ def test_build_viz_footer_uses_resolved_dom_widget_id(self): build_viz_footer( "SELECT * FROM test_data VISUALISE x, y DRAW point", "Chart", - "querychat_viz_raw", dom_widget_id="module-querychat_viz_raw", ) ).render()["html"] From 7715097371b04972de4ea9ae0c29dc704025a61b Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 1 May 2026 15:59:51 -0500 Subject: [PATCH 6/7] ci: add shared asset check workflow --- .github/workflows/js-check.yml | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/js-check.yml diff --git a/.github/workflows/js-check.yml b/.github/workflows/js-check.yml new file mode 100644 index 00000000..6f5fb9cb --- /dev/null +++ b/.github/workflows/js-check.yml @@ -0,0 +1,43 @@ +name: Check - Shared Viz Assets + +on: + workflow_dispatch: + push: + branches: ["main", "rc-*"] + paths: + - "js/**" + - "pkg-py/src/querychat/static/css/viz.css" + - "pkg-py/src/querychat/static/js/viz.js" + - "Makefile" + - ".github/workflows/js-check.yml" + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "js/**" + - "pkg-py/src/querychat/static/css/viz.css" + - "pkg-py/src/querychat/static/js/viz.js" + - "Makefile" + - ".github/workflows/js-check.yml" + +permissions: + contents: read + +jobs: + js-check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: js/package-lock.json + + - name: Install shared asset dependencies + run: make js-setup + + - name: Check shared assets + run: make js-check From b1335605177e6ff41f8e536e708e34bce6d12079 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 1 May 2026 16:06:19 -0500 Subject: [PATCH 7/7] docs(js): clarify viz footer runtime comment --- js/src/viz-core.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/js/src/viz-core.ts b/js/src/viz-core.ts index 74beb222..07751d93 100644 --- a/js/src/viz-core.ts +++ b/js/src/viz-core.ts @@ -1,3 +1,7 @@ +// Browser runtime for the Visualize tool card display footer. This powers the +// "Show Query" toggle, the custom "Save" menu, and the PNG/SVG export buttons. +// The export path is slightly hacky because Vega owns the real download links, +// so this file proxies those downloads through the footer buttons we control. type ExportFormat = "png" | "svg"; type QuerychatAction = | "show-query" @@ -5,7 +9,6 @@ type QuerychatAction = | "save-png" | "save-svg" | "copy"; - export interface VizRuntimeAdapter { exportPlot(widgetId: string, format: ExportFormat, filename: string): void; }