From 1f6ca4c3fe012145d41697e5b257d47627e021ee Mon Sep 17 00:00:00 2001 From: Timur Mashara Date: Tue, 25 Aug 2026 10:40:22 +0200 Subject: [PATCH 1/4] fix(search): keep a constant gap between the search modal and the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors metabase/metabase.github.io — both repos ship `/css/inkeep.css`, so the two files must stay identical. The Inkeep widget fixes its modal at `top: 5%` inside an overlay whose `backdrop-filter` makes it the containing block, so our `margin-top` override produced a gap that scaled with the viewport: 46px at 920px tall, 24px at 473px, and the panel ran 57px past the bottom of a short viewport. Anchor `top` to `--navigation-header-height` instead so the spacing is constant at every height, and clamp `max-height` so the results list scrolls rather than overflowing the screen. The mobile 85px header value flows through the same custom property. The `.ikp-modal__overlay` padding was dead code — the content is out of flow and positioned from the overlay's padding box, so it never contributed. Adds vitest to guard the offset, since the repo had no test runner. GRO-841 Co-Authored-By: Claude Opus 5 --- .github/workflows/test.yml | 2 + bun.lock | 45 ++++++++++++++++++++++ package.json | 4 +- public/css/inkeep.css | 11 +++--- test/inkeep-modal-offset.test.ts | 66 ++++++++++++++++++++++++++++++++ vitest.config.ts | 10 +++++ 6 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 test/inkeep-modal-offset.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a3e4ba4d8..6ada479972 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,8 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Install js dependencies run: bun install --frozen-lockfile + - name: Run tests + run: bun run test - name: Build the Docs Site env: NODE_ENV: production diff --git a/bun.lock b/bun.lock index 33a767968e..cb2df88acb 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,7 @@ "stylelint-order": "^5.0.0", "typescript": "^6.0.3", "vite-plugin-static-copy": "^4.1.1", + "vitest": "^4.1.11", "wait-on": "^9.0.10", }, }, @@ -386,10 +387,14 @@ "@types/acorn": ["@types/acorn@4.0.6", "", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/concat-stream": ["@types/concat-stream@2.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-3qe4oQAPNwVNwK4C9c8u+VJqv9kez+2MR4qJpoPFfXtgxxif1QbFusvXzK0/Wra2VX07smostI2VMmJNSpZjuQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -424,6 +429,20 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], @@ -494,6 +513,8 @@ "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "astro": ["astro@7.0.9", "", { "dependencies": { "@astrojs/compiler-rs": "^0.3.1", "@astrojs/internal-helpers": "0.10.1", "@astrojs/markdown-satteri": "0.3.4", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.8.1", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.28.0", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "jsonc-parser": "^3.3.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unstorage": "^1.17.5", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0 || ^0.35.0" }, "peerDependencies": { "@astrojs/markdown-remark": "7.2.1" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-WB5pA4LLQnmqjBh6EIu0z8aUV4q2/AoThgSZq57Rsp+oqqvPu7OwZ5eF+W4ku20TUTxIhiJW8dccuGvJPiW2UA=="], @@ -560,6 +581,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@4.1.1", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg=="], "character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="], @@ -610,6 +633,8 @@ "concurrently": ["concurrently@10.0.3", "", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.8.4", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "conc": "dist/bin/index.js", "concurrently": "dist/bin/index.js" } }, "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], @@ -774,6 +799,8 @@ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], @@ -1394,6 +1421,8 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "peek-stream": ["peek-stream@1.1.3", "", { "dependencies": { "buffer-from": "^1.0.0", "duplexify": "^3.5.0", "through2": "^2.0.3" } }, "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA=="], "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], @@ -1584,6 +1613,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "single-line-log": ["single-line-log@0.4.1", "", {}, "sha512-L3Y5LepaFaTPgD/ftnTVC0mdLeIL+e7wKFuJrdudiI4S9Bab4KfPDoJTmIWLaffa2HIQCExhvgYOLF7RKKvk5w=="], @@ -1614,6 +1645,10 @@ "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1694,12 +1729,16 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + "titlecase": ["titlecase@1.1.3", "", { "bin": { "to-title-case": "./bin.js" } }, "sha512-pQX4oiemzjBEELPqgK4WE+q0yhAqjp/yzusGtlSJsOuiDys0RQxggepYmo0BuegIDppYS3b3cpdegRwkpyN3hw=="], "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], @@ -1816,6 +1855,8 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + "volar-service-css": ["volar-service-css@0.0.71", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw=="], "volar-service-emmet": ["volar-service-emmet@0.0.71", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA=="], @@ -1868,6 +1909,8 @@ "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wordwrapjs": ["wordwrapjs@4.0.1", "", { "dependencies": { "reduce-flatten": "^2.0.0", "typical": "^5.2.0" } }, "sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA=="], @@ -1932,6 +1975,8 @@ "@types/nlcst/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], "ajv-i18n/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], diff --git a/package.json b/package.json index 1d4ebe6ee8..9d6619dc61 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ }, "scripts": { "build": "astro build", - "check": "bun lint-scripts && bun fmt:check && bun ts", + "check": "bun lint-scripts && bun fmt:check && bun ts && bun run test", "dev": "astro dev", "preview": "astro preview", + "test": "vitest run", "fmt": "prettier --write \"src/**/*.{ts,astro}\"", "fmt:check": "prettier --check \"src/**/*.{ts,astro}\"", "ts": "astro check", @@ -86,6 +87,7 @@ "stylelint-order": "^5.0.0", "typescript": "^6.0.3", "vite-plugin-static-copy": "^4.1.1", + "vitest": "^4.1.11", "wait-on": "^9.0.10" }, "resolutions": { diff --git a/public/css/inkeep.css b/public/css/inkeep.css index f962e3103c..0027894b88 100644 --- a/public/css/inkeep.css +++ b/public/css/inkeep.css @@ -5,13 +5,12 @@ inkeep-portal { /* cxkit-js builds class names from each component's `_id` debug prop via a helper that kebab-cases and lowercases the suffix (e.g., `modal__Content` - becomes `.ikp-modal__content`). Push the modal below our fixed header. */ + becomes `.ikp-modal__content`). The widget fixes the modal at `top: 5%`, so + the gap under our header scaled with the viewport; anchor it to the header + instead so the spacing is constant. */ .ikp-modal__content { - margin-top: var(--navigation-header-height, 77px); -} - -.ikp-modal__overlay { - padding-top: var(--navigation-header-height, 77px); + max-height: calc(100dvh - var(--navigation-header-height, 77px) - 48px); + top: calc(var(--navigation-header-height, 77px) + 24px); } .ikp-search-bar-trigger__container { diff --git a/test/inkeep-modal-offset.test.ts b/test/inkeep-modal-offset.test.ts new file mode 100644 index 0000000000..8118453997 --- /dev/null +++ b/test/inkeep-modal-offset.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +// GRO-841. The Inkeep widget renders into a shadow root and fixes its modal at +// `top: 5%`, which made the gap under our fixed header scale with the viewport +// (46px at 920px tall, 24px at 473px, and the panel ran off the bottom on short +// screens). `public/css/inkeep.css` is the only lever we have over that shadow +// DOM, so these assertions pin the offset to the header, not to the viewport. +// The same file ships from metabase/metabase.github.io as +// `apps/marketing-jekyll/css/inkeep.css`; keep the two in sync. +const STYLESHEET = "../public/css/inkeep.css"; + +const source = readFileSync( + fileURLToPath(new URL(STYLESHEET, import.meta.url)), + "utf8", +); + +const declarations = (selector: string): Record => { + const block = source.match( + new RegExp(`^${selector.replace(/[.\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m"), + ); + if (!block) throw new Error(`no \`${selector}\` rule in ${STYLESHEET}`); + + return Object.fromEntries( + block[1] + .split(";") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const colon = line.indexOf(":"); + return [line.slice(0, colon).trim(), line.slice(colon + 1).trim()]; + }), + ); +}; + +describe(".ikp-modal__content", () => { + const decls = declarations(".ikp-modal__content"); + + test("offsets the modal from the header, not the viewport", () => { + expect(decls.top).toBeDefined(); + expect(decls.top).toContain("var(--navigation-header-height"); + }); + + test("uses no viewport-proportional term in `top`", () => { + expect(decls.top ?? "").not.toMatch(/\d+\s*(%|vh|dvh|svh|lvh|vmin|vmax)/); + }); + + test("leaves a constant gap under the header", () => { + const gap = decls.top?.match(/\+\s*(\d+)px/); + expect( + gap, + `expected a fixed px gap in \`top: ${decls.top}\``, + ).not.toBeNull(); + expect(Number(gap?.[1])).toBeGreaterThan(0); + }); + + test("does not reintroduce the margin-top offset", () => { + expect(decls["margin-top"]).toBeUndefined(); + }); + + test("stays inside the viewport on short screens", () => { + expect(decls["max-height"]).toBeDefined(); + expect(decls["max-height"]).toContain("var(--navigation-header-height"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000000..5261b4ebd3 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Node, not a DOM environment: the suite asserts on files read from disk. + // `_site`/`tmp` hold build copies of source files, so scope the glob. + environment: "node", + include: ["test/**/*.test.ts", "src/**/*.test.ts"], + }, +}); From fd7dfa783bc02dfd985ac2210b1cb4c4084fdff2 Mon Sep 17 00:00:00 2001 From: Timur Mashara Date: Tue, 25 Aug 2026 11:51:07 +0200 Subject: [PATCH 2/4] refactor(search): extract the Inkeep widget into a self-contained component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors metabase/metabase.github.io. Markup, init script and shadow-DOM CSS now live together in `src/components/search/`, inlined via `theme.styles: [{ type: "style" }]` instead of a `/css/inkeep.css` fetched into the shadow root — which also drops a render-blocking round trip, since cxkit gates the whole widget on its linked sheets loading. The offset arithmetic moves out of CSS into `header-offset.ts`, which measures the header and publishes `--inkeep-modal-top`. This matters more here than on marketing: `--navigation-header-height` is not defined in this repo at all, it arrives from a stylesheet on the marketing origin, and the shared-chrome header snapshot carries a live `.promo-banner`. Verified against a real build — with the banner up the gap is 24px, and it stays 24px once the banner is dismissed. The offset is scoped `:not([data-mobile])`. Below 768px the widget goes full-screen through `inset: 0` at a specificity our `top` could not reach, while our `max-height` still applied and stranded a dead strip at the bottom. `learn/top-bar.html` becomes `TopBar.astro` so the widget can be a real component rather than a liquid partial; it had a single consumer, and its `domain != "docs"` and `show_search == "false"` branches were already dead. Adds happy-dom and the render-astro harness so the component and its offset logic are tested rather than the CSS text. GRO-841 Co-Authored-By: Claude Opus 5 --- _includes/head.html | 5 - _includes/learn/search-input.html | 8 -- _includes/learn/top-bar.html | 27 ----- bun.lock | 19 ++- package.json | 1 + public/js/inkeep.js | 81 ------------- src/components/TopBar.astro | 33 +++++ src/components/inkeep/shared.css | 37 ++++++ src/components/search/Search.astro | 20 ++++ src/components/search/Search.astro.test.ts | 11 ++ src/components/search/header-offset.test.ts | 79 ++++++++++++ src/components/search/header-offset.ts | 45 +++++++ .../components/search/inkeep-shadow.css | 65 +++------- src/components/search/search.test.ts | 78 ++++++++++++ src/components/search/search.ts | 113 ++++++++++++++++++ src/layouts/NewDocsLayout.astro | 8 +- src/test/render-astro.ts | 58 +++++++++ test/inkeep-modal-offset.test.ts | 66 ---------- vitest.config.ts | 13 +- 19 files changed, 520 insertions(+), 247 deletions(-) delete mode 100644 _includes/learn/search-input.html delete mode 100644 _includes/learn/top-bar.html delete mode 100644 public/js/inkeep.js create mode 100644 src/components/TopBar.astro create mode 100644 src/components/inkeep/shared.css create mode 100644 src/components/search/Search.astro create mode 100644 src/components/search/Search.astro.test.ts create mode 100644 src/components/search/header-offset.test.ts create mode 100644 src/components/search/header-offset.ts rename public/css/inkeep.css => src/components/search/inkeep-shadow.css (56%) create mode 100644 src/components/search/search.test.ts create mode 100644 src/components/search/search.ts create mode 100644 src/test/render-astro.ts delete mode 100644 test/inkeep-modal-offset.test.ts diff --git a/_includes/head.html b/_includes/head.html index fab490a729..d29c530f8d 100644 --- a/_includes/head.html +++ b/_includes/head.html @@ -103,9 +103,4 @@ {% endif %} - {% if page.url contains "/learn" or page.url contains "/docs" or page.url contains "/search" or page.url contains - "/changelog" %} - - - {% endif %} diff --git a/_includes/learn/search-input.html b/_includes/learn/search-input.html deleted file mode 100644 index c1374ca7d8..0000000000 --- a/_includes/learn/search-input.html +++ /dev/null @@ -1,8 +0,0 @@ -{% if include.domain == "docs" %} -{% assign action = "/docs-search" %} -{% else %} -{% assign action = "/search" %} -{% endif %} -
- - \ No newline at end of file diff --git a/_includes/learn/top-bar.html b/_includes/learn/top-bar.html deleted file mode 100644 index 8b39688ff4..0000000000 --- a/_includes/learn/top-bar.html +++ /dev/null @@ -1,27 +0,0 @@ -
-
- {% if include.show_breadcrumb %} -
- {% if include.domain == "docs" %} - {% include docs_breadcrumb.html %} - {% else %} - {% include learn/breadcrumbs.html %} - {% endif %} -
- {% endif %} -
- -
- {% if include.domain == "docs" %} - {% include docs_version.html %} - - {% include svg-icons/stars.html %} - What’s new - - {% endif %} - - {% unless include.show_search == "false" %} - {% include learn/search-input.html domain=include.domain %} - {% endunless %} -
-
diff --git a/bun.lock b/bun.lock index cb2df88acb..d101373922 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,7 @@ "eslint-plugin-markdown": "^2.2.1", "eslint-plugin-mdx": "^2.0.0", "eslint-plugin-react": "^7.30.0", + "happy-dom": "^20.11.6", "liquidjs": "^10.27.2", "markdownlint-cli": "^0.33.0", "prettier": "^3.9.5", @@ -425,6 +426,10 @@ "@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/yamljs": ["@types/yamljs@0.2.34", "", {}, "sha512-gJvfRlv9ErxdOv7ux7UsJVePtX54NAvQyd8ncoiFqK8G5aeHIfQfGH2fbruvjAQ9657HwAaO54waS+Dsk2QTUQ=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -563,6 +568,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bulk-require": ["bulk-require@1.0.1", "", { "dependencies": { "glob": "^7.1.1" } }, "sha512-BLU9AMnm1FMr68fR0sYvOkMew4x2ZJ8YztshITlGArl6aLtfAazOWiFj/bwJJixRO8C0wXx9PnRNRWeoR03e8Q=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], @@ -733,7 +740,7 @@ "end-of-stream": ["end-of-stream@1.4.4", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q=="], - "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], @@ -921,6 +928,8 @@ "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + "happy-dom": ["happy-dom@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg=="], + "har-schema": ["har-schema@2.0.0", "", {}, "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q=="], "har-validator": ["har-validator@5.1.5", "", { "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" } }, "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w=="], @@ -1899,6 +1908,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -1923,6 +1934,8 @@ "write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], @@ -2019,6 +2032,8 @@ "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "duplexify/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -2059,6 +2074,8 @@ "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "htmlparser2/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "inquirer/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/package.json b/package.json index 9d6619dc61..7a571d4684 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "eslint-plugin-markdown": "^2.2.1", "eslint-plugin-mdx": "^2.0.0", "eslint-plugin-react": "^7.30.0", + "happy-dom": "^20.11.6", "liquidjs": "^10.27.2", "markdownlint-cli": "^0.33.0", "prettier": "^3.9.5", diff --git a/public/js/inkeep.js b/public/js/inkeep.js deleted file mode 100644 index f3c5fd9db0..0000000000 --- a/public/js/inkeep.js +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Inkeep widget settings (cxkit-js) - */ -import "https://cdn.jsdelivr.net/npm/@inkeep/cxkit-js@0.5.117/dist/embed.js"; - -const baseConfig = { - baseSettings: { - apiKey: "6dd55673e83be3649d9ef8281b40795329b492a8fc320985", - organizationDisplayName: "Metabase", - primaryBrandColor: "#509EE3", - theme: { - styles: [{ type: "link", value: "/css/inkeep.css" }], - }, - }, - aiChatSettings: { - aiAssistantName: "Metabase", - chatSubjectName: "Metabase", - placeholder: "How long does it take to bake a pie chart?", - introMessage: - "Hi! You can ask me about Metabase. (Please check the sources; I'm still learning.)", - prompts: ["Be succinct"], - aiAssistantAvatar: { - light: "/images/icons/metabase-icon.svg", - dark: "/images/icons/metabase-icon.svg", - }, - userAvatar: "/images/icons/person.svg", - }, - searchSettings: { - placeholder: "Search", - }, - shouldShowAskAICard: false, - askAILabel: "Ask", - forceDefaultView: "search", - modalSettings: { shortcutKey: "k" }, -}; - -const getSearchQuery = () => - new URLSearchParams(window.location.search).get("query") || ""; - -let inkeepWidget; - -const handleOpenChange = (isOpen) => { - inkeepWidget?.update({ - modalSettings: { isOpen, onOpenChange: handleOpenChange }, - }); -}; - -const initializeInkeep = () => { - const initialQuery = getSearchQuery(); - const shouldOpenOnLoad = - window.location.pathname.startsWith("/search") && initialQuery.length > 0; - - const config = shouldOpenOnLoad - ? { - ...baseConfig, - modalSettings: { - ...baseConfig.modalSettings, - isOpen: true, - onOpenChange: handleOpenChange, - }, - searchSettings: { ...baseConfig.searchSettings, defaultQuery: initialQuery }, - } - : baseConfig; - - inkeepWidget = window.Inkeep.SearchBar("#inkeep", config); - - if (shouldOpenOnLoad) { - try { - inkeepWidget.setView("search"); - inkeepWidget.search.updateQuery(initialQuery); - } catch (_e) { - // Widget still mounts even if these helpers aren't ready on first paint. - } - } -}; - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initializeInkeep); -} else { - initializeInkeep(); -} diff --git a/src/components/TopBar.astro b/src/components/TopBar.astro new file mode 100644 index 0000000000..a469a59ffb --- /dev/null +++ b/src/components/TopBar.astro @@ -0,0 +1,33 @@ +--- +import LiquidInclude from "@/components/LiquidInclude.astro"; +import Search from "@/components/search/Search.astro"; +import type { LiquidRenderer } from "@/lib/liquid/liquidRenderer"; + +interface Props { + lq: LiquidRenderer; + showBreadcrumb?: boolean; +} + +const { lq, showBreadcrumb = false } = Astro.props; +--- + +
+
+ { + showBreadcrumb && ( +
+ +
+ ) + } +
+ + +
diff --git a/src/components/inkeep/shared.css b/src/components/inkeep/shared.css new file mode 100644 index 0000000000..7790e2b430 --- /dev/null +++ b/src/components/inkeep/shared.css @@ -0,0 +1,37 @@ +/* Styles for the widget's shadow DOM, inlined through `theme.styles` rather than + linked, so each widget carries only what it needs. cxkit-js derives these class + names from each component's `_id` debug prop, kebab-cased (`modal__Content` + becomes `.ikp-modal__content`). */ +.ikp-modal-widget__content { + font-family: Lato, sans-serif; +} + +.ikp-ai-chat__toolbar-header { + visibility: hidden; +} + +.ikp-ai-chat-page-wrapper { + padding-top: 10px; +} + +.ikp-ai-chat__message-box { + background-color: #eef6fc; + border: 1px solid #e4ecfb; + box-shadow: none; +} + +.ikp-ai-chat-footer__content-wrapper { + padding-bottom: 30px; +} + +.ikp-code { + background-color: #f9fbfe; + color: #5a6072; + font-weight: 400; +} + +.ikp-chat-bubble__user-message img { + left: -2px; + position: relative; + zoom: 1.3; +} diff --git a/src/components/search/Search.astro b/src/components/search/Search.astro new file mode 100644 index 0000000000..5f93fa4555 --- /dev/null +++ b/src/components/search/Search.astro @@ -0,0 +1,20 @@ +
+ + + + diff --git a/src/components/search/Search.astro.test.ts b/src/components/search/Search.astro.test.ts new file mode 100644 index 0000000000..7229d48934 --- /dev/null +++ b/src/components/search/Search.astro.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "vitest"; +import { renderToDocument } from "../../test/render-astro"; +import Search from "./Search.astro"; + +describe("Search.astro", () => { + test("renders the node the widget mounts into", async () => { + const { doc } = await renderToDocument(Search, {}); + + expect(doc.querySelector("#inkeep")).not.toBeNull(); + }); +}); diff --git a/src/components/search/header-offset.test.ts b/src/components/search/header-offset.test.ts new file mode 100644 index 0000000000..66ea4b97a1 --- /dev/null +++ b/src/components/search/header-offset.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, test } from "vitest"; +import { GAP, MODAL_TOP, modalTop, publishHeaderOffset } from "./header-offset"; + +const HEADER_HEIGHT = 82; +const BANNER_HEIGHT = 48; + +const build = (bannerHeight = 0): Document => { + document.documentElement.innerHTML = + '
'; + + const header = document.querySelector("header.bootstrap") as HTMLElement; + header.getBoundingClientRect = () => + ({ bottom: HEADER_HEIGHT + bannerHeight }) as DOMRect; + + return document; +}; + +beforeEach(() => { + document.documentElement.style.removeProperty(MODAL_TOP); +}); + +describe("modalTop", () => { + test.each([ + [82, "106px"], + [130, "154px"], + [90, "114px"], + [0, "24px"], + ])("%ipx header becomes %s", (height, expected) => { + expect(modalTop(height)).toBe(expected); + }); + + test("rounds subpixel heights", () => { + expect(modalTop(81.6)).toBe(`${82 + GAP}px`); + }); + + test("never returns a negative offset", () => { + expect(modalTop(-40)).toBe(`${GAP}px`); + }); +}); + +describe("publishHeaderOffset", () => { + test("clears the header, not the declared variable", () => { + const doc = build(); + + expect(publishHeaderOffset(doc)).toBe("106px"); + expect(doc.documentElement.style.getPropertyValue(MODAL_TOP)).toBe("106px"); + }); + + test("tracks the promo banner, which the header height does not include", () => { + expect(publishHeaderOffset(build(BANNER_HEIGHT))).toBe("154px"); + }); + + test("follows the banner being dismissed mid-session", () => { + const doc = build(BANNER_HEIGHT); + publishHeaderOffset(doc); + + const header = doc.querySelector("header.bootstrap") as HTMLElement; + header.getBoundingClientRect = () => ({ bottom: HEADER_HEIGHT }) as DOMRect; + + expect(publishHeaderOffset(doc)).toBe("106px"); + }); + + test("leaves the CSS fallback alone when there is no header", () => { + document.documentElement.innerHTML = ""; + + expect(publishHeaderOffset(document)).toBeNull(); + expect(document.documentElement.style.getPropertyValue(MODAL_TOP)).toBe(""); + }); + + test("never redefines --navigation-header-height", () => { + const doc = build(); + publishHeaderOffset(doc); + + expect( + doc.documentElement.style.getPropertyValue("--navigation-header-height"), + ).toBe(""); + }); +}); diff --git a/src/components/search/header-offset.ts b/src/components/search/header-offset.ts new file mode 100644 index 0000000000..07db98b96e --- /dev/null +++ b/src/components/search/header-offset.ts @@ -0,0 +1,45 @@ +export const MODAL_TOP = "--inkeep-modal-top"; +export const GAP = 24; + +// `.promo-banner` renders inside `.navigation-header`, above the nav row, and +// `--navigation-header-height` is a static 77/85px that does not account for it — +// nor for the 5px it is already short by. Measure instead. +const HEADER = "header.bootstrap, .navigation-header"; +const BANNER_REMOVAL_DELAY = 2500; + +// The header is sticky at top: 0, so its bottom edge is where content may start. +export const modalTop = (headerBottom: number): string => + `${Math.max(0, Math.round(headerBottom)) + GAP}px`; + +export function publishHeaderOffset(doc: Document): string | null { + const header = doc.querySelector(HEADER); + if (!header) return null; + + const top = modalTop(header.getBoundingClientRect().bottom); + doc.documentElement.style.setProperty(MODAL_TOP, top); + return top; +} + +export function trackHeaderOffset(doc: Document): () => void { + const header = doc.querySelector(HEADER); + if (!header) return () => {}; + + const publish = () => publishHeaderOffset(doc); + publish(); + + const resize = new ResizeObserver(publish); + resize.observe(header); + + // promo-banner.js removes the banner on a timer after transitioning it to zero + // height, which does not always fire the ResizeObserver. + const mutation = new MutationObserver(() => { + publish(); + setTimeout(publish, BANNER_REMOVAL_DELAY); + }); + mutation.observe(header, { childList: true, subtree: true }); + + return () => { + resize.disconnect(); + mutation.disconnect(); + }; +} diff --git a/public/css/inkeep.css b/src/components/search/inkeep-shadow.css similarity index 56% rename from public/css/inkeep.css rename to src/components/search/inkeep-shadow.css index 0027894b88..e2a3a8d449 100644 --- a/public/css/inkeep.css +++ b/src/components/search/inkeep-shadow.css @@ -1,30 +1,22 @@ -inkeep-portal { - position: relative; - z-index: 100000; -} +/* Search-widget shadow DOM. Twin of metabase.github.io + apps/marketing-astro/src/components/search/inkeep-shadow.css — keep the two in sync. */ + +/* `--inkeep-modal-top` is published by header-offset.ts: the widget pins the modal + at `top: 5%`, and CSS in here cannot measure the header past the shadow boundary. + The fallback is the desktop header plus the same gap. -/* cxkit-js builds class names from each component's `_id` debug prop via a - helper that kebab-cases and lowercases the suffix (e.g., `modal__Content` - becomes `.ikp-modal__content`). The widget fixes the modal at `top: 5%`, so - the gap under our header scaled with the viewport; anchor it to the header - instead so the spacing is constant. */ -.ikp-modal__content { - max-height: calc(100dvh - var(--navigation-header-height, 77px) - 48px); - top: calc(var(--navigation-header-height, 77px) + 24px); + Below 768px the widget sets `data-mobile` and goes full-screen via `inset: 0`, at a + specificity a bare class cannot reach — `:not()` opts us out rather than losing `top` + while `max-height` still applied and stranded a dead strip at the bottom. */ +.ikp-modal__content:not([data-mobile]) { + max-height: calc(100dvh - var(--inkeep-modal-top, 101px) - 24px); + top: var(--inkeep-modal-top, 101px); } .ikp-search-bar-trigger__container { font-family: Lato, "Helvetica Neue", Helvetica, sans-serif; } -.ikp-modal-widget__content { - font-family: Lato, sans-serif; -} - -.ikp-ai-chat__toolbar-header { - visibility: hidden; -} - .ikp-search-bar-trigger__wrapper .ikp-btn { background-color: #eef6fc; border: 1px solid #e4ecfb; @@ -40,14 +32,10 @@ inkeep-portal { background-color: white; } -.ikp-search-bar-trigger__container--variant_subtle:is(:hover,[data-hover]) { +.ikp-search-bar-trigger__container--variant_subtle:is(:hover, [data-hover]) { background-color: #eef6fc !important; } -.ikp-ai-chat-page-wrapper { - padding-top: 10px; -} - .ikp-search-bar-trigger__text--size_compact { font-size: 14px; } @@ -63,7 +51,9 @@ inkeep-portal { font-weight: 700; margin-left: 6px; padding: 18px; - transition: background-color 0.2s ease, color 0.2s ease; + transition: + background-color 0.2s ease, + color 0.2s ease; } .ikp-switch-view-toggle button:hover { @@ -91,33 +81,12 @@ inkeep-portal { fill: #509ee3; } -.ikp-code { - background-color: #f9fbfe; - color: #5a6072; - font-weight: 400; -} - -.ikp-chat-bubble__user-message img { - left: -2px; - position: relative; - zoom: 1.3; -} - +/* prettier-ignore -- wrapping this selector trips stylelint's indentation rule */ div.ikp-d_flex.ikp-flex_row.ikp-items_center.ikp-pt_3.ikp-pb_1:has(.ikp-search-bar-stand-alone__search-input) { margin-right: -2px; margin-top: 6px; } -.ikp-ai-chat__message-box { - background-color: #eef6fc; - border: 1px solid #e4ecfb; - box-shadow: none; -} - -.ikp-ai-chat-footer__content-wrapper { - padding-bottom: 30px; -} - @media screen and (max-width: 992px) { .ikp-search-bar-trigger__text { display: none; diff --git a/src/components/search/search.test.ts b/src/components/search/search.test.ts new file mode 100644 index 0000000000..f8eddb596e --- /dev/null +++ b/src/components/search/search.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import { GAP } from "./header-offset"; +import { baseConfig, configFor, deepLinkQuery } from "./search"; + +const noop = () => {}; + +describe("deepLinkQuery", () => { + test.each([ + ["https://www.metabase.com/search?query=dashboard", "dashboard"], + ["https://www.metabase.com/search?query=", ""], + ["https://www.metabase.com/search", ""], + ["https://www.metabase.com/docs/latest/?query=dashboard", ""], + ["https://www.metabase.com/learn/?query=dashboard", ""], + ])("%s yields %j", (href, expected) => { + expect(deepLinkQuery(new URL(href))).toBe(expected); + }); +}); + +describe("configFor", () => { + test("opens the modal pre-filled on a /search deep link", () => { + const config = configFor( + new URL("https://www.metabase.com/search?query=dashboard"), + noop, + ); + + expect(config.modalSettings).toMatchObject({ + isOpen: true, + shortcutKey: "k", + }); + expect(config.searchSettings).toMatchObject({ defaultQuery: "dashboard" }); + }); + + test("leaves the modal closed everywhere else", () => { + const config = configFor(new URL("https://www.metabase.com/learn/"), noop); + + expect(config).toBe(baseConfig); + expect(config.modalSettings).not.toHaveProperty("isOpen"); + }); +}); + +describe("shadow styles", () => { + const styles = baseConfig.baseSettings?.theme?.styles ?? []; + + test("are inlined, so no stylesheet has to be served at a fixed URL", () => { + expect(styles.map((style) => style.type)).toEqual(["style", "style"]); + }); + + // Vite's SSR pass empties `.css?raw`, so the config's `value` reads blank here + // even though the client build inlines it. Assert the file itself instead. + const css = readFileSync( + fileURLToPath(new URL("./inkeep-shadow.css", import.meta.url)), + "utf8", + ); + + test("offset the modal from the published header height", () => { + expect(css).toContain("top: var(--inkeep-modal-top, 101px)"); + expect(css).not.toContain("margin-top: var(--navigation-header-height"); + }); + + test("carry no viewport-proportional offset", () => { + const rule = css.match(/\.ikp-modal__content[^{]*\{([^}]*)\}/); + + expect(rule?.[1]).toBeDefined(); + expect(rule?.[1]).not.toMatch(/top:[^;]*\d+\s*(%|vh|svh|lvh|vmin|vmax)/); + }); + + test("keep the fallback in step with the gap the offset module adds", () => { + expect(css).toContain(`var(--inkeep-modal-top, ${77 + GAP}px)`); + }); + + // The widget's own `data-[mobile]:inset-0[data-mobile]` is (0,2,0); a bare class + // loses `top` to it but keeps `max-height`, which strands a dead strip on phones. + test("opt out below the widget's mobile breakpoint", () => { + expect(css).toContain(".ikp-modal__content:not([data-mobile])"); + }); +}); diff --git a/src/components/search/search.ts b/src/components/search/search.ts new file mode 100644 index 0000000000..4767838646 --- /dev/null +++ b/src/components/search/search.ts @@ -0,0 +1,113 @@ +import sharedCss from "../inkeep/shared.css?raw"; +import { trackHeaderOffset } from "./header-offset"; +import shadowCss from "./inkeep-shadow.css?raw"; + +const EMBED_URL = + "https://cdn.jsdelivr.net/npm/@inkeep/cxkit-js@0.5.117/dist/embed.js"; + +const MOUNT = "#inkeep"; + +interface SearchBarConfig { + baseSettings: { + theme?: { + styles?: { key?: string; type: "link" | "style"; value: string }[]; + }; + [key: string]: unknown; + }; + modalSettings: Record; + searchSettings: Record; + [key: string]: unknown; +} + +interface SearchWidget { + update: (settings: Record) => void; + setView: (view: string) => void; + search: { updateQuery: (query: string) => void }; +} + +declare global { + interface Window { + Inkeep?: { SearchBar: (selector: string, config: object) => SearchWidget }; + } +} + +export const baseConfig: SearchBarConfig = { + baseSettings: { + apiKey: "6dd55673e83be3649d9ef8281b40795329b492a8fc320985", + organizationDisplayName: "Metabase", + primaryBrandColor: "#509EE3", + theme: { + styles: [ + { key: "metabase-shared", type: "style", value: sharedCss }, + { key: "metabase-search", type: "style", value: shadowCss }, + ], + }, + }, + aiChatSettings: { + aiAssistantName: "Metabase", + chatSubjectName: "Metabase", + placeholder: "How long does it take to bake a pie chart?", + introMessage: + "Hi! You can ask me about Metabase. (Please check the sources; I'm still learning.)", + prompts: ["Be succinct"], + aiAssistantAvatar: { + light: "/images/icons/metabase-icon.svg", + dark: "/images/icons/metabase-icon.svg", + }, + userAvatar: "/images/icons/person.svg", + }, + searchSettings: { + placeholder: "Search", + }, + shouldShowAskAICard: false, + askAILabel: "Ask", + forceDefaultView: true, + modalSettings: { shortcutKey: "k" }, +}; + +export const deepLinkQuery = (url: URL): string => + url.pathname.startsWith("/search") + ? new URLSearchParams(url.search).get("query") || "" + : ""; + +export function configFor( + url: URL, + onOpenChange: (isOpen: boolean) => void, +): SearchBarConfig { + const query = deepLinkQuery(url); + if (!query) return baseConfig; + + return { + ...baseConfig, + modalSettings: { ...baseConfig.modalSettings, isOpen: true, onOpenChange }, + searchSettings: { ...baseConfig.searchSettings, defaultQuery: query }, + }; +} + +let widget: SearchWidget | undefined; + +const handleOpenChange = (isOpen: boolean) => { + widget?.update({ modalSettings: { isOpen, onOpenChange: handleOpenChange } }); +}; + +export async function mountSearch(): Promise { + if (widget || !document.querySelector(MOUNT)) return; + + await import(/* @vite-ignore */ EMBED_URL); + if (!window.Inkeep) return; + + trackHeaderOffset(document); + + const url = new URL(window.location.href); + const query = deepLinkQuery(url); + widget = window.Inkeep.SearchBar(MOUNT, configFor(url, handleOpenChange)); + + if (query) { + try { + widget.setView("search"); + widget.search.updateQuery(query); + } catch { + // The widget still mounts if these helpers aren't ready on first paint. + } + } +} diff --git a/src/layouts/NewDocsLayout.astro b/src/layouts/NewDocsLayout.astro index 61be162268..de62553728 100644 --- a/src/layouts/NewDocsLayout.astro +++ b/src/layouts/NewDocsLayout.astro @@ -1,5 +1,6 @@ --- import LiquidInclude from "@/components/LiquidInclude.astro"; +import TopBar from "@/components/TopBar.astro"; import { UNIFY_ENABLED_PAGES } from "@/constants"; import { getLiquidRenderer } from "@/lib/liquid/liquidRenderer"; import DefaultNewLayout from "./DefaultNewLayout.astro"; @@ -30,12 +31,7 @@ const unify = UNIFY_ENABLED_PAGES.includes(page.url);
- +
{!showBreadcrumb && } diff --git a/src/test/render-astro.ts b/src/test/render-astro.ts new file mode 100644 index 0000000000..8a4c1d9002 --- /dev/null +++ b/src/test/render-astro.ts @@ -0,0 +1,58 @@ +// Renders a real `.astro` component and hands back a live DOM to run that +// component's client module against. Because both halves see the same rendered +// markup, a template change that drops a `data-*` hook fails the behavior tests +// too — no hand-built mirror to drift out of sync. +// +// Two environment quirks shape this, and are the reason it is worth sharing: +// +// 1. Component tests run in the default `node` environment, NOT `happy-dom`. +// Under a browser-ish environment Vite takes the client resolve condition and +// the `.astro` default export comes back as something other than the +// component factory, so the container renders nothing. So the `Window` is +// built by hand here instead, and installed as the globals a client module +// might reach for: `window` (`requestAnimationFrame`, `getComputedStyle`) and +// `CustomEvent` (happy-dom targets only accept happy-dom events, so Node's +// own constructor won't do). Both are dropped after each test. +// 2. happy-dom's DOM types are structurally compatible with `lib.dom`'s but not +// nominally, so the boundary casts below are unavoidable. They are here once +// rather than in every test file. +// +// happy-dom does no layout, so every box reads 0. Stubbing whatever geometry or +// Web Animations behavior a component needs stays with that component's test. +// +// A component test is named `X.astro.test.ts`, not `X.test.ts` — the filesystem +// is case-insensitive, and the latter collides with `x.ts`'s own casing. +import { experimental_AstroContainer as AstroContainer } from "astro/container"; +import { Window as HappyWindow } from "happy-dom"; +import { afterEach } from "vitest"; + +type Container = Awaited>; +type Renderable = Parameters[0]; + +// One container for the whole file — creating it is the expensive part, and it +// holds no per-render state. +let container: Container | undefined; + +afterEach(() => { + // @ts-expect-error — the node env has no `window`; drop the stand-in. + delete globalThis.window; + // @ts-expect-error — restore Node's own `CustomEvent`. + delete globalThis.CustomEvent; +}); + +export async function renderToDocument( + Component: Renderable, + props: Record, + slots?: Record, +): Promise<{ win: HappyWindow; doc: Document }> { + container ??= await AstroContainer.create(); + + const html = await container.renderToString(Component, { props, slots }); + + const win = new HappyWindow(); + win.document.body.innerHTML = html; + globalThis.window = win as unknown as typeof globalThis.window; + globalThis.CustomEvent = win.CustomEvent as unknown as typeof CustomEvent; + + return { win, doc: win.document as unknown as Document }; +} diff --git a/test/inkeep-modal-offset.test.ts b/test/inkeep-modal-offset.test.ts deleted file mode 100644 index 8118453997..0000000000 --- a/test/inkeep-modal-offset.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, test } from "vitest"; - -// GRO-841. The Inkeep widget renders into a shadow root and fixes its modal at -// `top: 5%`, which made the gap under our fixed header scale with the viewport -// (46px at 920px tall, 24px at 473px, and the panel ran off the bottom on short -// screens). `public/css/inkeep.css` is the only lever we have over that shadow -// DOM, so these assertions pin the offset to the header, not to the viewport. -// The same file ships from metabase/metabase.github.io as -// `apps/marketing-jekyll/css/inkeep.css`; keep the two in sync. -const STYLESHEET = "../public/css/inkeep.css"; - -const source = readFileSync( - fileURLToPath(new URL(STYLESHEET, import.meta.url)), - "utf8", -); - -const declarations = (selector: string): Record => { - const block = source.match( - new RegExp(`^${selector.replace(/[.\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m"), - ); - if (!block) throw new Error(`no \`${selector}\` rule in ${STYLESHEET}`); - - return Object.fromEntries( - block[1] - .split(";") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const colon = line.indexOf(":"); - return [line.slice(0, colon).trim(), line.slice(colon + 1).trim()]; - }), - ); -}; - -describe(".ikp-modal__content", () => { - const decls = declarations(".ikp-modal__content"); - - test("offsets the modal from the header, not the viewport", () => { - expect(decls.top).toBeDefined(); - expect(decls.top).toContain("var(--navigation-header-height"); - }); - - test("uses no viewport-proportional term in `top`", () => { - expect(decls.top ?? "").not.toMatch(/\d+\s*(%|vh|dvh|svh|lvh|vmin|vmax)/); - }); - - test("leaves a constant gap under the header", () => { - const gap = decls.top?.match(/\+\s*(\d+)px/); - expect( - gap, - `expected a fixed px gap in \`top: ${decls.top}\``, - ).not.toBeNull(); - expect(Number(gap?.[1])).toBeGreaterThan(0); - }); - - test("does not reintroduce the margin-top offset", () => { - expect(decls["margin-top"]).toBeUndefined(); - }); - - test("stays inside the viewport on short screens", () => { - expect(decls["max-height"]).toBeDefined(); - expect(decls["max-height"]).toContain("var(--navigation-header-height"); - }); -}); diff --git a/vitest.config.ts b/vitest.config.ts index 5261b4ebd3..9515dae07e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,13 @@ -import { defineConfig } from "vitest/config"; +/// +import { getViteConfig } from "astro/config"; -export default defineConfig({ +// `getViteConfig` is what makes `.astro` imports compile; plain Vite resolves them +// to a path string and the container renders nothing. +export default getViteConfig({ test: { - // Node, not a DOM environment: the suite asserts on files read from disk. - // `_site`/`tmp` hold build copies of source files, so scope the glob. + // Node by default. Tests needing DOM globals opt in with: + // // @vitest-environment happy-dom environment: "node", - include: ["test/**/*.test.ts", "src/**/*.test.ts"], + include: ["src/**/*.test.ts"], }, }); From 9dec5a6adc791ffef0c34e8a9ae052ff0080db91 Mon Sep 17 00:00:00 2001 From: Timur Mashara Date: Tue, 25 Aug 2026 11:56:15 +0200 Subject: [PATCH 3/4] fix(search): watch the banner with a MutationObserver, not a ResizeObserver Measured on a real build of this site: removing `.promo-banner` shrinks `.navigation-header` from 130px to 82px, and a ResizeObserver on either that element or the outer `header.bootstrap` does not fire at all. Only a childList mutation does. The banner is a direct child, so no subtree is needed. The ResizeObserver stays for the 77/85px breakpoint and font reflow. GRO-841 Co-Authored-By: Claude Opus 5 --- src/components/search/header-offset.test.ts | 45 ++++++++++++++++++++- src/components/search/header-offset.ts | 20 ++++----- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/components/search/header-offset.test.ts b/src/components/search/header-offset.test.ts index 66ea4b97a1..8e2c1263dd 100644 --- a/src/components/search/header-offset.test.ts +++ b/src/components/search/header-offset.test.ts @@ -1,6 +1,12 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, test } from "vitest"; -import { GAP, MODAL_TOP, modalTop, publishHeaderOffset } from "./header-offset"; +import { + GAP, + MODAL_TOP, + modalTop, + publishHeaderOffset, + trackHeaderOffset, +} from "./header-offset"; const HEADER_HEIGHT = 82; const BANNER_HEIGHT = 48; @@ -77,3 +83,40 @@ describe("publishHeaderOffset", () => { ).toBe(""); }); }); + +describe("trackHeaderOffset", () => { + // A ResizeObserver on the header does not fire when the banner is removed, so + // this is the only thing keeping the offset correct after a dismissal. + test("republishes when the banner is removed", async () => { + document.documentElement.innerHTML = + '
'; + + const header = document.querySelector("header.bootstrap") as HTMLElement; + header.getBoundingClientRect = () => + ({ + bottom: document.querySelector(".promo-banner") + ? HEADER_HEIGHT + BANNER_HEIGHT + : HEADER_HEIGHT, + }) as DOMRect; + + const stop = trackHeaderOffset(document); + const read = () => + document.documentElement.style.getPropertyValue(MODAL_TOP); + + expect(read()).toBe("154px"); + + document.querySelector(".promo-banner")?.remove(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(read()).toBe("106px"); + stop(); + }); + + test("is a no-op without a header", () => { + document.documentElement.innerHTML = ""; + + expect(() => trackHeaderOffset(document)()).not.toThrow(); + expect(document.documentElement.style.getPropertyValue(MODAL_TOP)).toBe(""); + }); +}); diff --git a/src/components/search/header-offset.ts b/src/components/search/header-offset.ts index 07db98b96e..d1066872eb 100644 --- a/src/components/search/header-offset.ts +++ b/src/components/search/header-offset.ts @@ -5,7 +5,7 @@ export const GAP = 24; // `--navigation-header-height` is a static 77/85px that does not account for it — // nor for the 5px it is already short by. Measure instead. const HEADER = "header.bootstrap, .navigation-header"; -const BANNER_REMOVAL_DELAY = 2500; +const BANNER_PARENT = ".navigation-header"; // The header is sticky at top: 0, so its bottom edge is where content may start. export const modalTop = (headerBottom: number): string => @@ -27,19 +27,19 @@ export function trackHeaderOffset(doc: Document): () => void { const publish = () => publishHeaderOffset(doc); publish(); + // A ResizeObserver on the header does not fire when promo-banner.js removes the + // banner, even though the header visibly shrinks — measured, not assumed. The + // banner is a direct child, so childList alone catches it without subtree noise. + const banners = doc.querySelector(BANNER_PARENT); + const mutation = new MutationObserver(publish); + if (banners) mutation.observe(banners, { childList: true }); + + // Still worth observing for the 77/85px breakpoint and font reflow. const resize = new ResizeObserver(publish); resize.observe(header); - // promo-banner.js removes the banner on a timer after transitioning it to zero - // height, which does not always fire the ResizeObserver. - const mutation = new MutationObserver(() => { - publish(); - setTimeout(publish, BANNER_REMOVAL_DELAY); - }); - mutation.observe(header, { childList: true, subtree: true }); - return () => { - resize.disconnect(); mutation.disconnect(); + resize.disconnect(); }; } From 97f72aa78498315069e99a5cd00bdf967bdbf05c Mon Sep 17 00:00:00 2001 From: Timur Mashara Date: Tue, 25 Aug 2026 16:21:16 +0200 Subject: [PATCH 4/4] fix(search): track the header offset across scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header is sticky at `top: -48px` so the promo banner scrolls out while the nav row stays pinned: its bottom edge moves between 130px and 82px, and neither observer fires for it. Measured against a real build of this site, which has a live banner — opening the modal after scrolling put it 72px too low. Reads through on scroll rather than scheduling a frame: the browser already caps scroll events at one per frame, and requestAnimationFrame does not run in a hidden tab. GRO-841 Co-Authored-By: Claude Opus 5 --- .github/workflows/test.yml | 2 - bun.lock | 64 +--------- package.json | 5 +- src/components/search/Search.astro.test.ts | 11 -- src/components/search/header-offset.test.ts | 122 -------------------- src/components/search/header-offset.ts | 34 ++++-- src/components/search/search.test.ts | 78 ------------- src/test/render-astro.ts | 58 ---------- vitest.config.ts | 13 --- 9 files changed, 28 insertions(+), 359 deletions(-) delete mode 100644 src/components/search/Search.astro.test.ts delete mode 100644 src/components/search/header-offset.test.ts delete mode 100644 src/components/search/search.test.ts delete mode 100644 src/test/render-astro.ts delete mode 100644 vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6ada479972..3a3e4ba4d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,8 +17,6 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Install js dependencies run: bun install --frozen-lockfile - - name: Run tests - run: bun run test - name: Build the Docs Site env: NODE_ENV: production diff --git a/bun.lock b/bun.lock index d101373922..33a767968e 100644 --- a/bun.lock +++ b/bun.lock @@ -36,7 +36,6 @@ "eslint-plugin-markdown": "^2.2.1", "eslint-plugin-mdx": "^2.0.0", "eslint-plugin-react": "^7.30.0", - "happy-dom": "^20.11.6", "liquidjs": "^10.27.2", "markdownlint-cli": "^0.33.0", "prettier": "^3.9.5", @@ -47,7 +46,6 @@ "stylelint-order": "^5.0.0", "typescript": "^6.0.3", "vite-plugin-static-copy": "^4.1.1", - "vitest": "^4.1.11", "wait-on": "^9.0.10", }, }, @@ -388,14 +386,10 @@ "@types/acorn": ["@types/acorn@4.0.6", "", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="], - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@types/concat-stream": ["@types/concat-stream@2.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-3qe4oQAPNwVNwK4C9c8u+VJqv9kez+2MR4qJpoPFfXtgxxif1QbFusvXzK0/Wra2VX07smostI2VMmJNSpZjuQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -426,28 +420,10 @@ "@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], - - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@types/yamljs": ["@types/yamljs@0.2.34", "", {}, "sha512-gJvfRlv9ErxdOv7ux7UsJVePtX54NAvQyd8ncoiFqK8G5aeHIfQfGH2fbruvjAQ9657HwAaO54waS+Dsk2QTUQ=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], - - "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], - - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], - - "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], - - "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], - - "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], - - "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], - "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], @@ -518,8 +494,6 @@ "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "astro": ["astro@7.0.9", "", { "dependencies": { "@astrojs/compiler-rs": "^0.3.1", "@astrojs/internal-helpers": "0.10.1", "@astrojs/markdown-satteri": "0.3.4", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.8.1", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.28.0", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "jsonc-parser": "^3.3.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unstorage": "^1.17.5", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0 || ^0.35.0" }, "peerDependencies": { "@astrojs/markdown-remark": "7.2.1" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-WB5pA4LLQnmqjBh6EIu0z8aUV4q2/AoThgSZq57Rsp+oqqvPu7OwZ5eF+W4ku20TUTxIhiJW8dccuGvJPiW2UA=="], @@ -568,8 +542,6 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], - "bulk-require": ["bulk-require@1.0.1", "", { "dependencies": { "glob": "^7.1.1" } }, "sha512-BLU9AMnm1FMr68fR0sYvOkMew4x2ZJ8YztshITlGArl6aLtfAazOWiFj/bwJJixRO8C0wXx9PnRNRWeoR03e8Q=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], @@ -588,8 +560,6 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@4.1.1", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg=="], "character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="], @@ -640,8 +610,6 @@ "concurrently": ["concurrently@10.0.3", "", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.8.4", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "conc": "dist/bin/index.js", "concurrently": "dist/bin/index.js" } }, "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], @@ -740,7 +708,7 @@ "end-of-stream": ["end-of-stream@1.4.4", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q=="], - "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], @@ -806,8 +774,6 @@ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], @@ -928,8 +894,6 @@ "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], - "happy-dom": ["happy-dom@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg=="], - "har-schema": ["har-schema@2.0.0", "", {}, "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q=="], "har-validator": ["har-validator@5.1.5", "", { "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" } }, "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w=="], @@ -1430,8 +1394,6 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "peek-stream": ["peek-stream@1.1.3", "", { "dependencies": { "buffer-from": "^1.0.0", "duplexify": "^3.5.0", "through2": "^2.0.3" } }, "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA=="], "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], @@ -1622,8 +1584,6 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "single-line-log": ["single-line-log@0.4.1", "", {}, "sha512-L3Y5LepaFaTPgD/ftnTVC0mdLeIL+e7wKFuJrdudiI4S9Bab4KfPDoJTmIWLaffa2HIQCExhvgYOLF7RKKvk5w=="], @@ -1654,10 +1614,6 @@ "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - - "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], - "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1738,16 +1694,12 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], - "titlecase": ["titlecase@1.1.3", "", { "bin": { "to-title-case": "./bin.js" } }, "sha512-pQX4oiemzjBEELPqgK4WE+q0yhAqjp/yzusGtlSJsOuiDys0RQxggepYmo0BuegIDppYS3b3cpdegRwkpyN3hw=="], "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], @@ -1864,8 +1816,6 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], - "volar-service-css": ["volar-service-css@0.0.71", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw=="], "volar-service-emmet": ["volar-service-emmet@0.0.71", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA=="], @@ -1908,8 +1858,6 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], - "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -1920,8 +1868,6 @@ "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wordwrapjs": ["wordwrapjs@4.0.1", "", { "dependencies": { "reduce-flatten": "^2.0.0", "typical": "^5.2.0" } }, "sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA=="], @@ -1934,8 +1880,6 @@ "write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="], - "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], @@ -1988,8 +1932,6 @@ "@types/nlcst/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], "ajv-i18n/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -2032,8 +1974,6 @@ "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], - "duplexify/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -2074,8 +2014,6 @@ "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - "htmlparser2/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], - "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "inquirer/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/package.json b/package.json index 7a571d4684..1d4ebe6ee8 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,9 @@ }, "scripts": { "build": "astro build", - "check": "bun lint-scripts && bun fmt:check && bun ts && bun run test", + "check": "bun lint-scripts && bun fmt:check && bun ts", "dev": "astro dev", "preview": "astro preview", - "test": "vitest run", "fmt": "prettier --write \"src/**/*.{ts,astro}\"", "fmt:check": "prettier --check \"src/**/*.{ts,astro}\"", "ts": "astro check", @@ -77,7 +76,6 @@ "eslint-plugin-markdown": "^2.2.1", "eslint-plugin-mdx": "^2.0.0", "eslint-plugin-react": "^7.30.0", - "happy-dom": "^20.11.6", "liquidjs": "^10.27.2", "markdownlint-cli": "^0.33.0", "prettier": "^3.9.5", @@ -88,7 +86,6 @@ "stylelint-order": "^5.0.0", "typescript": "^6.0.3", "vite-plugin-static-copy": "^4.1.1", - "vitest": "^4.1.11", "wait-on": "^9.0.10" }, "resolutions": { diff --git a/src/components/search/Search.astro.test.ts b/src/components/search/Search.astro.test.ts deleted file mode 100644 index 7229d48934..0000000000 --- a/src/components/search/Search.astro.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { renderToDocument } from "../../test/render-astro"; -import Search from "./Search.astro"; - -describe("Search.astro", () => { - test("renders the node the widget mounts into", async () => { - const { doc } = await renderToDocument(Search, {}); - - expect(doc.querySelector("#inkeep")).not.toBeNull(); - }); -}); diff --git a/src/components/search/header-offset.test.ts b/src/components/search/header-offset.test.ts deleted file mode 100644 index 8e2c1263dd..0000000000 --- a/src/components/search/header-offset.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// @vitest-environment happy-dom -import { beforeEach, describe, expect, test } from "vitest"; -import { - GAP, - MODAL_TOP, - modalTop, - publishHeaderOffset, - trackHeaderOffset, -} from "./header-offset"; - -const HEADER_HEIGHT = 82; -const BANNER_HEIGHT = 48; - -const build = (bannerHeight = 0): Document => { - document.documentElement.innerHTML = - '
'; - - const header = document.querySelector("header.bootstrap") as HTMLElement; - header.getBoundingClientRect = () => - ({ bottom: HEADER_HEIGHT + bannerHeight }) as DOMRect; - - return document; -}; - -beforeEach(() => { - document.documentElement.style.removeProperty(MODAL_TOP); -}); - -describe("modalTop", () => { - test.each([ - [82, "106px"], - [130, "154px"], - [90, "114px"], - [0, "24px"], - ])("%ipx header becomes %s", (height, expected) => { - expect(modalTop(height)).toBe(expected); - }); - - test("rounds subpixel heights", () => { - expect(modalTop(81.6)).toBe(`${82 + GAP}px`); - }); - - test("never returns a negative offset", () => { - expect(modalTop(-40)).toBe(`${GAP}px`); - }); -}); - -describe("publishHeaderOffset", () => { - test("clears the header, not the declared variable", () => { - const doc = build(); - - expect(publishHeaderOffset(doc)).toBe("106px"); - expect(doc.documentElement.style.getPropertyValue(MODAL_TOP)).toBe("106px"); - }); - - test("tracks the promo banner, which the header height does not include", () => { - expect(publishHeaderOffset(build(BANNER_HEIGHT))).toBe("154px"); - }); - - test("follows the banner being dismissed mid-session", () => { - const doc = build(BANNER_HEIGHT); - publishHeaderOffset(doc); - - const header = doc.querySelector("header.bootstrap") as HTMLElement; - header.getBoundingClientRect = () => ({ bottom: HEADER_HEIGHT }) as DOMRect; - - expect(publishHeaderOffset(doc)).toBe("106px"); - }); - - test("leaves the CSS fallback alone when there is no header", () => { - document.documentElement.innerHTML = ""; - - expect(publishHeaderOffset(document)).toBeNull(); - expect(document.documentElement.style.getPropertyValue(MODAL_TOP)).toBe(""); - }); - - test("never redefines --navigation-header-height", () => { - const doc = build(); - publishHeaderOffset(doc); - - expect( - doc.documentElement.style.getPropertyValue("--navigation-header-height"), - ).toBe(""); - }); -}); - -describe("trackHeaderOffset", () => { - // A ResizeObserver on the header does not fire when the banner is removed, so - // this is the only thing keeping the offset correct after a dismissal. - test("republishes when the banner is removed", async () => { - document.documentElement.innerHTML = - '
'; - - const header = document.querySelector("header.bootstrap") as HTMLElement; - header.getBoundingClientRect = () => - ({ - bottom: document.querySelector(".promo-banner") - ? HEADER_HEIGHT + BANNER_HEIGHT - : HEADER_HEIGHT, - }) as DOMRect; - - const stop = trackHeaderOffset(document); - const read = () => - document.documentElement.style.getPropertyValue(MODAL_TOP); - - expect(read()).toBe("154px"); - - document.querySelector(".promo-banner")?.remove(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(read()).toBe("106px"); - stop(); - }); - - test("is a no-op without a header", () => { - document.documentElement.innerHTML = ""; - - expect(() => trackHeaderOffset(document)()).not.toThrow(); - expect(document.documentElement.style.getPropertyValue(MODAL_TOP)).toBe(""); - }); -}); diff --git a/src/components/search/header-offset.ts b/src/components/search/header-offset.ts index d1066872eb..c66f7e8574 100644 --- a/src/components/search/header-offset.ts +++ b/src/components/search/header-offset.ts @@ -7,10 +7,12 @@ export const GAP = 24; const HEADER = "header.bootstrap, .navigation-header"; const BANNER_PARENT = ".navigation-header"; -// The header is sticky at top: 0, so its bottom edge is where content may start. export const modalTop = (headerBottom: number): string => `${Math.max(0, Math.round(headerBottom)) + GAP}px`; +// The header is sticky at `top: -48px` so the promo banner scrolls away while the +// nav row stays pinned — its bottom edge moves between 130px and 82px. Read the +// live rect rather than assuming a resting height. export function publishHeaderOffset(doc: Document): string | null { const header = doc.querySelector(HEADER); if (!header) return null; @@ -22,23 +24,39 @@ export function publishHeaderOffset(doc: Document): string | null { export function trackHeaderOffset(doc: Document): () => void { const header = doc.querySelector(HEADER); - if (!header) return () => {}; + const view = doc.defaultView; + if (!header || !view) return () => {}; + + let last = publishHeaderOffset(doc); + + // Scrolling moves the header's bottom edge without firing either observer. The + // browser already caps scroll events at one per frame, so read straight through + // and skip the style write while the value holds. + const onScroll = () => { + const top = modalTop(header.getBoundingClientRect().bottom); + if (top === last) return; + last = top; + doc.documentElement.style.setProperty(MODAL_TOP, top); + }; + view.addEventListener("scroll", onScroll, { passive: true }); - const publish = () => publishHeaderOffset(doc); - publish(); + const republish = () => { + last = publishHeaderOffset(doc); + }; // A ResizeObserver on the header does not fire when promo-banner.js removes the // banner, even though the header visibly shrinks — measured, not assumed. The // banner is a direct child, so childList alone catches it without subtree noise. - const banners = doc.querySelector(BANNER_PARENT); - const mutation = new MutationObserver(publish); - if (banners) mutation.observe(banners, { childList: true }); + const bannerParent = doc.querySelector(BANNER_PARENT); + const mutation = new MutationObserver(republish); + if (bannerParent) mutation.observe(bannerParent, { childList: true }); // Still worth observing for the 77/85px breakpoint and font reflow. - const resize = new ResizeObserver(publish); + const resize = new ResizeObserver(republish); resize.observe(header); return () => { + view.removeEventListener("scroll", onScroll); mutation.disconnect(); resize.disconnect(); }; diff --git a/src/components/search/search.test.ts b/src/components/search/search.test.ts deleted file mode 100644 index f8eddb596e..0000000000 --- a/src/components/search/search.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, test } from "vitest"; -import { GAP } from "./header-offset"; -import { baseConfig, configFor, deepLinkQuery } from "./search"; - -const noop = () => {}; - -describe("deepLinkQuery", () => { - test.each([ - ["https://www.metabase.com/search?query=dashboard", "dashboard"], - ["https://www.metabase.com/search?query=", ""], - ["https://www.metabase.com/search", ""], - ["https://www.metabase.com/docs/latest/?query=dashboard", ""], - ["https://www.metabase.com/learn/?query=dashboard", ""], - ])("%s yields %j", (href, expected) => { - expect(deepLinkQuery(new URL(href))).toBe(expected); - }); -}); - -describe("configFor", () => { - test("opens the modal pre-filled on a /search deep link", () => { - const config = configFor( - new URL("https://www.metabase.com/search?query=dashboard"), - noop, - ); - - expect(config.modalSettings).toMatchObject({ - isOpen: true, - shortcutKey: "k", - }); - expect(config.searchSettings).toMatchObject({ defaultQuery: "dashboard" }); - }); - - test("leaves the modal closed everywhere else", () => { - const config = configFor(new URL("https://www.metabase.com/learn/"), noop); - - expect(config).toBe(baseConfig); - expect(config.modalSettings).not.toHaveProperty("isOpen"); - }); -}); - -describe("shadow styles", () => { - const styles = baseConfig.baseSettings?.theme?.styles ?? []; - - test("are inlined, so no stylesheet has to be served at a fixed URL", () => { - expect(styles.map((style) => style.type)).toEqual(["style", "style"]); - }); - - // Vite's SSR pass empties `.css?raw`, so the config's `value` reads blank here - // even though the client build inlines it. Assert the file itself instead. - const css = readFileSync( - fileURLToPath(new URL("./inkeep-shadow.css", import.meta.url)), - "utf8", - ); - - test("offset the modal from the published header height", () => { - expect(css).toContain("top: var(--inkeep-modal-top, 101px)"); - expect(css).not.toContain("margin-top: var(--navigation-header-height"); - }); - - test("carry no viewport-proportional offset", () => { - const rule = css.match(/\.ikp-modal__content[^{]*\{([^}]*)\}/); - - expect(rule?.[1]).toBeDefined(); - expect(rule?.[1]).not.toMatch(/top:[^;]*\d+\s*(%|vh|svh|lvh|vmin|vmax)/); - }); - - test("keep the fallback in step with the gap the offset module adds", () => { - expect(css).toContain(`var(--inkeep-modal-top, ${77 + GAP}px)`); - }); - - // The widget's own `data-[mobile]:inset-0[data-mobile]` is (0,2,0); a bare class - // loses `top` to it but keeps `max-height`, which strands a dead strip on phones. - test("opt out below the widget's mobile breakpoint", () => { - expect(css).toContain(".ikp-modal__content:not([data-mobile])"); - }); -}); diff --git a/src/test/render-astro.ts b/src/test/render-astro.ts deleted file mode 100644 index 8a4c1d9002..0000000000 --- a/src/test/render-astro.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Renders a real `.astro` component and hands back a live DOM to run that -// component's client module against. Because both halves see the same rendered -// markup, a template change that drops a `data-*` hook fails the behavior tests -// too — no hand-built mirror to drift out of sync. -// -// Two environment quirks shape this, and are the reason it is worth sharing: -// -// 1. Component tests run in the default `node` environment, NOT `happy-dom`. -// Under a browser-ish environment Vite takes the client resolve condition and -// the `.astro` default export comes back as something other than the -// component factory, so the container renders nothing. So the `Window` is -// built by hand here instead, and installed as the globals a client module -// might reach for: `window` (`requestAnimationFrame`, `getComputedStyle`) and -// `CustomEvent` (happy-dom targets only accept happy-dom events, so Node's -// own constructor won't do). Both are dropped after each test. -// 2. happy-dom's DOM types are structurally compatible with `lib.dom`'s but not -// nominally, so the boundary casts below are unavoidable. They are here once -// rather than in every test file. -// -// happy-dom does no layout, so every box reads 0. Stubbing whatever geometry or -// Web Animations behavior a component needs stays with that component's test. -// -// A component test is named `X.astro.test.ts`, not `X.test.ts` — the filesystem -// is case-insensitive, and the latter collides with `x.ts`'s own casing. -import { experimental_AstroContainer as AstroContainer } from "astro/container"; -import { Window as HappyWindow } from "happy-dom"; -import { afterEach } from "vitest"; - -type Container = Awaited>; -type Renderable = Parameters[0]; - -// One container for the whole file — creating it is the expensive part, and it -// holds no per-render state. -let container: Container | undefined; - -afterEach(() => { - // @ts-expect-error — the node env has no `window`; drop the stand-in. - delete globalThis.window; - // @ts-expect-error — restore Node's own `CustomEvent`. - delete globalThis.CustomEvent; -}); - -export async function renderToDocument( - Component: Renderable, - props: Record, - slots?: Record, -): Promise<{ win: HappyWindow; doc: Document }> { - container ??= await AstroContainer.create(); - - const html = await container.renderToString(Component, { props, slots }); - - const win = new HappyWindow(); - win.document.body.innerHTML = html; - globalThis.window = win as unknown as typeof globalThis.window; - globalThis.CustomEvent = win.CustomEvent as unknown as typeof CustomEvent; - - return { win, doc: win.document as unknown as Document }; -} diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 9515dae07e..0000000000 --- a/vitest.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -/// -import { getViteConfig } from "astro/config"; - -// `getViteConfig` is what makes `.astro` imports compile; plain Vite resolves them -// to a path string and the container renders nothing. -export default getViteConfig({ - test: { - // Node by default. Tests needing DOM globals opt in with: - // // @vitest-environment happy-dom - environment: "node", - include: ["src/**/*.test.ts"], - }, -});