diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77a4229..0b4fe6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - run: pnpm typecheck - run: pnpm test:unit - run: pnpm test:e2e + - run: pnpm test:site visual: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index c591877..348c9f1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ tmp/ tests/visual/results/ test-results/ playwright-report/ +.wrangler/ diff --git a/README.md b/README.md index 418f5c3..02cce34 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ To add your own rules, select the extension icon in the Chrome toolbar to open t ## Install -[Install from the Chrome Web Store](https://chromewebstore.google.com/detail/github-file-hider/nocgonekcilofckcilmhpjldgkclkcbf). +[Install from the Chrome Web Store](https://chromewebstore.google.com/detail/github-file-hider/nocgonekcilofckcilmhpjldgkclkcbf), or try the rules first in the sandbox on the [website](https://github-file-hider.ribeiro.engineer).
Install from a ZIP diff --git a/biome.json b/biome.json index 1154c4b..49f8104 100644 --- a/biome.json +++ b/biome.json @@ -30,7 +30,7 @@ }, "overrides": [ { - "includes": ["src/styles/**"], + "includes": ["src/styles/**", "site/src/**/*.css"], "linter": { "rules": { "complexity": { diff --git a/docs/development.md b/docs/development.md index d1c8112..15dceca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -82,7 +82,29 @@ The Chrome Web Store screenshots, the small promo tile, and the README demo imag pnpm store:images ``` -The command builds the extension and opens demo pull request 1 from the fixtures, with GitHub's real stylesheets and the demo settings. It writes five 1280 × 800 screenshots, the 440 × 280 promo tile (`tests/store/promo.html`), and the light and dark README demo images and logos to `store/`. The store icon is `src/icons/128.png`. The command needs network access to `github.githubassets.com` and `avatars.githubusercontent.com`. +The command builds the extension and opens demo pull request 1 from the fixtures, with GitHub's real stylesheets and the demo settings. It writes five 1280 × 800 screenshots, the 440 × 280 promo tile (`tests/store/promo.html`), and the light and dark README demo images and logos to `store/`. It also writes the landing page's 1200 × 630 link preview (`tests/store/og.html`) and touch icon to `site/public/`. The store icon is `src/icons/128.png`. The command needs network access to `github.githubassets.com` and `avatars.githubusercontent.com`. + +## Landing page + +The landing page at https://github-file-hider.ribeiro.engineer is a static site built with Vite in `site/`. Its sandbox replays the demo pull request with the extension's own rule engine (`src/rules.ts`) and interface text (`src/ui/copy.ts`), so it always matches the extension. + +```sh +pnpm site:dev +pnpm site:build +pnpm test:site +``` + +- `pnpm site:dev` serves the page with live reload at http://localhost:5173. +- `pnpm site:build` writes the static site to `site/dist`. The build adds a Content Security Policy that allows only the site's own files, so the page makes no third-party requests. Fonts are self-hosted from the Fontsource packages. +- `pnpm test:site` builds the site and checks it in Chromium: the sandbox, the phone layout, the metadata for search engines and link previews, and the absence of errors, policy violations, and third-party requests. The sandbox model also has unit tests in `tests/unit/site-model.test.ts`. + +To publish the page, sign in once with `pnpm exec wrangler login`, using the Cloudflare account that has the `ribeiro.engineer` zone, then run: + +```sh +pnpm site:deploy +``` + +The command builds the site and deploys it as a Cloudflare Worker with static assets (`wrangler.jsonc`). The Worker serves `github-file-hider.ribeiro.engineer` as a custom domain, so Cloudflare manages its DNS record and certificate. `site/public/_headers` sets the security and cache headers. ## Manual test on the demo repository diff --git a/package.json b/package.json index a327a98..d9d1498 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,19 @@ "test": "pnpm test:unit && pnpm build:test && playwright test --project e2e --project visual", "icons": "node scripts/icons.mjs", "store:images": "pnpm build:test && playwright test --project store", + "site:dev": "vite site", + "site:build": "vite build site", + "site:preview": "vite preview site", + "test:site": "pnpm site:build && playwright test --project site", + "site:deploy": "pnpm site:build && wrangler deploy", "release": "node scripts/release.mjs" }, "devDependencies": { "@biomejs/biome": "2.5.15", + "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0", + "@fontsource-variable/familjen-grotesk": "5.3.0", + "@fontsource-variable/mona-sans": "5.3.0", + "@fontsource/fragment-mono": "5.3.0", "@playwright/test": "^1.63.0", "@types/chrome": "^0.3.4", "@types/node": "^26.6.3", @@ -34,6 +43,8 @@ "esbuild": "^0.28.2", "pngjs": "^7.0.0", "typescript": "^7.0.2", - "vitest": "^5.0.3" + "vite": "8.3.2", + "vitest": "^5.0.3", + "wrangler": "^4.147.0" } } diff --git a/playwright.config.ts b/playwright.config.ts index 72dba80..92e5c7d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,5 +15,6 @@ export default defineConfig({ testMatch: "baseline.spec.ts", }, { name: "store", testDir: "tests/store" }, + { name: "site", testDir: "tests/site" }, ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c1f6d5..5ea6f2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,18 @@ importers: '@biomejs/biome': specifier: 2.5.15 version: 2.5.15 + '@fontsource-variable/atkinson-hyperlegible-next': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource-variable/familjen-grotesk': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource-variable/mona-sans': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource/fragment-mono': + specifier: 5.3.0 + version: 5.3.0 '@playwright/test': specifier: ^1.63.0 version: 1.63.0 @@ -32,9 +44,15 @@ importers: typescript: specifier: ^7.0.2 version: 7.0.2 + vite: + specifier: 8.3.2 + version: 8.3.2(@types/node@26.6.3)(esbuild@0.28.2) vitest: specifier: ^5.0.3 version: 5.0.3(@types/node@26.6.3)(vite@8.3.2(@types/node@26.6.3)(esbuild@0.28.2)) + wrangler: + specifier: ^4.147.0 + version: 4.147.0(@types/node@26.6.3) packages: @@ -95,162 +113,542 @@ packages: cpu: [x64] os: [win32] + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20261001.1': + resolution: {integrity: sha512-4cgSgDf28JSw/P5Dj5GCS59hzVqS5XnmGAWNkvYHLI6ODU9idGaMMNEuhJXDkEG/lsABmlVlnCgsdh2VbKWepw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261001.1': + resolution: {integrity: sha512-8ulAWruEVouNmEIsQsy9WSSCS9zkLu93W2MTwp5esiFyoPp05BNSVFIFsYSPi1pkFmBBd7fsnwMpbLkaJLaVPQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20261001.1': + resolution: {integrity: sha512-kZbTZJGrhsMOdqZ2BIybjaBRLZjYsRLWj7mcNw/6Y3hodOhp5MrNzcz4iWGwNVWwyIV+7Pl+/LX5VcgnRqdHOg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261001.1': + resolution: {integrity: sha512-oOk3Zj6k/8oP0FJgZBWDn7+BqbsqIMEMaV95pulHPVbwVu4wYoLfQq2hp0vkbCNsCFLqZb7cqixXdrwD54ZIow==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20261001.1': + resolution: {integrity: sha512-uRxm5W4VyBkoSaoP1BfOuH0873tE+vxsknF4sab6/5YIRvIAufChq3QDp+zlAn67PuGPGcn7W8QraA0t4ucmOQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@fontsource-variable/atkinson-hyperlegible-next@5.3.0': + resolution: {integrity: sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==} + + '@fontsource-variable/familjen-grotesk@5.3.0': + resolution: {integrity: sha512-R9W0xEZRIHUV658cuOGRMAIcqX1QJ8p5/W6Boooa6zAw1BAozU//mr8C0VNggSE26/VHPwj2XNyMAG6yUJmjxA==} + + '@fontsource-variable/mona-sans@5.3.0': + resolution: {integrity: sha512-PkxRB3KI4z9Gjpk7vVqgO3GMSyQuOIIj4TaRDbvbcDj8SzWwI38jvLTQxJM/cTRorhcT7h8qjcllR+qjRtM/Fg==} + + '@fontsource/fragment-mono@5.3.0': + resolution: {integrity: sha512-vpOLZhWq7Z6wUPo1qMl16Y4yjQJiN0d9TXX2oUtiYXmqUrXU23Wuto73Knyak+osdlIuHz46srpBZb2pfhHCIA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -261,6 +659,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@oxc-project/types@0.152.0': resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} @@ -269,6 +670,15 @@ packages: engines: {node: '>=20'} hasBin: true + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@rolldown/binding-android-arm-eabi@1.2.12': resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -368,6 +778,13 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -533,17 +950,32 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -570,6 +1002,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -647,6 +1083,10 @@ packages: magic-string@1.4.2: resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + miniflare@5.20261001.0-alpha: + resolution: {integrity: sha512-GaimS5mSIOMyvd16ga+e1/QkI8cmp3z35QPHFex0DktqNQf2RVZQwMPQXdyoUreUiFP/0Gpe2MoQInTyMAD5xA==} + engines: {node: '>=22.0.0'} + nanoid@3.3.19: resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -656,6 +1096,12 @@ packages: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -686,6 +1132,20 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} @@ -693,6 +1153,10 @@ packages: std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + tinybench@6.2.0: resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} engines: {node: '>=20.0.0'} @@ -705,6 +1169,9 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -713,6 +1180,13 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + vite@8.3.2: resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -802,6 +1276,39 @@ packages: engines: {node: '>=20.11'} hasBin: true + workerd@1.20261001.1: + resolution: {integrity: sha512-d/SIYHFO0PT/wiFZg8in4NpRIxYuFwslX1HdylOtWkBIIUmSpkGFhK820cV84XACFylwJ48xuRoWW/8DWDPsPQ==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.147.0: + resolution: {integrity: sha512-pQYRoiq8PTAxphaG69z8+GC1DkSGd19EDZehQ8zxjo/Ko3mRB6Qs1mTrd8ZuKAarLklIjTqr1lUdCK9r4q2hUg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20261001.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + snapshots: '@biomejs/biome@2.5.15': @@ -839,84 +1346,308 @@ snapshots: '@biomejs/cli-win32-x64@2.5.15': optional: true + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261001.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261001.1 + + '@cloudflare/workerd-darwin-64@1.20261001.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261001.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261001.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261001.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261001.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm64@0.28.2': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-arm@0.28.2': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/android-x64@0.28.2': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.28.2': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.28.2': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.28.2': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.28.2': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.28.2': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-arm@0.28.2': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-ia32@0.28.2': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-loong64@0.28.2': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.28.2': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.28.2': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.28.2': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-s390x@0.28.2': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/linux-x64@0.28.2': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.28.2': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.28.2': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.28.2': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.28.2': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.28.2': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.28.2': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.28.2': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-ia32@0.28.2': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@esbuild/win32-x64@0.28.2': optional: true + '@fontsource-variable/atkinson-hyperlegible-next@5.3.0': {} + + '@fontsource-variable/familjen-grotesk@5.3.0': {} + + '@fontsource-variable/mona-sans@5.3.0': {} + + '@fontsource/fragment-mono@5.3.0': {} + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -926,12 +1657,29 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@oxc-project/types@0.152.0': {} '@playwright/test@1.63.0': dependencies: playwright: 1.63.0 + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@rolldown/binding-android-arm-eabi@1.2.12': optional: true @@ -979,6 +1727,10 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1082,12 +1834,47 @@ snapshots: assertion-error@2.0.1: {} + blake3-wasm@2.1.5: {} + chai@6.3.0: {} + cookie@1.1.1: {} + detect-libc@2.1.2: {} + error-stack-parser-es@1.0.5: {} + es-module-lexer@2.3.2: {} + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -1130,6 +1917,8 @@ snapshots: fsevents@2.3.3: optional: true + kleur@4.1.5: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -1183,10 +1972,27 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + miniflare@5.20261001.0-alpha(@types/node@26.6.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.4(@types/node@26.6.3) + undici: 7.29.1 + workerd: 1.20261001.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + nanoid@3.3.19: {} obug@2.2.1: {} + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -1226,10 +2032,47 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.12 '@rolldown/binding-win32-x64-msvc': 1.2.12 + semver@7.8.5: {} + + sharp@0.35.4(@types/node@26.6.3): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 26.6.3 + source-map-js@1.2.2: {} std-env@4.3.0: {} + supports-color@10.2.2: {} + tinybench@6.2.0: {} tinyexec@1.3.1: {} @@ -1239,6 +2082,9 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tslib@2.8.1: + optional: true + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -1264,6 +2110,12 @@ snapshots: undici-types@8.9.0: {} + undici@7.29.1: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + vite@8.3.2(@types/node@26.6.3)(esbuild@0.28.2): dependencies: lightningcss: 1.33.0 @@ -1298,3 +2150,43 @@ snapshots: - msw why-is-node-running@3.2.1: {} + + workerd@1.20261001.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261001.1 + '@cloudflare/workerd-darwin-arm64': 1.20261001.1 + '@cloudflare/workerd-linux-64': 1.20261001.1 + '@cloudflare/workerd-linux-arm64': 1.20261001.1 + '@cloudflare/workerd-windows-64': 1.20261001.1 + + wrangler@4.147.0(@types/node@26.6.3): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261001.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20261001.0-alpha(@types/node@26.6.3) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261001.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ed0b5a..02365ef 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ allowBuilds: esbuild: true + workerd: true diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..47c95cf --- /dev/null +++ b/site/404.html @@ -0,0 +1,33 @@ + + + + + +Page not found · GitHub File Hider + + + + + + +
+
+
+ + + GitHub File Hider + +
+
+

404

+

This page is hidden.

+

The page you opened does not exist. The extension and its source are one click away.

+ +
+
+
+ + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..3455a51 --- /dev/null +++ b/site/index.html @@ -0,0 +1,428 @@ + + + + + +GitHub File Hider: hide tests and lockfiles in pull request reviews + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + GitHub File Hider + + + Add to Chrome (opens the Chrome Web Store) +
+ +
+
+
+
+

Chrome extension for pull request reviews

+

Review the code that matters first.

+
+
+

Hide tests, lockfiles, and generated code in pull requests. Bring them back when you are ready.

+ +

Free and open source. MIT license. No account.

+
+
+ +
+
+
    +
  • Try it right here:

  • +
  • open the menu next to files hidden
  • +
  • clear Tests
  • +
  • add docs/**
  • +
  • turn off Hide in sidebar tree
  • +
  • Show all files
  • +
+
+ + +
+
+ +
+ + +
+ + +
+

Add retry policy to checkout payments#1

+

ribeirogab wants to merge 5 commits into main

+
+
+ + + +
+ + + +
+ + + +
+ +
+ +
+
+
+
+
+

A replica built from the 25 files of the real demo pull request. Rules match paths the way the extension does. The tab still says 25 files changed, because File Hider changes only what you see.

+
+ +
+
+
+

How it works

+

Three steps, all inside the pull request.

+
+

Set your rules once. They apply to every repository on github.com. Each pull request then needs one click.

+
+
    +
  1. + +

    Choose what to hide

    +

    Turn on the Tests or Lockfiles preset, or write rules such as docs/** or **/*.sql. Rules match file paths from the repository root.

    + +
  2. +
  3. + +

    Select Hide files

    +

    The control sits in the pull request toolbar. Matching files leave the page, and the control counts them. In Manual mode, File Hider remembers the choice for that pull request.

    + +
  4. +
  5. + +

    Review, then show the rest

    +

    When you are ready, select Show all files. Your rules stay on, and the control reads Hide files again for when you want the short list back.

    + +
  6. +
+
+ +
+
+
+

Screenshot

+

The real extension on github.com

+
+
+

This is GitHub File Hider on the demo pull request, with the menu open. Same files, same rules, and the same 11 files hidden as the replica above.

+

Open the demo pull request to try it with the extension installed.

+
+
+
+ + + The GitHub File Hider menu open on the Files changed page of the pull request Add retry policy to checkout payments. The toolbar control reads 11 files hidden. The Tests and Lockfiles presets are on with counts 8 and 2, one custom rule hides generated files, and Hide in sidebar tree is on. + +
Real extension on github.comA screenshot, not the replica. The control and its menu sit in GitHub's own pull request toolbar and follow GitHub's light and dark themes.
+
+
+ +
+
+
+

Features

+

What it does

+
+

Every rule is a path pattern. Every hidden file is one click away. Nothing else on the page changes.

+
+
    +
  • Presets for tests and lockfiles

    Tests covers **/*.spec.*, **/*.test.* and **/__tests__/**. Lockfiles covers npm, Yarn and pnpm lockfiles. Both start off. Edit their rules, or restore the defaults.

  • +
  • Custom rules

    Write any path pattern from the repository root, such as docs/**, **/generated/**, src/config.ts or **/*.sql. Turn a rule off without deleting it.

  • +
  • Always show

    Keep one file visible while similar files stay hidden. Hide every spec file, but keep src/payments/checkout.spec.ts on the page.

  • +
  • Manual or Automatic

    Manual is the default. Every pull request starts with all files visible, and your Hide files choice is remembered for that pull request. Automatic applies your rules as soon as Files changed opens.

  • +
  • Direct links still work

    A link to a hidden file, or to a comment in it, shows that file for now. A notice says why, and a Hide again button puts it back.

  • +
  • A shorter sidebar tree

    Matching files leave the file tree too. Turn off Hide in sidebar tree to keep the tree complete, with matching files muted and marked with an eye-off icon.

  • +
  • Part of GitHub's toolbar

    The control uses GitHub's own components and follows your GitHub light or dark theme.

  • +
  • Commits too

    Works on the Files changed page of a pull request, on a single commit, and on a commit range.

  • +
+
+ +
+
+
+

Privacy

+

No account. No server. No tracking.

+

File Hider is display only. It hides files from your view and leaves the pull request as it is.

+
+ +

GitHub still says 25 files changed.

+
+
+
+
Settings stay in this browser
Rules and choices live in chrome.storage.local. They are not synced between devices.
+
Runs only on github.com
It asks for two permissions: storage and https://github.com/*.
+
Display only
It never changes files, comments, reviews, or the files you marked as viewed. GitHub's own counts stay the same.
+
Open source
MIT license. Read every line on GitHub.
+
+
+
+ +
+
+
+

Compatibility

+

Where it works

+
+

File Hider follows the current GitHub interface. If something looks off, open an issue.

+
+
+
+

Works on

+
    +
  • Chrome
  • +
  • The current Files changed page on github.com
  • +
  • Single commit and commit range views
  • +
+
+
+

Not supported

+
    +
  • The classic /files page
  • +
  • GitHub Enterprise
  • +
  • Other browsers
  • +
+
+

Versions are named by release date, such as 2026.10.03.1. See every change in the releases.

+
+
+
+ + +
+
+ +

+ + + diff --git a/site/public/_headers b/site/public/_headers new file mode 100644 index 0000000..c3fb957 --- /dev/null +++ b/site/public/_headers @@ -0,0 +1,9 @@ +/* + X-Content-Type-Options: nosniff + X-Frame-Options: DENY + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() + Cross-Origin-Opener-Policy: same-origin + +/assets/* + Cache-Control: public, max-age=31536000, immutable diff --git a/site/public/apple-touch-icon.png b/site/public/apple-touch-icon.png new file mode 100644 index 0000000..140fae7 Binary files /dev/null and b/site/public/apple-touch-icon.png differ diff --git a/site/public/favicon.svg b/site/public/favicon.svg new file mode 100644 index 0000000..14c9047 --- /dev/null +++ b/site/public/favicon.svg @@ -0,0 +1 @@ +GitHub File Hider diff --git a/site/public/og.png b/site/public/og.png new file mode 100644 index 0000000..ce14a64 Binary files /dev/null and b/site/public/og.png differ diff --git a/site/public/robots.txt b/site/public/robots.txt new file mode 100644 index 0000000..c14b22e --- /dev/null +++ b/site/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://github-file-hider.ribeiro.engineer/sitemap.xml diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml new file mode 100644 index 0000000..38f87df --- /dev/null +++ b/site/public/sitemap.xml @@ -0,0 +1,6 @@ + + + + https://github-file-hider.ribeiro.engineer/ + + diff --git a/site/src/base.ts b/site/src/base.ts new file mode 100644 index 0000000..eac4ac1 --- /dev/null +++ b/site/src/base.ts @@ -0,0 +1,5 @@ +import "@fontsource-variable/familjen-grotesk"; +import "@fontsource-variable/atkinson-hyperlegible-next"; +import "@fontsource/fragment-mono/400.css"; +import "@fontsource-variable/mona-sans"; +import "./styles.css"; diff --git a/site/src/demo.ts b/site/src/demo.ts new file mode 100644 index 0000000..ff1c292 --- /dev/null +++ b/site/src/demo.ts @@ -0,0 +1,96 @@ +export type FileStatus = "modified" | "added" | "renamed"; + +export type DemoFile = { + path: string; + status: FileStatus; + from: string | null; +}; + +const file = ( + path: string, + status: FileStatus = "modified", + from: string | null = null, +): DemoFile => ({ path, status, from }); + +export const DEMO_PULL_REQUEST = "ribeirogab/github-file-hider-demo#1"; + +export const EXPANDED_FILE = "docs/payments.md"; + +export const DEMO_FILES: readonly DemoFile[] = [ + file("README.md"), + file("docs/payments.md"), + file("docs/release notes.md", "added"), + file("e2e/checkout.spec.ts"), + file("examples/node/package-lock.json"), + file("package.json"), + file("pnpm-lock.yaml"), + file("src/__tests__/checkout.ts"), + file("src/components/Button.spec.tsx"), + file("src/components/Button.tsx"), + file("src/components/CheckoutForm.test.tsx"), + file("src/components/CheckoutForm.tsx"), + file("src/config.ts"), + file("src/generated/client.ts"), + file("src/legacy/old-checkout.spec.ts"), + file("src/payments/__tests__/gateway.ts"), + file("src/payments/checkout.spec.ts"), + file("src/payments/checkout.ts"), + file("src/payments/gateway.ts"), + file("src/payments/retry-policy.spec.ts", "added"), + file("src/payments/retry-policy.ts", "added"), + file("src/test-utils.ts"), + file( + "src/utils/format-helpers.ts", + "renamed", + "src/__tests__/format-helpers.ts", + ), + file("src/utils/format.test.ts"), + file("src/utils/format.ts"), +]; + +export type DiffLine = { + kind: "context" | "add" | "del"; + oldNumber: number | null; + newNumber: number | null; + text: string; +}; + +const line = ( + kind: DiffLine["kind"], + oldNumber: number | null, + newNumber: number | null, + text: string, +): DiffLine => ({ kind, oldNumber, newNumber, text }); + +export const PAYMENTS_HUNK = "@@ -1,5 +1,13 @@"; + +export const PAYMENTS_DIFF: readonly DiffLine[] = [ + line( + "context", + 1, + 1, + "The checkout flow charges the customer when the order is confirmed.", + ), + line("context", 2, 2, ""), + line( + "del", + 3, + null, + "Failed charges are reported to the customer immediately.", + ), + line( + "add", + null, + 3, + "Failed charges are retried with exponential backoff before the", + ), + line("add", null, 4, "customer sees an error."), + line("add", null, 5, ""), + line("add", null, 6, "## Retry policy"), + line("add", null, 7, ""), + line("add", null, 8, "| Attempt | Delay |"), + line("add", null, 9, "| ------- | ----- |"), + line("add", null, 10, "| 1 | 250ms |"), + line("add", null, 11, "| 2 | 1s |"), + line("context", 4, 12, ""), +]; diff --git a/site/src/main.ts b/site/src/main.ts new file mode 100644 index 0000000..0d24eb9 --- /dev/null +++ b/site/src/main.ts @@ -0,0 +1,4 @@ +import "./base.ts"; +import { startSandbox } from "./sandbox.ts"; + +startSandbox(); diff --git a/site/src/model.ts b/site/src/model.ts new file mode 100644 index 0000000..4fccf6e --- /dev/null +++ b/site/src/model.ts @@ -0,0 +1,226 @@ +import { + type AlwaysShowRule, + type CustomRule, + type Evaluation, + enabledRuleCount, + evaluate, + matchesPattern, + PRESETS, + type PresetId, + presetRules, + type RuleSettings, +} from "../../src/rules.ts"; +import { DEMO_FILES, type DemoFile } from "./demo.ts"; + +export type SandboxState = { + active: boolean; + showingAll: boolean; + tree: boolean; + menuOpen: boolean; + presets: Record; + custom: CustomRule[]; + always: AlwaysShowRule[]; + revealed: Set; + collapsed: Set; + filter: string; + current: string | null; +}; + +export type FileState = "visible" | "hidden" | "revealed" | "kept"; + +export type View = "empty" | "inactive" | "filtering" | "showing"; + +export type FileModel = DemoFile & { evaluation: Evaluation; state: FileState }; + +export type Model = { + filtering: boolean; + files: FileModel[]; + byPath: Map; + view: View; + hidden: number; + match: number; + counts: Map; + live: Map; +}; + +export function initialState(menuOpen: boolean): SandboxState { + return { + active: true, + showingAll: false, + tree: true, + menuOpen, + presets: { tests: true, lockfiles: true }, + custom: [ + { id: "custom-generated", pattern: "**/generated/**", enabled: true }, + ], + always: [ + { id: "always-checkout", pattern: "src/payments/checkout.spec.ts" }, + ], + revealed: new Set(), + collapsed: new Set(), + filter: "", + current: null, + }; +} + +export const ruleSettings = (state: SandboxState): RuleSettings => ({ + presets: { + tests: { enabled: state.presets.tests }, + lockfiles: { enabled: state.presets.lockfiles }, + }, + customRules: state.custom, + alwaysShow: state.always, +}); + +export const presetKey = (id: PresetId) => `preset:${id}`; + +export const customKey = (id: string) => `custom:${id}`; + +export const alwaysKey = (id: string) => `always:${id}`; + +function fileState( + file: DemoFile, + evaluation: Evaluation, + state: SandboxState, + filtering: boolean, +): FileState { + if (filtering && evaluation.matching) + return state.revealed.has(file.path) ? "revealed" : "hidden"; + if (filtering && evaluation.kept) return "kept"; + return "visible"; +} + +function viewOf( + state: SandboxState, + settings: RuleSettings, + filtering: boolean, +): View { + if (enabledRuleCount(settings) === 0) return "empty"; + if (filtering) return "filtering"; + if (state.active && state.showingAll) return "showing"; + return "inactive"; +} + +const keptByAlwaysShow = (state: SandboxState, path: string) => + state.always.some((rule) => matchesPattern(rule.pattern, path)); + +function presetCounts( + state: SandboxState, + settings: RuleSettings, + files: FileModel[], + filtering: boolean, + counts: Map, + live: Map, +) { + for (const preset of PRESETS) { + const key = presetKey(preset.id); + if (!state.presets[preset.id]) { + const rules = presetRules(settings, preset.id); + counts.set( + key, + DEMO_FILES.filter( + (file) => + rules.some((rule) => matchesPattern(rule, file.path)) && + !keptByAlwaysShow(state, file.path), + ).length, + ); + live.set(key, false); + continue; + } + const matched = files.filter( + (file) => + file.evaluation.reason?.kind === "preset" && + file.evaluation.reason.presetId === preset.id, + ); + counts.set( + key, + filtering + ? matched.filter((file) => file.state === "hidden").length + : matched.filter((file) => file.evaluation.matching).length, + ); + live.set(key, filtering); + } +} + +function customCounts( + state: SandboxState, + files: FileModel[], + filtering: boolean, + counts: Map, + live: Map, +) { + for (const rule of state.custom) { + const key = customKey(rule.id); + if (!rule.enabled) { + counts.set( + key, + DEMO_FILES.filter((file) => matchesPattern(rule.pattern, file.path)) + .length, + ); + live.set(key, false); + continue; + } + const matched = files.filter( + (file) => + file.evaluation.reason?.kind === "custom" && + file.evaluation.reason.ruleId === rule.id, + ); + counts.set( + key, + filtering + ? matched.filter((file) => file.state === "hidden").length + : matched.length, + ); + live.set(key, filtering); + } +} + +function alwaysCounts( + state: SandboxState, + files: FileModel[], + filtering: boolean, + counts: Map, + live: Map, +) { + for (const rule of state.always) { + const key = alwaysKey(rule.id); + counts.set( + key, + files.filter( + (file) => + file.evaluation.kept && + file.evaluation.reason?.kind === "always" && + file.evaluation.reason.ruleId === rule.id, + ).length, + ); + live.set(key, filtering); + } +} + +export function derive(state: SandboxState): Model { + const settings = ruleSettings(state); + const filtering = state.active && !state.showingAll; + const files = DEMO_FILES.map((file) => { + const evaluation = evaluate(file.path, settings); + return { + ...file, + evaluation, + state: fileState(file, evaluation, state, filtering), + }; + }); + const counts = new Map(); + const live = new Map(); + presetCounts(state, settings, files, filtering, counts, live); + customCounts(state, files, filtering, counts, live); + alwaysCounts(state, files, filtering, counts, live); + return { + filtering, + files, + byPath: new Map(files.map((file) => [file.path, file])), + view: viewOf(state, settings, filtering), + hidden: files.filter((file) => file.state === "hidden").length, + match: files.filter((file) => file.evaluation.matching).length, + counts, + live, + }; +} diff --git a/site/src/sandbox.ts b/site/src/sandbox.ts new file mode 100644 index 0000000..fb36f88 --- /dev/null +++ b/site/src/sandbox.ts @@ -0,0 +1,821 @@ +import { reasonPhrase, validatePattern } from "../../src/rules.ts"; +import { copy, plural } from "../../src/ui/copy.ts"; +import { + DEMO_FILES, + type DemoFile, + EXPANDED_FILE, + PAYMENTS_DIFF, + PAYMENTS_HUNK, +} from "./demo.ts"; +import { + alwaysKey, + customKey, + derive, + initialState, + type Model, + type SandboxState, +} from "./model.ts"; + +type TreeRow = { + kind: "dir" | "file"; + name: string; + path: string; + depth: number; + ancestors: string[]; + files: string[]; + file: DemoFile | null; +}; + +type TreeNode = { + name: string; + dirs: Map; + files: DemoFile[]; +}; + +type RuleList = "custom" | "always"; + +const escapeHtml = (value: string) => + value.replace( + /[&<>"]/g, + (character) => + ({ "&": "&", "<": "<", ">": ">", '"': """ })[character] ?? + character, + ); + +const icon = (name: string, className = "") => + ``; + +function byId(id: string, type: { new (): T }): T { + const found = document.getElementById(id); + if (!(found instanceof type)) throw new Error(`Missing #${id}`); + return found; +} + +function inside( + root: ParentNode, + selector: string, + type: { new (): T }, +): T { + const found = root.querySelector(selector); + if (!(found instanceof type)) throw new Error(`Missing ${selector}`); + return found; +} + +const targetOf = (event: Event) => + event.target instanceof Element ? event.target : null; + +const compareNames = (a: string, b: string) => + a.localeCompare(b, "en", { sensitivity: "base" }); + +const baseName = (path: string) => path.split("/").pop() ?? path; + +function treeOf(files: readonly DemoFile[]) { + const root: TreeNode = { name: "", dirs: new Map(), files: [] }; + for (const file of files) { + let node = root; + for (const part of file.path.split("/").slice(0, -1)) { + let child = node.dirs.get(part); + if (!child) { + child = { name: part, dirs: new Map(), files: [] }; + node.dirs.set(part, child); + } + node = child; + } + node.files.push(file); + } + return root; +} + +function collapseSingleChild(dir: TreeNode, path: string) { + let node = dir; + let name = dir.name; + let fullPath = path; + while (node.files.length === 0 && node.dirs.size === 1) { + const [only] = node.dirs.values(); + if (!only) break; + name += `/${only.name}`; + fullPath += `/${only.name}`; + node = only; + } + return { node, name, path: fullPath }; +} + +function buildRows(files: readonly DemoFile[]) { + const rows: TreeRow[] = []; + const walk = ( + node: TreeNode, + depth: number, + prefix: string, + ancestors: string[], + ) => { + const dirs = [...node.dirs.values()].sort((a, b) => + compareNames(a.name, b.name), + ); + for (const dir of dirs) { + const merged = collapseSingleChild(dir, prefix + dir.name); + rows.push({ + kind: "dir", + name: merged.name, + path: merged.path, + depth, + ancestors, + files: files + .filter((file) => file.path.startsWith(`${merged.path}/`)) + .map((file) => file.path), + file: null, + }); + walk(merged.node, depth + 1, `${merged.path}/`, [ + ...ancestors, + merged.path, + ]); + } + const sorted = [...node.files].sort((a, b) => + compareNames(baseName(a.path), baseName(b.path)), + ); + for (const file of sorted) + rows.push({ + kind: "file", + name: baseName(file.path), + path: file.path, + depth, + ancestors, + files: [], + file, + }); + }; + walk(treeOf(files), 0, "", []); + return rows; +} + +function fileIconName(file: DemoFile) { + if (file.status === "added") return "file-add"; + if (file.status === "renamed") return "file-ren"; + return "file-diff"; +} + +function treeRowHtml(row: TreeRow) { + const key = escapeHtml(`${row.kind}:${row.path}`); + if (row.kind === "dir") + return `
  • `; + const file = row.file; + const added = file?.status === "added" ? " is-added" : ""; + const fileIcon = file ? fileIconName(file) : "file-diff"; + return `
  • `; +} + +function diffBodyHtml() { + const lines = PAYMENTS_DIFF.map((line) => { + const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : " "; + const tone = line.kind === "context" ? "" : line.kind; + return `${line.oldNumber ?? ""}${line.newNumber ?? ""}${sign}${escapeHtml(line.text)}`; + }).join(""); + return `
    ${PAYMENTS_HUNK}${lines}
    `; +} + +function diffSectionHtml(file: DemoFile) { + const expanded = file.path === EXPANDED_FILE; + const path = file.from + ? `${escapeHtml(file.from)} → ${escapeHtml(file.path)}` + : escapeHtml(file.path); + const stat = expanded + ? `+9−1` + : ""; + return `
    ${icon(expanded ? "chev-d" : "chev-r")}${path}${stat}
    ${expanded ? diffBodyHtml() : ""}
    `; +} + +function customRuleHtml(rule: SandboxState["custom"][number]) { + const pattern = escapeHtml(rule.pattern); + return `
  • `; +} + +function alwaysRuleHtml(rule: SandboxState["always"][number]) { + const pattern = escapeHtml(rule.pattern); + return `
  • ${icon("eye", "lead")}${pattern}
  • `; +} + +const reasonWithPattern = ( + reason: Parameters[0], + pattern = reason?.pattern ?? "", +) => `${reasonPhrase(reason)} (${pattern})`; + +export function startSandbox() { + const narrow = matchMedia("(max-width: 719.98px)"); + const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)"); + let state = initialState(!narrow.matches); + let lastView: Model["view"] | null = null; + let ruleSequence = 0; + + const gh = byId("gh", HTMLDivElement); + const toolbar = byId("gh-toolbar", HTMLDivElement); + const control = byId("fh-ctl", HTMLDivElement); + const mainButton = byId("fh-main", HTMLButtonElement); + const mainLabel = byId("fh-main-label", HTMLSpanElement); + const mainCount = byId("fh-main-count", HTMLSpanElement); + const separator = byId("fh-sep", HTMLSpanElement); + const menuButton = byId("fh-menu-btn", HTMLButtonElement); + const status = byId("fh-status", HTMLSpanElement); + const statusCounter = byId("fh-status-counter", HTMLSpanElement); + const statusCount = byId("fh-status-count", HTMLSpanElement); + const noun = byId("fh-noun", HTMLSpanElement); + const menu = byId("fh-menu", HTMLDivElement); + const emptyBox = byId("fh-empty", HTMLDivElement); + const pullRequestSwitch = byId("fh-pr", HTMLButtonElement); + const showButton = byId("fh-show", HTMLButtonElement); + const showIcon = byId("fh-show-use", SVGUseElement); + const showLabel = byId("fh-show-label", HTMLSpanElement); + const showDescription = byId("fh-show-desc", HTMLSpanElement); + const showTrail = byId("fh-show-trail", HTMLSpanElement); + const showCount = byId("fh-show-count", HTMLSpanElement); + const treeSwitch = byId("fh-tree", HTMLButtonElement); + const treeDescription = byId("fh-tree-desc", HTMLSpanElement); + const customList = byId("fh-custom-list", HTMLUListElement); + const customNote = byId("fh-custom-note", HTMLSpanElement); + const alwaysList = byId("fh-always-list", HTMLUListElement); + const alwaysToggle = byId("fh-always-toggle", HTMLButtonElement); + const treeList = byId("tree-list", HTMLUListElement); + const treeEmpty = byId("tree-empty", HTMLDivElement); + const treePanel = inside(document, ".gh-tree", HTMLElement); + const filterInput = byId("gh-filter", HTMLInputElement); + const diffPane = byId("gh-diff", HTMLElement); + const diffList = byId("diff-list", HTMLDivElement); + const blank = byId("fh-blank", HTMLDivElement); + const blankTitle = byId("fh-blank-title", HTMLHeadingElement); + const blankBody = byId("fh-blank-body", HTMLParagraphElement); + const shownReadout = byId("ro-shown", HTMLElement); + const live = byId("live", HTMLParagraphElement); + const reset = byId("reset", HTMLButtonElement); + const forms = { + custom: { + form: byId("fh-custom-form", HTMLFormElement), + input: byId("fh-custom-input", HTMLInputElement), + error: byId("fh-custom-error", HTMLParagraphElement), + }, + always: { + form: byId("fh-always-form", HTMLFormElement), + input: byId("fh-always-input", HTMLInputElement), + error: byId("fh-always-error", HTMLParagraphElement), + }, + }; + + const rows = buildRows(DEMO_FILES); + const fileOrder = rows.flatMap((row) => (row.file ? [row.file] : [])); + const treeRows = new Map(); + const diffSections = new Map(); + + function renderTree() { + treeList.innerHTML = rows.map(treeRowHtml).join(""); + for (const item of treeList.querySelectorAll("li.tr")) { + treeRows.set(item.dataset.key ?? "", item); + const button = inside(item, ".tr-btn", HTMLButtonElement); + button.style.setProperty("--d", button.dataset.depth ?? "0"); + } + } + + function renderDiff() { + diffList.innerHTML = fileOrder.map(diffSectionHtml).join(""); + for (const section of diffList.querySelectorAll("section.df")) + diffSections.set(section.dataset.path ?? "", section); + } + + function renderLists() { + customList.innerHTML = state.custom.length + ? state.custom.map(customRuleHtml).join("") + : `
  • ${copy.menuCustomNone}
  • `; + alwaysList.innerHTML = state.always.length + ? state.always.map(alwaysRuleHtml).join("") + : `
  • ${copy.settingsPage.alwaysEmptyTitle}
  • `; + } + + function setCount(element: HTMLElement, value: number, animate: boolean) { + const next = String(value); + if (element.dataset.value === next) return; + const previous = element.dataset.value; + element.dataset.value = next; + const still = + !animate || + previous === undefined || + reduceMotion.matches || + gh.classList.contains("is-still"); + if (still) { + element.innerHTML = `${next}`; + return; + } + element.style.setProperty( + "--dir", + Number(next) > Number(previous) ? "1" : "-1", + ); + for (const child of [...element.children].slice(0, -1)) child.remove(); + const outgoing = element.lastElementChild; + if (outgoing) { + outgoing.className = "roll-out"; + outgoing.addEventListener("animationend", () => outgoing.remove(), { + once: true, + }); + } + const incoming = document.createElement("span"); + incoming.className = "roll-in"; + incoming.textContent = next; + element.append(incoming); + } + + function syncControl(model: Model) { + const split = model.view === "inactive" || model.view === "showing"; + control.dataset.view = model.view; + control.toggleAttribute("data-split", split); + const mainHadFocus = document.activeElement === mainButton; + mainButton.hidden = !split; + separator.hidden = !split; + status.hidden = split; + mainLabel.textContent = + model.view === "showing" ? copy.hideAgain : copy.hideFiles; + setCount(mainCount, model.match, true); + mainButton.title = + model.view === "showing" ? copy.tipHideAgain : copy.tipHide(model.match); + if (model.view === "filtering") { + statusCounter.hidden = false; + setCount(statusCount, model.hidden, true); + noun.textContent = copy.filesHidden(model.hidden); + } else { + statusCounter.hidden = true; + noun.textContent = copy.hideFiles; + } + if (split) menuButton.setAttribute("aria-label", `${copy.product} menu`); + else menuButton.removeAttribute("aria-label"); + menuButton.title = model.view === "empty" ? copy.tipEmpty : ""; + if ( + lastView !== null && + lastView !== model.view && + model.view === "filtering" + ) { + status.classList.remove("is-pop"); + void status.offsetWidth; + status.classList.add("is-pop"); + } + if (mainHadFocus && mainButton.hidden) menuButton.focus(); + } + + function syncCounts(model: Model) { + for (const element of menu.querySelectorAll("[data-count]")) { + const key = element.dataset.count ?? ""; + element.textContent = String(model.counts.get(key) ?? 0); + element.classList.toggle("is-live", model.live.get(key) === true); + } + for (const element of menu.querySelectorAll( + "[data-count-sr]", + )) { + const key = element.dataset.countSr ?? ""; + const count = model.counts.get(key) ?? 0; + const verb = key.startsWith("always") + ? "kept visible" + : model.live.get(key) + ? "hidden" + : plural(count, "matches", "match"); + element.textContent = `, ${count} ${plural(count, "file", "files")} ${verb}`; + } + } + + function syncShowAll(model: Model) { + showButton.hidden = !( + model.view === "filtering" || model.view === "showing" + ); + if (model.view === "showing") { + showIcon.setAttribute("href", "#i-eye-off"); + showLabel.innerHTML = `${copy.hideAgain}, ${model.match} ${plural(model.match, "file", "files")}`; + showDescription.textContent = copy.menuHideAgainDesc; + showTrail.hidden = false; + setCount(showCount, model.match, true); + return; + } + showIcon.setAttribute("href", "#i-eye"); + showLabel.textContent = copy.showAll; + showDescription.textContent = copy.menuShowAllDesc; + showTrail.hidden = true; + } + + function syncMenu(model: Model) { + menu.hidden = !state.menuOpen; + menuButton.setAttribute("aria-expanded", String(state.menuOpen)); + const empty = model.view === "empty"; + emptyBox.hidden = !empty; + pullRequestSwitch.hidden = empty; + pullRequestSwitch.setAttribute("aria-checked", String(state.active)); + syncShowAll(model); + for (const input of menu.querySelectorAll( + "[data-preset]", + )) { + const preset = input.dataset.preset; + if (preset === "tests" || preset === "lockfiles") + input.checked = state.presets[preset]; + } + syncCounts(model); + for (const row of customList.querySelectorAll(".fh-rule")) { + const rule = state.custom.find( + (candidate) => candidate.id === row.dataset.id, + ); + if (rule) row.classList.toggle("is-off", !rule.enabled); + } + const enabled = state.custom.filter((rule) => rule.enabled).length; + customNote.textContent = state.custom.length + ? copy.settingsPage.rulesOn(enabled, state.custom.length) + : ""; + treeSwitch.setAttribute("aria-checked", String(state.tree)); + treeDescription.textContent = state.tree + ? copy.menuTreeOn + : copy.menuTreeOff; + } + + function treeTip(model: Model, path: string) { + const file = model.byPath.get(path); + if (!file) return ""; + if (file.state === "hidden" && !state.tree) + return copy.tipTreeHidden(reasonWithPattern(file.evaluation.reason)); + if (file.state === "revealed") return copy.tipTreeRevealed; + return ""; + } + + function syncTree(model: Model) { + const query = state.filter.trim().toLowerCase(); + const passesFilter = (path: string) => + !query || path.toLowerCase().includes(query); + const leavesTree = (path: string) => + model.byPath.get(path)?.state === "hidden" && state.tree; + for (const row of rows) { + const item = treeRows.get(`${row.kind}:${row.path}`); + if (!item) continue; + const button = inside(item, ".tr-btn", HTMLButtonElement); + const collapsed = row.ancestors.some((ancestor) => + state.collapsed.has(ancestor), + ); + let gone: boolean; + if (row.kind === "dir") { + gone = + collapsed || + !row.files.some((path) => !leavesTree(path) && passesFilter(path)); + button.setAttribute( + "aria-expanded", + String(!state.collapsed.has(row.path)), + ); + } else { + const file = model.byPath.get(row.path); + const muted = file?.state === "hidden" && !state.tree; + const revealed = file?.state === "revealed"; + gone = collapsed || leavesTree(row.path) || !passesFilter(row.path); + item.classList.toggle("is-muted", muted); + item.classList.toggle("is-revealed", revealed); + inside(item, ".tr-hint use", SVGUseElement).setAttribute( + "href", + revealed ? "#i-eye" : "#i-eye-off", + ); + const tip = treeTip(model, row.path); + inside(item, ".tr-tip", HTMLSpanElement).textContent = tip + ? `. ${tip}` + : ""; + button.title = tip; + button.setAttribute("aria-current", String(state.current === row.path)); + } + item.classList.toggle("is-gone", gone); + item.inert = gone; + } + treeEmpty.hidden = !( + model.filtering && + state.tree && + model.files.every((file) => file.state === "hidden") + ); + } + + function syncLabels(section: HTMLElement, file: Model["files"][number]) { + const labels = inside(section, ".df-labels", HTMLSpanElement); + const { evaluation } = file; + if (file.state === "kept" && evaluation.reason) { + labels.innerHTML = `${copy.labelKept}`; + } else if (file.state === "revealed") { + labels.innerHTML = `${copy.labelRevealed}`; + } else { + labels.innerHTML = ""; + } + const notice = inside(section, ".fh-flash", HTMLDivElement); + notice.hidden = file.state !== "revealed"; + if (file.state === "revealed") + inside(section, ".fh-flash-detail", HTMLParagraphElement).innerHTML = + copy.noticeFile( + `${reasonPhrase(evaluation.reason)} ${escapeHtml(evaluation.reason?.pattern ?? "")}`, + ); + } + + function syncDiff(model: Model) { + for (const file of model.files) { + const section = diffSections.get(file.path); + if (!section) continue; + const gone = file.state === "hidden"; + section.classList.toggle("is-gone", gone); + section.inert = gone; + section.classList.toggle("is-revealed", file.state === "revealed"); + syncLabels(section, file); + } + const shown = model.files.filter((file) => file.state !== "hidden"); + const onlyKept = + shown.length > 0 && shown.every((file) => file.state === "kept"); + blank.hidden = !(model.filtering && (shown.length === 0 || onlyKept)); + if (!blank.hidden) { + blankTitle.textContent = onlyKept ? copy.emptyKept : copy.emptyAll; + blankBody.textContent = onlyKept + ? copy.emptyKeptBody(shown.length) + : copy.emptyAllBody(true); + } + return shown.length; + } + + function placeMenu() { + if (narrow.matches || menu.hidden) return; + const frame = gh.getBoundingClientRect(); + const anchor = control.getBoundingClientRect(); + const left = Math.max( + 8, + Math.min(anchor.left - frame.left, frame.width - menu.offsetWidth - 8), + ); + gh.style.setProperty("--menu-left", `${Math.round(left)}px`); + gh.style.setProperty("--menu-right", "auto"); + gh.style.setProperty( + "--menu-top", + `${toolbar.offsetTop + toolbar.offsetHeight + 4}px`, + ); + } + + function viewMessage(model: Model) { + if (model.view === "filtering") return copy.live.hidden(model.hidden); + if (model.view === "showing") return copy.live.showing(model.match); + return copy.live.off; + } + + function announce(model: Model, prefix = "") { + const message = [prefix, viewMessage(model)].filter(Boolean).join(" "); + live.textContent = ""; + requestAnimationFrame(() => { + live.textContent = message; + }); + } + + function sync(options: { announce?: boolean; prefix?: string } = {}) { + const model = derive(state); + syncControl(model); + syncMenu(model); + syncTree(model); + shownReadout.textContent = String(syncDiff(model)); + placeMenu(); + lastView = model.view; + if (options.announce) announce(model, options.prefix); + return model; + } + + function setMenuOpen(open: boolean, focusInside = false) { + state.menuOpen = open; + sync(); + if (!open || !focusInside) return; + menu.querySelector("button:not([hidden]), input")?.focus(); + } + + function scrollDiffTo(path: string) { + const section = diffSections.get(path); + if (!section || narrow.matches) return; + diffPane.scrollTo({ + top: Math.max(0, section.offsetTop - 12), + behavior: reduceMotion.matches ? "auto" : "smooth", + }); + section.classList.remove("is-flash"); + void section.offsetWidth; + section.classList.add("is-flash"); + } + + function showAllFiles() { + state.showingAll = true; + state.revealed.clear(); + sync({ announce: true }); + menuButton.focus(); + } + + function setAlwaysFormOpen(open: boolean) { + forms.always.form.hidden = !open; + alwaysToggle.setAttribute("aria-expanded", String(open)); + if (open) forms.always.input.focus(); + placeMenu(); + } + + function clearError(list: RuleList) { + const { input, error } = forms[list]; + input.removeAttribute("aria-invalid"); + error.hidden = true; + error.textContent = ""; + } + + function addRule(list: RuleList, value: string) { + ruleSequence += 1; + const id = `${list}-${ruleSequence}`; + if (list === "custom") + state.custom.push({ id, pattern: value, enabled: true }); + else state.always.push({ id, pattern: value }); + } + + function submitRule(list: RuleList) { + const { input, error } = forms[list]; + const value = input.value.trim(); + const message = validatePattern( + value, + state[list].map((rule) => rule.pattern), + copy.validation, + ); + if (message) { + input.setAttribute("aria-invalid", "true"); + error.innerHTML = `${icon("alert")}${escapeHtml(message)}`; + error.hidden = false; + input.focus(); + return; + } + clearError(list); + addRule(list, value); + input.value = ""; + renderLists(); + sync({ announce: true, prefix: copy.live.ruleAdded }); + } + + function removeRule(list: RuleList, id: string) { + if (list === "custom") + state.custom = state.custom.filter((rule) => rule.id !== id); + else state.always = state.always.filter((rule) => rule.id !== id); + renderLists(); + sync({ announce: true, prefix: copy.live.ruleRemoved }); + if (list === "always") setAlwaysFormOpen(true); + else forms.custom.input.focus(); + } + + function selectTreeRow(button: HTMLButtonElement) { + const directory = button.dataset.dir; + if (directory) { + if (state.collapsed.has(directory)) state.collapsed.delete(directory); + else state.collapsed.add(directory); + sync(); + return; + } + const path = button.dataset.file ?? ""; + state.current = path; + if (derive(state).byPath.get(path)?.state === "hidden") { + state.revealed.add(path); + sync({ announce: true, prefix: `${path} is temporarily visible.` }); + requestAnimationFrame(() => scrollDiffTo(path)); + return; + } + sync(); + scrollDiffTo(path); + } + + function hideRevealed(path: string) { + state.revealed.delete(path); + sync({ announce: true, prefix: copy.live.hiddenAgain(path) }); + const row = treeRows.get(`file:${path}`); + const rowButton = row && !row.inert ? row.querySelector("button") : null; + if (rowButton) rowButton.focus(); + else diffPane.focus(); + } + + function resetDemo() { + state = initialState(!narrow.matches); + filterInput.value = ""; + for (const list of ["custom", "always"] as const) { + forms[list].input.value = ""; + clearError(list); + } + forms.always.form.hidden = true; + alwaysToggle.setAttribute("aria-expanded", "false"); + renderLists(); + gh.classList.add("is-still"); + sync({ announce: true, prefix: "Demo reset." }); + diffPane.scrollTop = 0; + treePanel.scrollTop = 0; + requestAnimationFrame(() => + requestAnimationFrame(() => gh.classList.remove("is-still")), + ); + } + + mainButton.addEventListener("click", () => { + state.active = true; + state.showingAll = false; + state.revealed.clear(); + sync({ announce: true }); + }); + + menuButton.addEventListener("click", (event) => + setMenuOpen(!state.menuOpen, event.detail === 0), + ); + + pullRequestSwitch.addEventListener("click", () => { + state.active = !state.active; + state.showingAll = false; + state.revealed.clear(); + sync({ announce: true }); + }); + + showButton.addEventListener("click", () => { + state.showingAll = !state.showingAll; + state.revealed.clear(); + sync({ announce: true }); + }); + + treeSwitch.addEventListener("click", () => { + state.tree = !state.tree; + sync(); + }); + + menu.addEventListener("change", (event) => { + const target = targetOf(event); + if (!(target instanceof HTMLInputElement)) return; + const preset = target.dataset.preset; + if (preset === "tests" || preset === "lockfiles") { + state.presets[preset] = target.checked; + sync({ announce: true }); + return; + } + const rule = state.custom.find( + (candidate) => candidate.id === target.dataset.customToggle, + ); + if (!rule) return; + rule.enabled = target.checked; + sync({ announce: true }); + }); + + menu.addEventListener("click", (event) => { + const remove = targetOf(event)?.closest("[data-remove]"); + const list = remove?.dataset.remove; + if (list === "custom" || list === "always") + removeRule(list, remove?.dataset.id ?? ""); + }); + + menu.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + setMenuOpen(false); + menuButton.focus(); + }); + + menuButton.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || !state.menuOpen) return; + event.preventDefault(); + setMenuOpen(false); + }); + + alwaysToggle.addEventListener("click", () => + setAlwaysFormOpen(forms.always.form.hidden !== false), + ); + + for (const list of ["custom", "always"] as const) { + forms[list].input.addEventListener("input", () => clearError(list)); + forms[list].form.addEventListener("submit", (event) => { + event.preventDefault(); + submitRule(list); + }); + } + + gh.addEventListener("pointerdown", (event) => { + if (!state.menuOpen || narrow.matches) return; + const target = targetOf(event); + if (target && (menu.contains(target) || control.contains(target))) return; + state.menuOpen = false; + sync(); + }); + + treeList.addEventListener("click", (event) => { + const button = targetOf(event)?.closest(".tr-btn"); + if (button instanceof HTMLButtonElement) selectTreeRow(button); + }); + + diffPane.addEventListener("click", (event) => { + const target = targetOf(event); + const hideAgain = target?.closest("[data-hide-again]"); + if (hideAgain) { + hideRevealed(hideAgain.dataset.hideAgain ?? ""); + return; + } + if (target?.closest("[data-act='show-all']")) showAllFiles(); + }); + + treeEmpty.addEventListener("click", (event) => { + if (targetOf(event)?.closest("[data-act='show-all']")) showAllFiles(); + }); + + filterInput.addEventListener("input", () => { + state.filter = filterInput.value; + sync(); + }); + + reset.addEventListener("click", resetDemo); + diffPane.tabIndex = -1; + addEventListener("resize", placeMenu); + narrow.addEventListener("change", () => sync()); + document.fonts.ready.then(placeMenu); + + renderTree(); + renderDiff(); + renderLists(); + sync(); + requestAnimationFrame(() => + requestAnimationFrame(() => gh.classList.remove("is-still")), + ); +} diff --git a/site/src/styles.css b/site/src/styles.css new file mode 100644 index 0000000..4ff7d4d --- /dev/null +++ b/site/src/styles.css @@ -0,0 +1,2441 @@ +:root { + --font-display: + "Familjen Grotesk Variable", "Helvetica Neue", Helvetica, Arial, sans-serif; + --font-body: + "Atkinson Hyperlegible Next Variable", "Segoe UI", system-ui, -apple-system, + Roboto, sans-serif; + --font-mono: + "Fragment Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + "Liberation Mono", monospace; + --gh-font: + "Mona Sans Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", + "Noto Sans", Helvetica, Arial, sans-serif; + --gh-mono: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; + --ease-out: cubic-bezier(0.33, 1, 0.68, 1); + --ease-quad: cubic-bezier(0.5, 1, 0.89, 1); + + --pg-bg: #e6e9e5; + --pg-dot: #c3c9c4; + --pg-surface: #f1f3f0; + --pg-raise: #fafbfa; + --pg-ink: #131715; + --pg-ink-soft: #2a312d; + --pg-muted: #4a5350; + --pg-line: #c7cdc8; + --pg-line-strong: #8c968f; + --pg-accent: #0969da; + --pg-link: #0757b8; + --pg-cta-bg: #131715; + --pg-cta-fg: #f1f3f0; + --pg-cta-hover: #2f3833; + --pg-chip-bg: #dbe0db; + --pg-focus: #0969da; + --pg-numeral: #9aa49d; + + --gh-fg: #1f2328; + --gh-fg-muted: #59636e; + --gh-fg-on-emphasis: #ffffff; + --gh-bg: #ffffff; + --gh-bg-muted: #f6f8fa; + --gh-overlay-bg: #ffffff; + --gh-border: #d1d9e0; + --gh-border-muted: #d1d9e0b3; + --gh-accent: #0969da; + --gh-accent-emphasis: #0969da; + --gh-accent-muted: #ddf4ff; + --gh-accent-border: #54aeff66; + --gh-success: #1a7f37; + --gh-danger: #d1242f; + --gh-open-bg: #1f883d; + --gh-neutral-muted: #818b981f; + --gh-control-bg: #f6f8fa; + --gh-control-hover: #eff2f5; + --gh-control-border: #d1d9e0; + --gh-transparent-hover: #818b981a; + --gh-transparent-active: #818b9826; + --gh-transparent-selected: #818b9826; + --gh-primary-bg: #1f883d; + --gh-track-bg: #e6eaef; + --gh-track-hover: #dde3e9; + --gh-track-border: #e6eaef; + --gh-knob-bg: #ffffff; + --gh-knob-border: #818b98; + --gh-checked: #0969da; + --gh-checked-hover: #0860ca; + --gh-check-border: #818b98; + --gh-input-bg: #ffffff; + --gh-focus: #0969da; + --gh-folder: #54aeff; + --gh-add-bg: #dafbe1; + --gh-add-num: #aceebb; + --gh-del-bg: #ffebe9; + --gh-del-num: #ffcecb; + --gh-hunk-bg: #ddf4ff; + --gh-square-add: #1a7f37; + --gh-square-del: #cf222e; + --gh-flash: #fff8c5; + --gh-shadow-floating: + 0 0 0 1px #d1d9e080, 0 6px 12px -3px #25292e0a, 0 6px 18px 0 #25292e1f; + --gh-shadow-knob: 0 1px 1px 0 #1f23280f, 0 1px 3px 0 #1f23280f; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --pg-bg: #0f1211; + --pg-dot: #252b28; + --pg-surface: #161a18; + --pg-raise: #1c211f; + --pg-ink: #e4e9e5; + --pg-ink-soft: #c7cec9; + --pg-muted: #9ba49f; + --pg-line: #2b322f; + --pg-line-strong: #4f5954; + --pg-accent: #4493f8; + --pg-link: #74b0fb; + --pg-cta-bg: #e4e9e5; + --pg-cta-fg: #0f1211; + --pg-cta-hover: #c7cec9; + --pg-chip-bg: #232926; + --pg-focus: #4493f8; + --pg-numeral: #5c6761; + + --gh-fg: #f0f6fc; + --gh-fg-muted: #9198a1; + --gh-fg-on-emphasis: #ffffff; + --gh-bg: #0d1117; + --gh-bg-muted: #151b23; + --gh-overlay-bg: #151b23; + --gh-border: #3d444d; + --gh-border-muted: #3d444db3; + --gh-accent: #4493f8; + --gh-accent-emphasis: #1f6feb; + --gh-accent-muted: #388bfd1a; + --gh-accent-border: #388bfd66; + --gh-success: #3fb950; + --gh-danger: #f85149; + --gh-open-bg: #238636; + --gh-neutral-muted: #656c7633; + --gh-control-bg: #212830; + --gh-control-hover: #262c36; + --gh-control-border: #3d444d; + --gh-transparent-hover: #656c7633; + --gh-transparent-active: #656c7640; + --gh-transparent-selected: #656c7626; + --gh-primary-bg: #238636; + --gh-track-bg: #262c36; + --gh-track-hover: #2a313c; + --gh-track-border: #3d444d; + --gh-knob-bg: #0d1117; + --gh-knob-border: #656c76; + --gh-checked: #1f6feb; + --gh-checked-hover: #2a7aef; + --gh-check-border: #6e7681; + --gh-input-bg: #0d1117; + --gh-focus: #1f6feb; + --gh-folder: #9198a1; + --gh-add-bg: #2ea04326; + --gh-add-num: #3fb9504d; + --gh-del-bg: #f851491a; + --gh-del-num: #f851494d; + --gh-hunk-bg: #388bfd1a; + --gh-square-add: #3fb950; + --gh-square-del: #f85149; + --gh-flash: #bb800926; + --gh-shadow-floating: + 0 0 0 1px #3d444d, 0 6px 12px -3px #01040966, 0 6px 18px 0 #01040966; + --gh-shadow-knob: 0 1px 1px 0 #01040999, 0 1px 3px 0 #01040999; + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --pg-bg: #0f1211; + --pg-dot: #252b28; + --pg-surface: #161a18; + --pg-raise: #1c211f; + --pg-ink: #e4e9e5; + --pg-ink-soft: #c7cec9; + --pg-muted: #9ba49f; + --pg-line: #2b322f; + --pg-line-strong: #4f5954; + --pg-accent: #4493f8; + --pg-link: #74b0fb; + --pg-cta-bg: #e4e9e5; + --pg-cta-fg: #0f1211; + --pg-cta-hover: #c7cec9; + --pg-chip-bg: #232926; + --pg-focus: #4493f8; + --pg-numeral: #5c6761; + + --gh-fg: #f0f6fc; + --gh-fg-muted: #9198a1; + --gh-fg-on-emphasis: #ffffff; + --gh-bg: #0d1117; + --gh-bg-muted: #151b23; + --gh-overlay-bg: #151b23; + --gh-border: #3d444d; + --gh-border-muted: #3d444db3; + --gh-accent: #4493f8; + --gh-accent-emphasis: #1f6feb; + --gh-accent-muted: #388bfd1a; + --gh-accent-border: #388bfd66; + --gh-success: #3fb950; + --gh-danger: #f85149; + --gh-open-bg: #238636; + --gh-neutral-muted: #656c7633; + --gh-control-bg: #212830; + --gh-control-hover: #262c36; + --gh-control-border: #3d444d; + --gh-transparent-hover: #656c7633; + --gh-transparent-active: #656c7640; + --gh-transparent-selected: #656c7626; + --gh-primary-bg: #238636; + --gh-track-bg: #262c36; + --gh-track-hover: #2a313c; + --gh-track-border: #3d444d; + --gh-knob-bg: #0d1117; + --gh-knob-border: #656c76; + --gh-checked: #1f6feb; + --gh-checked-hover: #2a7aef; + --gh-check-border: #6e7681; + --gh-input-bg: #0d1117; + --gh-focus: #1f6feb; + --gh-folder: #9198a1; + --gh-add-bg: #2ea04326; + --gh-add-num: #3fb9504d; + --gh-del-bg: #f851491a; + --gh-del-num: #f851494d; + --gh-hunk-bg: #388bfd1a; + --gh-square-add: #3fb950; + --gh-square-del: #f85149; + --gh-flash: #bb800926; + --gh-shadow-floating: + 0 0 0 1px #3d444d, 0 6px 12px -3px #01040966, 0 6px 18px 0 #01040966; + --gh-shadow-knob: 0 1px 1px 0 #01040999, 0 1px 3px 0 #01040999; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + scroll-padding-top: 24px; +} + +body { + margin: 0; + background: var(--pg-bg); + color: var(--pg-ink); + font: 400 1.0625rem / 1.6 var(--font-body); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +img { + max-width: 100%; + height: auto; + display: block; +} + +a { + color: var(--pg-link); + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} +a:hover { + text-decoration-thickness: 2px; +} + +:focus-visible { + outline: 2px solid var(--pg-focus); + outline-offset: 2px; +} + +h1, +h2, +h3 { + margin: 0; + font-family: var(--font-display); + font-weight: 600; + color: var(--pg-ink); +} +p { + margin: 0; +} + +code { + font-family: var(--font-mono); + font-size: 0.86em; +} + +.sr { + position: absolute !important; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.skip { + position: absolute; + left: 16px; + top: -64px; + z-index: 50; + padding: 10px 14px; + border-radius: 8px; + background: var(--pg-cta-bg); + color: var(--pg-cta-fg); + font-weight: 700; + text-decoration: none; +} +.skip:focus { + top: 12px; +} + +.i { + width: 16px; + height: 16px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.page { + position: relative; + padding-inline: max( + 16px, + min(4vw, 48px), + env(safe-area-inset-left), + env(safe-area-inset-right) + ); + overflow-x: clip; + isolation: isolate; +} + +.page::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 1100px; + z-index: -1; + background-image: radial-gradient( + circle, + var(--pg-dot) 1px, + transparent 1.4px + ); + background-size: 22px 22px; + -webkit-mask-image: linear-gradient( + to bottom, + #000 0%, + #000 45%, + transparent 100% + ); + mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 100%); +} + +.shell { + max-width: 1240px; + margin-inline: auto; +} + +.logo { + display: block; + flex: none; + color: var(--pg-ink); +} +.logo-band { + fill: var(--pg-accent); +} + +.top { + display: flex; + align-items: center; + gap: 24px; + min-height: 72px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--pg-ink); + text-decoration: none; + font: 600 1.0625rem / 1 var(--font-display); + letter-spacing: -0.01em; +} + +.top-nav { + display: flex; + gap: 4px; + margin-left: auto; +} +.top-nav a { + padding: 8px 12px; + border-radius: 8px; + color: var(--pg-ink-soft); + text-decoration: none; + font-size: 0.9375rem; +} +.top-nav a:hover { + background: var(--pg-chip-bg); + color: var(--pg-ink); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 48px; + padding: 0 20px; + border: 1px solid transparent; + border-radius: 10px; + font: 700 1rem / 1.2 var(--font-body); + text-decoration: none; + white-space: nowrap; + cursor: pointer; + transition: + background-color 160ms var(--ease-out), + border-color 160ms var(--ease-out), + transform 160ms var(--ease-out); +} +.btn:active { + transform: translateY(1px); +} +.btn-primary { + background: var(--pg-cta-bg); + color: var(--pg-cta-fg); +} +.btn-primary:hover { + background: var(--pg-cta-hover); + text-decoration: none; +} +.btn-ghost { + border-color: var(--pg-line-strong); + color: var(--pg-ink); + background: transparent; +} +.btn-ghost:hover { + background: var(--pg-chip-bg); + text-decoration: none; +} +.btn-small { + gap: 0; + min-height: 38px; + padding: 0 14px; + border-radius: 8px; + font-size: 0.9375rem; +} +.btn .band { + display: inline-block; + width: 14px; + height: 4px; + border-radius: 2px; + background: var(--pg-accent); +} + +.hero { + padding-block: clamp(28px, 5vw, 56px) 0; +} + +.hero-head { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); + gap: 24px clamp(32px, 5vw, 72px); + align-items: end; +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 10px; + margin-bottom: 18px; + font: 400 0.8125rem / 1.4 var(--font-mono); + color: var(--pg-muted); +} +.eyebrow::before { + content: ""; + width: 22px; + height: 5px; + border-radius: 2px; + background: var(--pg-accent); +} + +.hero h1 { + font-size: clamp(2.5rem, 5.6vw, 4.75rem); + line-height: 0.98; + letter-spacing: -0.035em; + font-weight: 600; + text-wrap: balance; +} + +.hero h1 .ghost { + color: transparent; + -webkit-text-stroke: 1.25px var(--pg-ink); + text-decoration: underline dashed var(--pg-line-strong) 1.5px; + text-underline-offset: 0.16em; +} + +.hero-copy { + min-width: 0; + display: grid; + gap: 22px; + padding-bottom: 6px; +} +.lede { + font-size: clamp(1.0625rem, 1.4vw, 1.1875rem); + line-height: 1.55; + color: var(--pg-ink-soft); + max-width: 36em; +} +.ctas { + display: flex; + flex-wrap: wrap; + gap: 12px; +} +.fine { + font: 400 0.8125rem / 1.5 var(--font-mono); + color: var(--pg-muted); +} + +.sandbox { + margin-top: clamp(28px, 4vw, 44px); + border: 1px solid var(--pg-line-strong); + border-radius: 14px; + background: var(--pg-surface); + overflow: hidden; +} + +.sandbox-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 20px; + padding: 10px 12px 10px 16px; + border-bottom: 1px solid var(--pg-line-strong); + font: 400 0.8125rem / 1.4 var(--font-mono); + color: var(--pg-muted); +} + +.try { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; + flex: 1 1 520px; + margin: 0; + padding: 0; + list-style: none; +} +.try-title { + font: inherit; + color: var(--pg-ink); + margin-right: 4px; +} +.try li { + display: inline-flex; + align-items: center; + gap: 6px; +} +.try li + li::before { + content: ""; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--pg-line-strong); + margin-right: 2px; +} +.try .narrow-only { + display: none; +} +.try code { + font-size: 1em; + padding: 1px 5px; + border-radius: 5px; + background: var(--pg-chip-bg); + color: var(--pg-ink); +} + +.sandbox-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 14px; + margin-left: auto; +} +.sandbox-actions a { + color: var(--pg-ink-soft); +} +.readout { + color: var(--pg-ink); + white-space: nowrap; +} +.readout b { + font-weight: 400; + color: var(--pg-ink); +} + +.reset { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--pg-line-strong); + border-radius: 8px; + background: var(--pg-raise); + color: var(--pg-ink); + font: 400 0.8125rem / 1 var(--font-mono); + cursor: pointer; +} +.reset:hover { + background: var(--pg-chip-bg); +} + +.sandbox-note { + display: flex; + gap: 10px; + align-items: baseline; + max-width: 62em; + margin-top: 14px; + font-size: 0.9375rem; + color: var(--pg-muted); +} +.sandbox-note::before { + content: ""; + flex: none; + width: 10px; + height: 10px; + border: 1.5px dashed var(--pg-line-strong); + border-radius: 2px; + transform: translateY(1px); +} + +.gh { + --gh-body-h: 604px; + position: relative; + background: var(--gh-bg); + color: var(--gh-fg); + font: 400 14px / 20px var(--gh-font); + -webkit-font-smoothing: antialiased; + text-align: left; +} + +.gh .i { + width: 16px; + height: 16px; +} +.gh code { + font: 12px / 20px var(--gh-mono); +} +.gh :focus-visible { + outline: 2px solid var(--gh-focus); + outline-offset: -2px; +} + +.gh-tabs { + display: flex; + align-items: flex-end; + gap: 4px; + min-height: 52px; + padding: 8px 16px 0; + border-bottom: 1px solid var(--gh-border); + background: var(--gh-bg); +} + +.gh-tab { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + margin-bottom: -1px; + padding: 0 14px; + border: 1px solid transparent; + border-bottom: 0; + border-radius: 6px 6px 0 0; + color: var(--gh-fg-muted); + white-space: nowrap; +} +.gh-tab.is-current { + border-color: var(--gh-border); + background: var(--gh-bg); + color: var(--gh-fg); + box-shadow: 0 1px 0 var(--gh-bg); +} +.gh-tab .i { + color: var(--gh-fg-muted); +} + +.gh-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 2em; + background: var(--gh-neutral-muted); + font: 500 12px / 18px var(--gh-font); + color: var(--gh-fg); +} + +.gh-stat { + display: inline-flex; + align-items: center; + gap: 6px; + align-self: center; + margin-left: auto; + font: 600 12px / 20px var(--gh-font); +} +.gh-stat .add { + color: var(--gh-success); +} +.gh-stat .del { + color: var(--gh-danger); +} +.gh-squares { + display: inline-flex; + gap: 1px; +} +.gh-squares span { + width: 8px; + height: 8px; + border-radius: 1px; + background: var(--gh-square-add); +} +.gh-squares span.is-del { + background: var(--gh-square-del); +} + +.gh-toolbar { + display: flex; + align-items: center; + gap: 8px 10px; + min-height: 56px; + padding: 10px 16px; + border-bottom: 1px solid var(--gh-border); + background: var(--gh-bg); +} + +.gh-iconbtn { + display: grid; + place-items: center; + flex: none; + width: 32px; + height: 32px; + border: 1px solid var(--gh-control-border); + border-radius: 6px; + background: var(--gh-control-bg); + color: var(--gh-fg-muted); +} + +.gh-state { + display: inline-flex; + align-items: center; + gap: 4px; + flex: none; + height: 32px; + padding: 0 12px; + border-radius: 2em; + background: var(--gh-open-bg); + color: var(--gh-fg-on-emphasis); + font: 500 14px / 20px var(--gh-font); +} + +.gh-title { + flex: 1 1 auto; + min-width: 0; +} +.gh-title-main { + display: flex; + gap: 6px; + font-weight: 600; + white-space: nowrap; +} +.gh-title-main span:first-child { + overflow: hidden; + text-overflow: ellipsis; +} +.gh-title-main .num { + color: var(--gh-fg-muted); + font-weight: 400; +} +.gh-title-sub { + overflow: hidden; + color: var(--gh-fg-muted); + font-size: 12px; + line-height: 18px; + white-space: nowrap; + text-overflow: ellipsis; +} +.gh-title-sub b { + color: var(--gh-fg); + font-weight: 500; +} + +.gh-viewed { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; + color: var(--gh-fg-muted); + font-size: 12px; + white-space: nowrap; +} +.gh-viewed b { + color: var(--gh-fg); + font-weight: 600; +} +.gh-ring { + width: 14px; + height: 14px; + border: 2px solid var(--gh-border); + border-radius: 50%; +} + +.gh-vsep { + width: 1px; + height: 20px; + flex: none; + background: var(--gh-border); +} + +.gh-primary { + display: inline-flex; + align-items: center; + gap: 4px; + flex: none; + height: 28px; + padding: 0 10px; + border-radius: 6px; + background: var(--gh-primary-bg); + color: var(--gh-fg-on-emphasis); + font: 500 12px / 20px var(--gh-font); + white-space: nowrap; +} + +.gh-body { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + height: var(--gh-body-h); +} + +.gh-tree { + display: flex; + flex-direction: column; + min-width: 0; + border-right: 1px solid var(--gh-border); + overflow: auto; + overscroll-behavior: contain; +} + +.gh-tree-head { + position: sticky; + top: 0; + z-index: 1; + display: flex; + gap: 8px; + padding: 14px 12px 10px 16px; + background: var(--gh-bg); +} +.gh-filter { + position: relative; + flex: 1; + min-width: 0; +} +.gh-filter .i { + position: absolute; + left: 9px; + top: 8px; + color: var(--gh-fg-muted); + pointer-events: none; +} +.gh-filter input { + width: 100%; + height: 32px; + padding: 0 10px 0 32px; + border: 1px solid var(--gh-control-border); + border-radius: 6px; + background: var(--gh-input-bg); + color: var(--gh-fg); + font: 400 14px / 20px var(--gh-font); +} +.gh-filter input::placeholder { + color: var(--gh-fg-muted); +} +.gh .gh-filter input:focus-visible { + outline: 2px solid var(--gh-focus); + outline-offset: -1px; + border-color: var(--gh-focus); +} + +.tree-list { + margin: 0; + padding: 0 8px 16px; + list-style: none; +} + +.tr, +.df { + display: grid; + grid-template-rows: 1fr; + transition: + grid-template-rows 240ms var(--ease-out), + opacity 180ms var(--ease-out); +} +.tr > .row-in, +.df > .row-in { + min-height: 0; + overflow: hidden; + transition: transform 240ms var(--ease-out); +} +.tr.is-gone, +.df.is-gone { + grid-template-rows: 0fr; + opacity: 0; +} +.tr.is-gone > .row-in { + transform: translateX(-10px); +} +.df.is-gone > .row-in { + transform: translateY(-6px); +} +.gh.is-still .tr, +.gh.is-still .df, +.gh.is-still .row-in { + transition: none; +} + +.tr-btn { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 28px; + margin: 0; + padding: 0 8px 0 calc(6px + var(--d, 0) * 16px); + border: 0; + border-radius: 6px; + background: transparent; + color: var(--gh-fg); + font: 400 14px / 20px var(--gh-font); + text-align: left; + cursor: pointer; + transition: + background-color 120ms var(--ease-out), + color 160ms var(--ease-out); +} +.tr-btn:hover { + background: var(--gh-transparent-hover); +} +.tr-btn[aria-current="true"] { + background: var(--gh-transparent-selected); +} +.tr-chev { + color: var(--gh-fg-muted); + transition: transform 160ms var(--ease-out); +} +.tr-btn[aria-expanded="false"] .tr-chev { + transform: rotate(-90deg); +} +.tr-pad { + width: 16px; + flex: none; +} +.tr-folder { + color: var(--gh-folder); +} +.tr-file { + color: var(--gh-fg-muted); + transition: opacity 160ms var(--ease-out); +} +.tr-file.is-added { + color: var(--gh-success); +} +.tr-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tr-hint { + display: none; + place-items: center; + color: var(--gh-fg-muted); +} +.tr.is-muted .tr-btn { + color: var(--gh-fg-muted); +} +.tr.is-muted .tr-file { + opacity: 0.6; +} +.tr.is-muted .tr-hint, +.tr.is-revealed .tr-hint { + display: inline-grid; +} +.tr.is-revealed .tr-hint { + color: var(--gh-accent); +} + +.tree-empty { + display: flex; + gap: 8px; + margin: 4px 16px 16px; + padding: 12px; + border: 1px dashed var(--gh-border); + border-radius: 6px; + color: var(--gh-fg-muted); + font-size: 12px; + line-height: 18px; +} +.tree-empty strong { + display: block; + color: var(--gh-fg); + font-weight: 600; +} +.tree-empty .i { + margin-top: 1px; +} + +.gh-link { + padding: 0; + border: 0; + background: none; + color: var(--gh-accent); + font: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.gh-diff { + position: relative; + min-width: 0; + overflow: auto; + overscroll-behavior: contain; + padding: 16px 16px 24px; + background: var(--gh-bg); +} + +.df-file { + margin-bottom: 12px; + border: 1px solid var(--gh-border); + border-radius: 6px; + overflow: hidden; + background: var(--gh-bg); +} +.df.is-revealed .df-file { + border-color: var(--gh-accent-emphasis); + box-shadow: 0 0 0 3px var(--gh-accent-muted); +} +.df.is-flash .df-head { + animation: df-flash 900ms var(--ease-out); +} +@keyframes df-flash { + from { + background: var(--gh-flash); + } +} + +.df-head { + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; + padding: 6px 8px 6px 10px; + background: var(--gh-bg-muted); + font-size: 12px; +} +.df-file.has-body .df-head { + border-bottom: 1px solid var(--gh-border); +} +.df-head > .i { + color: var(--gh-fg-muted); +} +.df-path { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 600 12px / 20px var(--gh-mono); +} +.df-path .from { + color: var(--gh-fg-muted); + font-weight: 400; +} +.df-labels { + display: inline-flex; + gap: 6px; + flex: none; +} +.df-spacer { + flex: 1; +} +.df-stat { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; + font: 600 12px / 20px var(--gh-font); +} +.df-stat .add { + color: var(--gh-success); +} +.df-stat .del { + color: var(--gh-danger); +} +.df-viewed { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; + height: 28px; + padding: 0 8px; + border: 1px solid var(--gh-control-border); + border-radius: 6px; + background: var(--gh-control-bg); + color: var(--gh-fg); + font-weight: 500; +} +.df-box { + width: 13px; + height: 13px; + border: 1px solid var(--gh-check-border); + border-radius: 3px; + background: var(--gh-bg); +} + +.dl { + display: grid; + grid-template-columns: 44px 44px 18px minmax(0, 1fr); + font: 12px / 20px var(--gh-mono); +} +.dl > span { + padding: 0 8px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.dl .n { + color: var(--gh-fg-muted); + text-align: right; +} +.dl .s { + padding: 0; + text-align: center; +} +.dl .hunk { + grid-column: 1 / -1; + padding: 4px 10px; + background: var(--gh-hunk-bg); + color: var(--gh-fg-muted); +} +.dl .add { + background: var(--gh-add-bg); +} +.dl .n.add { + background: var(--gh-add-num); + color: var(--gh-fg); +} +.dl .del { + background: var(--gh-del-bg); +} +.dl .n.del { + background: var(--gh-del-num); + color: var(--gh-fg); +} + +.fh-label { + display: inline-flex; + align-items: center; + gap: 4px; + height: 20px; + padding: 0 7px; + border: 1px solid var(--gh-border); + border-radius: 2em; + color: var(--gh-fg-muted); + background: transparent; + font: 500 12px / 1 var(--gh-font); + white-space: nowrap; +} +.fh-label.is-accent { + color: var(--gh-accent); + border-color: var(--gh-accent-emphasis); +} + +.fh-flash { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + column-gap: 12px; + align-items: start; + margin-bottom: 8px; + padding: 12px 12px 12px 16px; + border: 1px solid var(--gh-accent-border); + border-radius: 6px; + background: var(--gh-accent-muted); + color: var(--gh-fg); + animation: fh-flash-in 240ms var(--ease-out); +} +.fh-flash[hidden] { + display: none; +} +.fh-flash > .i { + margin-top: 2px; + color: var(--gh-accent); +} +.fh-flash-detail { + margin-top: 2px; + font-size: 12px; + line-height: 18px; + color: var(--gh-fg-muted); +} +.fh-flash-detail code { + padding: 1px 5px; + border-radius: 6px; + background: var(--gh-neutral-muted); + font-size: 11px; +} +@keyframes fh-flash-in { + from { + opacity: 0; + transform: translateY(-6px); + } +} + +.fh-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border: 1px solid var(--gh-control-border); + border-radius: 6px; + background: var(--gh-control-bg); + color: var(--gh-fg); + font: 500 12px / 20px var(--gh-font); + white-space: nowrap; + cursor: pointer; + transition: background-color 120ms var(--ease-out); +} +.fh-btn:hover { + background: var(--gh-control-hover); +} +.fh-btn.is-primary { + height: 32px; + padding: 0 14px; + border-color: transparent; + background: var(--gh-primary-bg); + color: var(--gh-fg-on-emphasis); + font-size: 14px; +} +.fh-btn.is-primary:hover { + background: var(--gh-primary-bg); + filter: brightness(1.06); +} + +.fh-blankslate { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 16px; + padding: 40px 24px; + border: 1px solid var(--gh-border); + border-radius: 6px; + text-align: center; +} +.fh-blankslate[hidden] { + display: none; +} +.fh-blankslate-visual { + display: grid; + place-items: center; + width: 40px; + height: 40px; + margin-bottom: 12px; + border-radius: 10px; + background: var(--gh-accent-muted); + color: var(--gh-accent); +} +.fh-blankslate h3 { + margin: 0 0 4px; + font: 600 20px / 28px var(--gh-font); + color: var(--gh-fg); +} +.fh-blankslate p { + max-width: 420px; + margin: 0 0 16px; + color: var(--gh-fg-muted); +} + +.fh-ctl { + display: inline-flex; + align-items: center; + flex: none; +} + +.fh-ctl-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 8px; + margin: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--gh-fg-muted); + font: 500 12px / 20px var(--gh-font); + white-space: nowrap; + cursor: pointer; + transition: + background-color 120ms var(--ease-out), + color 120ms var(--ease-out); +} +.fh-ctl-btn[hidden] { + display: none; +} +.fh-ctl-btn:hover { + background: var(--gh-transparent-hover); + color: var(--gh-fg); +} +.fh-ctl-btn:active { + background: var(--gh-transparent-active); +} +.fh-ctl-btn[aria-expanded="true"] { + background: var(--gh-transparent-selected); + color: var(--gh-fg); +} +.fh-ctl-btn .i { + color: var(--gh-fg-muted); +} +.fh-ctl[data-split] .fh-ctl-main { + border-radius: 6px 0 0 6px; +} +.fh-ctl[data-split] .fh-ctl-menu { + border-radius: 0 6px 6px 0; + padding: 0 6px; +} +.fh-ctl-sep { + width: 1px; + height: 16px; + flex: none; + background: var(--gh-border); +} +.fh-ctl-sep[hidden] { + display: none; +} +.fh-ctl-status { + display: inline-flex; + align-items: center; + gap: 6px; +} +.fh-ctl-status[hidden] { + display: none; +} +.fh-ctl[data-view="filtering"] .fh-ctl-menu { + color: var(--gh-fg); +} +.fh-ctl[data-view="filtering"] .fh-ctl-icon { + color: var(--gh-accent); +} +.fh-ctl-status.is-pop { + animation: fh-pop 200ms var(--ease-out); +} +.fh-ctl-caret { + margin: 0 -2px; +} +@keyframes fh-pop { + from { + opacity: 0.4; + transform: scale(0.96); + } +} + +.fh-counter { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border: 1px solid transparent; + border-radius: 2em; + background: var(--gh-neutral-muted); + color: var(--gh-fg); + font: 500 12px / 18px var(--gh-font); +} +.fh-counter[hidden] { + display: none; +} +.fh-counter.is-accent { + background: var(--gh-accent-muted); + color: var(--gh-accent); +} +.fh-roll { + display: inline-grid; + overflow: hidden; + height: 18px; +} +.fh-roll > span { + grid-area: 1 / 1; + display: block; + text-align: center; +} +.fh-roll .roll-out { + animation: roll-out 200ms var(--ease-out) both; +} +.fh-roll .roll-in { + animation: roll-in 200ms var(--ease-out) both; +} +@keyframes roll-out { + to { + transform: translateY(calc(-100% * var(--dir, 1))); + opacity: 0; + } +} +@keyframes roll-in { + from { + transform: translateY(calc(100% * var(--dir, 1))); + opacity: 0; + } +} + +.fh-overlay { + width: 320px; + max-width: 100%; + padding-bottom: 8px; + border-radius: 12px; + background: var(--gh-overlay-bg); + color: var(--gh-fg); + box-shadow: var(--gh-shadow-floating); + font: 400 14px / 20px var(--gh-font); +} +.fh-overlay[hidden] { + display: none; +} + +#gh > .fh-overlay { + position: absolute; + z-index: 5; + top: var(--menu-top, 112px); + left: var(--menu-left, auto); + right: var(--menu-right, 16px); + max-height: calc(var(--gh-body-h) - 10px); + overflow: auto; + overscroll-behavior: contain; + animation: fh-overlay-in 200ms var(--ease-out); +} +@keyframes fh-overlay-in { + from { + opacity: 0; + transform: translateY(-4px); + } +} + +.fh-ov-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; +} +.fh-ov-header .logo { + color: var(--gh-fg); +} +.fh-ov-header .logo-band { + fill: var(--gh-accent); +} +.fh-ov-title { + flex: 1; + min-width: 0; + font-weight: 600; +} + +.fh-al-divider { + height: 1px; + margin: 8px 0; + border: 0; + background: var(--gh-border-muted); +} + +.fh-group { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.fh-ov-header + .fh-al-divider { + margin-top: 0; +} + +.fh-al-heading { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 6px 16px; + font-size: 12px; + line-height: 20px; + font-weight: 600; + color: var(--gh-fg-muted); +} +.fh-al-heading-note { + margin-left: auto; + font-weight: 400; +} + +.fh-al-item { + position: relative; + display: flex; + align-items: flex-start; + gap: 8px; + width: calc(100% - 16px); + min-height: 32px; + margin: 0 8px; + padding: 6px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--gh-fg); + font: inherit; + text-align: left; + cursor: pointer; + user-select: none; + transition: background-color 120ms var(--ease-out); +} +.fh-al-item[hidden] { + display: none; +} +.fh-al-item:hover { + background: var(--gh-transparent-hover); +} +.fh-al-item:active { + background: var(--gh-transparent-active); +} +.fh-al-item:has(> .fh-cb:focus-visible) { + outline: 2px solid var(--gh-focus); + outline-offset: -2px; + background: var(--gh-transparent-hover); +} +.fh-al-item.is-static { + cursor: default; +} +.fh-al-item.is-static:hover { + background: transparent; +} + +.fh-al-lead { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 16px; + height: 20px; + color: var(--gh-fg-muted); +} +.fh-al-content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} +.fh-al-desc { + overflow: hidden; + font-size: 12px; + line-height: 16px; + color: var(--gh-fg-muted); + text-overflow: ellipsis; + white-space: nowrap; +} +.fh-al-desc code { + font-size: 11px; + line-height: 16px; +} +.fh-al-trail { + display: flex; + align-items: center; + gap: 4px; + flex: none; + min-height: 20px; + color: var(--gh-fg-muted); + font-size: 12px; + line-height: 20px; +} +.fh-al-trail[hidden] { + display: none; +} +.fh-al-trail.is-switch { + align-self: center; +} +.fh-al-count { + min-width: 1ch; + text-align: right; + color: var(--gh-fg-muted); + font-size: 12px; + line-height: 20px; + font-variant-numeric: tabular-nums; +} +.fh-al-count.is-live { + color: var(--gh-fg); + font-weight: 500; +} + +.fh-ov-empty { + display: flex; + gap: 12px; + margin: 0 8px; + padding: 8px; +} +.fh-ov-empty[hidden] { + display: none; +} +.fh-ov-empty-visual { + display: grid; + place-items: center; + flex: none; + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--gh-accent-muted); + color: var(--gh-accent); +} +.fh-ov-empty strong { + display: block; + font-weight: 600; +} +.fh-ov-empty p { + margin: 2px 0 0; + font-size: 12px; + line-height: 18px; + color: var(--gh-fg-muted); +} + +.fh-cb { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; + pointer-events: none; +} + +.fh-check { + display: grid; + place-items: center; + flex: none; + width: 16px; + height: 16px; + border: 1px solid var(--gh-check-border); + border-radius: 3px; + background: var(--gh-bg); + transition: + background-color 80ms var(--ease-quad), + border-color 80ms var(--ease-quad); +} +.fh-check .i { + width: 12px; + height: 12px; + color: var(--gh-fg-on-emphasis); + stroke-width: 2; + opacity: 0; + transform: scale(0.6); + transition: + opacity 80ms var(--ease-quad), + transform 120ms var(--ease-out); +} +.fh-cb:checked + .fh-al-lead .fh-check, +.fh-cb:checked + .fh-check, +.fh-check.is-on { + background: var(--gh-checked); + border-color: var(--gh-checked); +} +.fh-cb:checked + .fh-al-lead .fh-check .i, +.fh-cb:checked + .fh-check .i, +.fh-check.is-on .i { + opacity: 1; + transform: none; +} + +.fh-switch-track { + position: relative; + display: block; + flex: none; + width: 48px; + height: 24px; + border: 1px solid var(--gh-track-border); + border-radius: 6px; + background: var(--gh-track-bg); + overflow: hidden; + transition: + background-color 80ms var(--ease-quad), + border-color 80ms var(--ease-quad); +} +.fh-switch-track::before { + content: ""; + position: absolute; + top: 50%; + left: 25%; + width: 2px; + height: 10px; + margin: -5px 0 0 -1px; + border-radius: 1px; + background: var(--gh-fg-on-emphasis); + opacity: 0; + transition: opacity 80ms var(--ease-quad); +} +.fh-switch-track::after { + content: ""; + position: absolute; + top: 50%; + left: 75%; + width: 10px; + height: 10px; + margin: -5px 0 0 -5px; + border: 1.5px solid var(--gh-fg-muted); + border-radius: 50%; + transition: opacity 80ms var(--ease-quad); +} +.fh-switch-knob { + position: absolute; + top: -1px; + bottom: -1px; + left: -1px; + z-index: 1; + width: calc(50% + 1px); + border: 1px solid var(--gh-knob-border); + border-radius: 6px; + background: var(--gh-knob-bg); + box-shadow: var(--gh-shadow-knob); + transition: + transform 80ms var(--ease-quad), + border-color 80ms var(--ease-quad); +} +[aria-checked="true"] .fh-switch-track, +.fh-switch-track.is-on { + background: var(--gh-checked); + border-color: var(--gh-checked); +} +[aria-checked="true"] .fh-switch-track::before, +.fh-switch-track.is-on::before { + opacity: 1; +} +[aria-checked="true"] .fh-switch-track::after, +.fh-switch-track.is-on::after { + opacity: 0; +} +[aria-checked="true"] .fh-switch-track .fh-switch-knob, +.fh-switch-track.is-on .fh-switch-knob { + transform: translateX(100%); + border-color: var(--gh-checked); +} + +.fh-rules { + margin: 0; + padding: 0; + list-style: none; +} +.fh-rule { + display: flex; + align-items: center; + gap: 4px; + margin: 0 8px; + padding: 0 0 0 8px; + border-radius: 6px; +} +.fh-rule:hover { + background: var(--gh-transparent-hover); +} +.fh-rule-main { + position: relative; + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + min-height: 30px; + cursor: pointer; +} +.fh-rule-main.is-static { + cursor: default; +} +.fh-rule-main code { + min-width: 0; + overflow-wrap: anywhere; + line-height: 18px; +} +.fh-rule-main .lead { + color: var(--gh-fg-muted); +} +.fh-rule:has(.fh-cb:focus-visible) { + outline: 2px solid var(--gh-focus); + outline-offset: -2px; +} +.fh-rule.is-off code { + color: var(--gh-fg-muted); + text-decoration: line-through; + text-decoration-color: var(--gh-border); +} +.fh-rule-x { + display: grid; + place-items: center; + flex: none; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--gh-fg-muted); + cursor: pointer; +} +.fh-rule-x:hover { + background: var(--gh-transparent-active); + color: var(--gh-danger); +} +.fh-rules-empty { + margin: 0 16px; + padding: 4px 0; + color: var(--gh-fg-muted); + font-size: 12px; +} + +.fh-add { + margin: 4px 16px 4px; +} +.fh-add[hidden] { + display: none; +} +.fh-add-row { + display: flex; + gap: 6px; +} +.fh-input { + flex: 1; + min-width: 0; + height: 28px; + padding: 0 8px; + border: 1px solid var(--gh-control-border); + border-radius: 6px; + background: var(--gh-input-bg); + color: var(--gh-fg); + font: 12px / 20px var(--gh-mono); +} +.fh-input::placeholder { + color: var(--gh-fg-muted); + opacity: 1; +} +.gh .fh-input:focus-visible { + outline: 2px solid var(--gh-focus); + outline-offset: -1px; + border-color: var(--gh-focus); +} +.fh-input[aria-invalid="true"] { + border-color: var(--gh-danger); +} +.fh-add-error { + display: flex; + gap: 6px; + margin-top: 4px; + color: var(--gh-danger); + font-size: 12px; + line-height: 16px; +} +.fh-add-error[hidden] { + display: none; +} +.fh-add-error .i { + width: 12px; + height: 12px; + margin-top: 2px; +} +.fh-heading-act { + margin-left: auto; + padding: 0 4px; + border: 0; + border-radius: 4px; + background: none; + color: var(--gh-accent); + font: 500 12px / 20px var(--gh-font); + cursor: pointer; +} +.fh-heading-act:hover { + text-decoration: underline; +} + +.section { + padding-block: clamp(64px, 9vw, 120px) 0; +} + +.section-head { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); + gap: 14px clamp(32px, 5vw, 72px); + align-items: end; + margin-bottom: clamp(32px, 4vw, 52px); + padding-top: 20px; + border-top: 1px solid var(--pg-line-strong); +} +.kicker { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; + font: 400 0.8125rem / 1.4 var(--font-mono); + color: var(--pg-muted); +} +.kicker::before { + content: ""; + width: 16px; + height: 4px; + border-radius: 2px; + background: var(--pg-accent); +} +.section h2 { + font-size: clamp(2rem, 3.6vw, 3rem); + line-height: 1.02; + letter-spacing: -0.03em; + text-wrap: balance; +} +.section-head > p { + max-width: 38em; + color: var(--pg-ink-soft); + font-size: 1.0625rem; +} + +.steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 40px clamp(24px, 3vw, 40px); + margin: 0; + padding: 0; + list-style: none; +} +.step { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} +.step-num { + font: 500 clamp(3rem, 5vw, 4.25rem) / 0.9 var(--font-display); + letter-spacing: -0.04em; + color: var(--pg-numeral); + font-variant-numeric: tabular-nums; +} +.step h3 { + font-size: 1.375rem; + letter-spacing: -0.015em; + line-height: 1.2; +} +.step p { + color: var(--pg-ink-soft); +} +.step p code, +.feat p code, +.facts code, +.support code { + padding: 1px 5px; + border-radius: 5px; + background: var(--pg-chip-bg); + color: var(--pg-ink); +} + +.frag { + margin-top: auto; + padding: 16px; + border: 1px solid var(--pg-line); + border-radius: 12px; + background: var(--pg-surface); +} +.frag-in { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; +} +.frag .gh { + border-radius: 8px; + background: transparent; +} +.frag-strip { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--gh-border); + border-radius: 8px; + background: var(--gh-bg); + max-width: 100%; +} +.frag .fh-overlay { + width: 100%; + max-width: 300px; + padding-top: 4px; +} +.frag-arrow { + display: flex; + align-items: center; + gap: 8px; + color: var(--pg-muted); + font: 400 0.8125rem / 1.4 var(--font-mono); +} +.frag-arrow .i { + transform: rotate(90deg); +} +.frag .fh-ctl-btn { + cursor: default; +} +.frag .fh-ctl-btn:hover { + background: transparent; + color: var(--gh-fg-muted); +} +.frag [data-view="filtering"] .fh-ctl-btn:hover { + color: var(--gh-fg); +} + +.real { + display: grid; + grid-template-columns: minmax(0, 960px) minmax(0, 1fr); + gap: 20px clamp(28px, 4vw, 48px); + align-items: end; + margin: 0; +} +.real-copy { + display: grid; + gap: 12px; +} +.real-copy p { + color: var(--pg-ink-soft); +} +.real picture { + display: block; + min-width: 0; +} +.real img { + width: 100%; + border-radius: 12px; +} +.tag { + display: inline-flex; + align-items: center; + gap: 8px; + justify-self: start; + padding: 5px 10px; + border: 1px solid var(--pg-line-strong); + border-radius: 8px; + font: 400 0.8125rem / 1.4 var(--font-mono); + color: var(--pg-ink); +} +.tag::before { + content: ""; + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--pg-accent); +} +.real figcaption { + display: grid; + gap: 12px; + min-width: 0; + padding-bottom: 8px; + font-size: 0.9375rem; + color: var(--pg-muted); +} + +.feats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: clamp(32px, 5vw, 72px); + margin: 0; + padding: 0; + list-style: none; +} +.feat { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + align-content: start; + gap: 4px 14px; + padding: 22px 0 26px; + border-top: 1px solid var(--pg-line); + min-width: 0; +} +.feat .i { + width: 20px; + height: 20px; + margin-top: 4px; + color: var(--pg-ink); +} +.feat h3 { + font-size: 1.1875rem; + line-height: 1.3; + letter-spacing: -0.01em; +} +.feat p { + grid-column: 2; + color: var(--pg-ink-soft); +} + +.privacy { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); + gap: 32px clamp(32px, 5vw, 72px); + padding: clamp(24px, 4vw, 48px); + border: 1.5px dashed var(--pg-line-strong); + border-radius: 18px; + background: var(--pg-surface); +} +.privacy h2 { + font-size: clamp(2rem, 3.6vw, 3rem); + line-height: 1.02; + letter-spacing: -0.03em; +} +.privacy-lead { + display: grid; + gap: 18px; + align-content: start; +} +.privacy-lead > p { + color: var(--pg-ink-soft); +} +.facts { + display: grid; + gap: 0; + margin: 0; +} +.facts > div { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + gap: 2px 12px; + padding: 16px 0; + border-bottom: 1px solid var(--pg-line); +} +.facts > div:first-child { + padding-top: 0; +} +.facts > div:last-child { + border-bottom: 0; + padding-bottom: 0; +} +.facts dt { + grid-column: 2; + font-weight: 700; + color: var(--pg-ink); +} +.facts dt .i { + position: absolute; +} +.facts .fi { + grid-row: 1 / span 2; + width: 18px; + height: 18px; + margin-top: 4px; + color: var(--pg-ink); +} +.facts dd { + grid-column: 2; + margin: 0; + color: var(--pg-ink-soft); +} + +.same { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + margin-top: 4px; + padding: 14px; + border: 1px solid var(--pg-line); + border-radius: 12px; + background: var(--pg-raise); +} +.same .gh { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + background: transparent; +} +.same .gh-tab { + height: 36px; + margin: 0; + border-bottom: 1px solid var(--gh-border); + border-radius: 6px; + background: var(--gh-bg); +} +.same .frag-strip { + padding: 4px 6px; +} +.same p { + font: 400 0.8125rem / 1.4 var(--font-mono); + color: var(--pg-muted); +} + +.support { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 28px clamp(24px, 4vw, 56px); +} +.support h3 { + font: 400 0.8125rem / 1.4 var(--font-mono); + color: var(--pg-muted); + margin-bottom: 10px; +} +.support ul { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 8px; +} +.support li { + display: flex; + gap: 10px; + align-items: baseline; +} +.support li::before { + content: ""; + flex: none; + width: 8px; + height: 8px; + border-radius: 2px; + background: var(--pg-ink); + transform: translateY(-1px); +} +.support .no li::before { + background: transparent; + border: 1.5px dashed var(--pg-line-strong); +} +.support .no li { + color: var(--pg-ink-soft); +} +.support-note { + color: var(--pg-ink-soft); +} + +.foot { + margin-top: clamp(72px, 10vw, 140px); + padding-block: 40px 48px; + border-top: 1px solid var(--pg-line-strong); +} +.foot-top { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: 32px clamp(32px, 5vw, 72px); + align-items: start; +} +.foot-claim { + display: grid; + gap: 22px; + justify-items: start; +} +.foot-claim p { + font: 600 clamp(1.75rem, 3vw, 2.5rem) / 1.05 var(--font-display); + letter-spacing: -0.03em; + color: var(--pg-ink); + text-wrap: balance; +} +.foot-links { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 24px; + margin: 0; + padding: 0; + list-style: none; +} +.foot-links a { + color: var(--pg-ink); +} +.foot-bottom { + display: flex; + flex-wrap: wrap; + gap: 8px 28px; + margin-top: 40px; + padding-top: 18px; + border-top: 1px solid var(--pg-line); + font: 400 0.8125rem / 1.5 var(--font-mono); + color: var(--pg-muted); +} +.foot-bottom p:first-child { + color: var(--pg-ink); +} + +@media (max-width: 1099.98px) { + .gh-primary, + .gh-vsep { + display: none; + } + .gh-body { + grid-template-columns: 260px minmax(0, 1fr); + } + .real { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 959.98px) { + .hero-head, + .section-head, + .privacy, + .foot-top { + grid-template-columns: minmax(0, 1fr); + } + .hero-head { + align-items: start; + } + .steps { + grid-template-columns: minmax(0, 1fr); + max-width: 560px; + } + .support { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + .support-note { + grid-column: 1 / -1; + } + .top-nav { + display: none; + } + .top .btn-small { + margin-left: auto; + } +} + +@media (max-width: 819.98px) { + .feats { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 719.98px) { + .gh-tab:not(.is-current) { + display: none; + } + .gh-tabs { + padding-inline: 12px; + } + .gh-toolbar { + flex-wrap: wrap; + padding: 10px 12px; + } + .gh-toolbar .gh-iconbtn { + display: none; + } + .gh-title { + order: -1; + flex: 1 0 100%; + } + .gh-state { + height: 28px; + padding: 0 10px; + font-size: 12px; + } + .gh-viewed { + margin-left: auto; + } + .gh-body { + display: block; + height: auto; + } + .gh-tree { + border-right: 0; + overflow: visible; + } + .gh-tree-head { + position: static; + padding-inline: 12px; + } + .gh-diff { + display: none; + } + #gh > .fh-overlay { + position: static; + width: auto; + max-height: none; + margin: 8px; + overflow: visible; + box-shadow: 0 0 0 1px var(--gh-border); + } + .sandbox-actions { + margin-left: 0; + } + .try { + flex-basis: 100%; + } + .try .narrow-only { + display: inline-flex; + } + .support { + grid-template-columns: minmax(0, 1fr); + } + .foot-links { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 479.98px) { + .ctas .btn { + flex: 1 1 100%; + } +} + +@media (max-width: 373.98px) { + .top .btn-small .long { + display: none; + } + .frag { + padding: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} +.sprite { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + +.frag .fh-overlay.is-tight { + padding: 4px 0; +} + +.not-found { + display: grid; + justify-items: start; + gap: 22px; + padding-block: clamp(48px, 10vw, 120px); +} +.not-found h1 { + font-size: clamp(2.5rem, 5.6vw, 4.75rem); + line-height: 0.98; + letter-spacing: -0.035em; +} diff --git a/site/vite.config.ts b/site/vite.config.ts new file mode 100644 index 0000000..209edf7 --- /dev/null +++ b/site/vite.config.ts @@ -0,0 +1,47 @@ +import { resolve } from "node:path"; +import { defineConfig, type Plugin } from "vite"; + +const root = resolve(import.meta.dirname); + +const CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "img-src 'self'", + "style-src 'self'", + "script-src 'self'", + "font-src 'self'", + "connect-src 'self'", + "base-uri 'self'", + "form-action 'none'", + "object-src 'none'", +].join("; "); + +const contentSecurityPolicy = (): Plugin => ({ + name: "content-security-policy", + apply: "build", + transformIndexHtml: () => [ + { + tag: "meta", + attrs: { + "http-equiv": "Content-Security-Policy", + content: CONTENT_SECURITY_POLICY, + }, + injectTo: "head-prepend", + }, + ], +}); + +export default defineConfig({ + root, + plugins: [contentSecurityPolicy()], + build: { + outDir: resolve(root, "dist"), + emptyOutDir: true, + assetsInlineLimit: 0, + rollupOptions: { + input: { + main: resolve(root, "index.html"), + notFound: resolve(root, "404.html"), + }, + }, + }, +}); diff --git a/tests/site/site.spec.ts b/tests/site/site.spec.ts new file mode 100644 index 0000000..047b044 --- /dev/null +++ b/tests/site/site.spec.ts @@ -0,0 +1,159 @@ +import { expect, type Page, test } from "@playwright/test"; +import { type PreviewServer, preview } from "vite"; +import { copy } from "../../src/ui/copy"; + +const SITE = "https://github-file-hider.ribeiro.engineer/"; + +let server: PreviewServer; +let origin: string; + +test.beforeAll(async () => { + server = await preview({ + root: "site", + logLevel: "silent", + preview: { host: "127.0.0.1", port: 0, strictPort: false }, + }); + origin = (server.resolvedUrls?.local[0] ?? "").replace(/\/$/, ""); +}); + +test.afterAll(async () => { + await server.close(); +}); + +async function openHome(page: Page) { + const problems: string[] = []; + const foreign: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") problems.push(message.text()); + }); + page.on("pageerror", (error) => problems.push(error.message)); + page.on("request", (request) => { + if (!request.url().startsWith(origin)) foreign.push(request.url()); + }); + await page.addInitScript(() => { + document.addEventListener("securitypolicyviolation", (event) => { + console.error(`CSP ${event.violatedDirective} ${event.blockedURI}`); + }); + }); + await page.goto(`${origin}/`); + await page.evaluate(() => document.fonts.ready); + return { problems, foreign }; +} + +const statusCount = (page: Page) => page.locator("#fh-status-count"); + +test("loads with no errors, no policy violations, and no third-party requests", async ({ + page, +}) => { + const { problems, foreign } = await openHome(page); + await page.waitForLoadState("networkidle"); + expect(problems).toEqual([]); + expect(foreign).toEqual([]); + await expect( + page.locator('meta[http-equiv="Content-Security-Policy"]'), + ).toHaveAttribute("content", /script-src 'self'/); +}); + +test("starts the sandbox with the demo rules hiding 11 of 25 files", async ({ + page, +}) => { + await openHome(page); + await expect(page.locator("#fh-noun")).toHaveText(copy.filesHidden(11)); + await expect(statusCount(page)).toHaveText("11"); + await expect(page.locator("#ro-shown")).toHaveText("14"); + await expect(page.locator("#fh-menu")).toBeVisible(); +}); + +test("updates the hidden count as the visitor changes rules", async ({ + page, +}) => { + await openHome(page); + await page.locator("label:has(#fh-p-tests)").click(); + await expect(statusCount(page)).toHaveText("3"); + await page.locator("#fh-custom-input").fill("docs/**"); + await page.locator("#fh-custom-input").press("Enter"); + await expect(statusCount(page)).toHaveText("5"); + await page.locator("#fh-custom-input").fill("/docs"); + await page.locator("#fh-custom-input").press("Enter"); + await expect(page.locator("#fh-custom-error")).toHaveText( + copy.validation.leadingSlash, + ); + await page.locator("#reset").click(); + await expect(statusCount(page)).toHaveText("11"); +}); + +test("shows every file and offers to hide them again", async ({ page }) => { + await openHome(page); + await page.locator("#fh-show").click(); + await expect(page.locator("#fh-main-label")).toHaveText(copy.hideAgain); + await expect(page.locator("#ro-shown")).toHaveText("25"); +}); + +test("reveals a hidden file from the tree when the tree keeps every file", async ({ + page, +}) => { + await openHome(page); + await page.locator("#fh-tree").click(); + await page.locator('[data-file="pnpm-lock.yaml"]').click(); + const section = page.locator('section.df[data-path="pnpm-lock.yaml"]'); + await expect(section.locator(".fh-label")).toHaveText(copy.labelRevealed); + await expect(section.locator(".fh-flash")).toContainText(copy.notice); +}); + +test("fits a phone screen with the menu closed", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openHome(page); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + await expect(page.locator("#fh-menu")).toBeHidden(); + await expect(statusCount(page)).toHaveText("11"); +}); + +test("describes the page for search engines and link previews", async ({ + page, + request, +}) => { + await openHome(page); + await expect(page).toHaveTitle(/GitHub File Hider/); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + SITE, + ); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + /Chrome extension/, + ); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + `${SITE}og.png`, + ); + const structured = JSON.parse( + (await page.locator('script[type="application/ld+json"]').textContent()) ?? + "{}", + ); + expect(structured["@type"]).toBe("SoftwareApplication"); + for (const [path, type] of [ + ["/og.png", "image/png"], + ["/apple-touch-icon.png", "image/png"], + ["/favicon.svg", "image/svg+xml"], + ] as const) { + const response = await request.get(`${origin}${path}`); + expect(response.ok(), path).toBe(true); + expect(response.headers()["content-type"]).toContain(type); + } + expect(await (await request.get(`${origin}/robots.txt`)).text()).toContain( + `Sitemap: ${SITE}sitemap.xml`, + ); + expect(await (await request.get(`${origin}/sitemap.xml`)).text()).toContain( + `${SITE}`, + ); +}); + +test("has a not found page that links home", async ({ page }) => { + await page.goto(`${origin}/404.html`); + await expect(page.locator("h1")).toContainText("hidden"); + await expect(page.locator('a[href="/"]').first()).toBeVisible(); +}); diff --git a/tests/store/og.html b/tests/store/og.html new file mode 100644 index 0000000..a236b98 --- /dev/null +++ b/tests/store/og.html @@ -0,0 +1,114 @@ + + + + +GitHub File Hider link preview + + + +
    + + GitHub File Hider +
    +
    +

    Review the code that matters first.

    +

    Hide tests, lockfiles, and generated code in GitHub pull requests.

    +
    +
    Free Chrome extension · Open source
    +
    +
    11files hidden
    +
      +
      + + + diff --git a/tests/store/store.spec.ts b/tests/store/store.spec.ts index d0b11b8..475513e 100644 --- a/tests/store/store.spec.ts +++ b/tests/store/store.spec.ts @@ -28,6 +28,11 @@ type Screenshot = { }; const DIRECTORY = "store"; +const LOGO_SIZE = 72; +const LOGO_COLORS = { + light: { ink: "#1f2328", band: "#0969da" }, + dark: { ink: "#f0f6fc", band: "#4493f8" }, +}; const VIEWPORT = { width: 1280, height: 800 }; const PROMO_TILE = { width: 440, height: 280 }; const CHANGES = FIXTURE_URLS["pr1-changes"]; @@ -121,16 +126,57 @@ for (const screenshot of SCREENSHOTS) } }); -test("captures the small promo tile for the store", async () => { +async function captureComposition( + source: string, + viewport: { width: number; height: number }, + path: string, +) { const browser = await chromium.launch({ channel: "chromium", args: RENDERING_ARGS, }); try { - const page = await browser.newPage({ viewport: PROMO_TILE }); - await page.goto(`file://${resolve("tests/store/promo.html")}`); + const page = await browser.newPage({ viewport }); + await page.goto(`file://${resolve(source)}`); await page.evaluate(() => document.fonts.ready); - await page.screenshot({ path: `${DIRECTORY}/promo-small.png` }); + await page.screenshot({ path }); + } finally { + await browser.close(); + } +} + +test("captures the small promo tile for the store", () => + captureComposition( + "tests/store/promo.html", + PROMO_TILE, + `${DIRECTORY}/promo-small.png`, + )); + +const SITE_PUBLIC = "site/public"; +const LINK_PREVIEW = { width: 1200, height: 630 }; +const TOUCH_ICON = 180; + +test("captures the link preview image for the landing page", () => + captureComposition( + "tests/store/og.html", + LINK_PREVIEW, + `${SITE_PUBLIC}/og.png`, + )); + +test("captures the touch icon for the landing page", async () => { + const browser = await chromium.launch({ + channel: "chromium", + args: RENDERING_ARGS, + }); + try { + const page = await browser.newPage({ + viewport: { width: TOUCH_ICON, height: TOUCH_ICON }, + }); + const { ink, band } = LOGO_COLORS.light; + await page.setContent( + `${logo(120)}`, + ); + await page.screenshot({ path: `${SITE_PUBLIC}/apple-touch-icon.png` }); } finally { await browser.close(); } @@ -177,12 +223,6 @@ for (const theme of ["light", "dark"] as const) } }); -const LOGO_SIZE = 72; -const LOGO_COLORS = { - light: { ink: "#1f2328", band: "#0969da" }, - dark: { ink: "#f0f6fc", band: "#4493f8" }, -}; - test("captures the logo for the README in both themes", async () => { const browser = await chromium.launch({ channel: "chromium", diff --git a/tests/unit/site-model.test.ts b/tests/unit/site-model.test.ts new file mode 100644 index 0000000..488b660 --- /dev/null +++ b/tests/unit/site-model.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + alwaysKey, + customKey, + derive, + initialState, + presetKey, + type SandboxState, +} from "../../site/src/model"; + +const hiddenPaths = (state: SandboxState) => + derive(state) + .files.filter((file) => file.state === "hidden") + .map((file) => file.path); + +describe("landing page sandbox model", () => { + it("starts with the demo rules hiding 11 of 25 files", () => { + const model = derive(initialState(true)); + expect(model.view).toBe("filtering"); + expect(model.files).toHaveLength(25); + expect(model.hidden).toBe(11); + expect(model.counts.get(presetKey("tests"))).toBe(8); + expect(model.counts.get(presetKey("lockfiles"))).toBe(2); + expect(model.counts.get(customKey("custom-generated"))).toBe(1); + expect(model.counts.get(alwaysKey("always-checkout"))).toBe(1); + expect(model.byPath.get("src/payments/checkout.spec.ts")?.state).toBe( + "kept", + ); + }); + + it("shows the test files again when the Tests preset is off", () => { + const state = initialState(true); + state.presets.tests = false; + expect(hiddenPaths(state)).toEqual([ + "examples/node/package-lock.json", + "pnpm-lock.yaml", + "src/generated/client.ts", + ]); + expect(derive(state).counts.get(presetKey("tests"))).toBe(8); + }); + + it("hides a folder through a custom rule with a trailing slash", () => { + const state = initialState(true); + state.custom.push({ id: "docs", pattern: "docs/", enabled: true }); + expect(hiddenPaths(state)).toContain("docs/release notes.md"); + expect(derive(state).hidden).toBe(13); + }); + + it("keeps every file visible while Show all files is on", () => { + const state = initialState(true); + state.showingAll = true; + const model = derive(state); + expect(model.view).toBe("showing"); + expect(model.hidden).toBe(0); + expect(model.match).toBe(11); + }); + + it("reveals a hidden file that the visitor opens from the tree", () => { + const state = initialState(true); + state.revealed.add("pnpm-lock.yaml"); + const model = derive(state); + expect(model.byPath.get("pnpm-lock.yaml")?.state).toBe("revealed"); + expect(model.hidden).toBe(10); + }); + + it("reports the empty view when no rule is on", () => { + const state = initialState(true); + state.presets = { tests: false, lockfiles: false }; + state.custom = []; + expect(derive(state).view).toBe("empty"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 34f30fd..9dd804e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", - "types": ["chrome", "node"], + "types": ["chrome", "node", "vite/client"], "strict": true, "noUncheckedIndexedAccess": true, "noEmit": true, @@ -13,5 +13,5 @@ "allowImportingTsExtensions": true, "skipLibCheck": true }, - "include": ["src", "tests", "*.config.ts"] + "include": ["src", "site", "tests", "*.config.ts"] } diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..5682432 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "github-file-hider-site", + "compatibility_date": "2026-10-01", + "assets": { + "directory": "./site/dist", + "not_found_handling": "404-page", + "html_handling": "auto-trailing-slash" + }, + "routes": [ + { "pattern": "github-file-hider.ribeiro.engineer", "custom_domain": true } + ], + "workers_dev": false, + "preview_urls": false +}