diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a9d3db..cf4dec8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,20 +35,20 @@ jobs: run: npm run lint - name: Type check server - run: cd packages/server && npx tsc --noEmit + run: cd services/server && npx tsc --noEmit - name: Type check extension - run: cd packages/extension && npx tsc --noEmit + run: cd apps/extension && npx tsc --noEmit - name: Build run: npm run build - - name: Test server + - name: Test run: npm run test - name: Package extension if: github.event_name != 'pull_request' - run: cd packages/extension/dist && zip -r ../../../meterx-extension.zip . + run: cd apps/extension/dist && zip -r ../../../meterx-extension.zip . - name: Upload extension if: github.event_name != 'pull_request' diff --git a/.gitignore b/.gitignore index befd6e3..bca5cc8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,10 @@ Thumbs.db # graphify knowledge graph (local) graphify-out/ + +# taste-skill working files (local) +.taste-skill/ +packages/site/dist/ +release/ +.dev/ +.wrangler/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/MeterX.iml b/.idea/MeterX.iml deleted file mode 100644 index 24643cc..0000000 --- a/.idea/MeterX.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index aa092cb..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.junie/guidelines.md b/.junie/guidelines.md deleted file mode 100644 index ee6a4ca..0000000 --- a/.junie/guidelines.md +++ /dev/null @@ -1,166 +0,0 @@ -# MeterX Development Guidelines - -This document provides essential information for developers working on the MeterX project. It includes build/configuration instructions, testing information, and additional development details. - -## Build and Configuration Instructions - -### Server Setup - -1. **Prerequisites**: - - Node.js (LTS version recommended) - - npm or yarn - -2. **Installation**: - ```powershell - # Navigate to the server directory - cd server - - # Install dependencies - npm install - ``` - -3. **Configuration**: - - The server uses port 3000 by default - - You can modify the port by changing the `PORT` environment variable or updating the `PORT` constant in `server.js` - - Test files are stored in the `test-files` directory - - The server automatically generates test files of different sizes (1MB, 10MB, 25MB) if they don't exist - -4. **Running the Server**: - ```powershell - # Start the server - npm start - - # Or directly with Node.js - node server.js - ``` - -### Extension Setup - -1. **Prerequisites**: - - Chrome, Edge, or Firefox browser - -2. **Loading the Extension**: - - Chrome/Edge: - ``` - 1. Open chrome://extensions or edge://extensions - 2. Enable "Developer mode" - 3. Click "Load unpacked" - 4. Select the "extension" directory - ``` - - Firefox: - ``` - 1. Open about:debugging#/runtime/this-firefox - 2. Click "Load Temporary Add-on" - 3. Select the manifest.json file in the "extension" directory - ``` - -3. **Configuration**: - - The extension is configured to use the live server at `https://meterx-speedtest-server.onrender.com` - - To use a local server, modify `TEST_SERVER_BASE_URL` in `extension/background.js`: - ```javascript - // Change this: - const TEST_SERVER_BASE_URL = 'https://meterx-speedtest-server.onrender.com'; - // To this: - const TEST_SERVER_BASE_URL = 'http://localhost:3000'; - ``` - -## Testing Information - -### Server Testing - -1. **Running Tests**: - ```powershell - # Navigate to the server directory - cd server - - # Start the server in one terminal - npm start - - # Run tests in another terminal - npm test - ``` - -2. **Test Coverage**: - - The test script (`test.js`) verifies the following endpoints: - - `/ping`: Checks server responsiveness - - `/test-file/1MB.bin`: Verifies download functionality - - `/upload`: Confirms upload endpoint works - -3. **Adding New Tests**: - - Add new test cases to the `runTests()` function in `test.js` - - Follow the existing pattern: - ```javascript - // Test example: New endpoint - console.log('\nTest 4: Testing new endpoint...'); - const response = await makeRequest(`${SERVER_URL}/new-endpoint`); - assert.strictEqual(response.statusCode, 200, 'New endpoint should return 200 status code'); - console.log('✅ New endpoint test passed!'); - ``` - -4. **Testing Different File Sizes**: - - The server supports multiple file sizes (1MB, 10MB, 25MB) - - To test different file sizes, modify the URL in the test: - ```javascript - // Change this: - const downloadResponse = await makeRequest(`${SERVER_URL}/test-file/1MB.bin`); - // To this: - const downloadResponse = await makeRequest(`${SERVER_URL}/test-file/10MB.bin`); - ``` - -### Extension Testing - -The extension doesn't have automated tests. Manual testing can be performed as follows: - -1. Load the extension in your browser -2. Click the extension icon to open the popup -3. Click "Start Test" to run the speed test -4. Verify the results display correctly - -## Code Style and Development Guidelines - -1. **JavaScript Style**: - - The project uses ESLint for code quality - - ECMAScript 2021 features are supported - - Run linting with: - ```powershell - # From the project root - npm run lint:fix - ``` - -2. **Project Structure**: - - `server/`: Contains the Node.js/Express server - - `server.js`: Main server implementation - - `test.js`: Server tests - - `test-files/`: Binary files for download tests - - `uploads/`: Directory for upload test files - - `extension/`: Contains the browser extension - - `background.js`: Background script for the extension - - `popup.html`, `popup.css`, `popup.js`: Extension UI - - `manifest.json`: Extension configuration - - `.junie/`: Development guidelines and documentation - -3. **Debugging Tips**: - - Server logs are output to the console - - For extension debugging: - - Chrome/Edge: Right-click the extension icon → "Inspect popup" - - Firefox: Navigate to about:debugging → "Inspect" on the extension - -4. **Performance Considerations**: - - The download test uses a 1MB file by default - - For more accurate results with faster connections, consider using larger test files (10MB or 25MB) - - The server has rate limiting to prevent abuse - -## Deployment - -1. **Server Deployment**: - - The server can be deployed to any Node.js hosting service - - Current production server: `https://meterx-speedtest-server.onrender.com` - - Required environment variables: None (but `PORT` can be set if needed) - -2. **Extension Publishing**: - - Chrome Web Store: - 1. Create a ZIP file of the `extension` directory - 2. Upload to the Chrome Developer Dashboard - - Firefox Add-ons: - 1. Create a ZIP file of the `extension` directory - 2. Upload to the Firefox Add-on Developer Hub \ No newline at end of file diff --git a/AUTHORS b/AUTHORS index 079bedc..d7e1cc4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,4 +1,4 @@ -# MeterX — Authors & Contributors +# MeterX authors and contributors # # This file lists everyone who has contributed to MeterX. # If you submit a PR, add your name here. @@ -7,4 +7,4 @@ Ajith Kumar K (https://github.com/ajithakdev) # Contributors -# Your name here — PRs welcome! +# Your name here? PRs welcome! diff --git a/CHANGELOG.md b/CHANGELOG.md index 969cb74..173c62b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,52 @@ All notable changes to MeterX are documented here. The format follows [Keep a Ch ## [Unreleased] +## [2027.0.0] - 2026-10-27 + +### Added +- The PDF report has a faint MeterX watermark, and every page shows a Report ID: a fingerprint of the results, so an edited copy no longer matches a fresh export of the same tests +- Running a test from the side panel shows it live: the summary card switches to the current download, upload, ping and elapsed time, the heading names the phase, and a progress line runs under the header +- New logo: a dial with download and upload arrows crossing into an X, on the toolbar icon, in the app, the PDF and the site. Store images, social cards and icons are generated by `brand/build.mjs` +- Product site in `apps/site` for meterx.ajithakdev.com: home page with an in-page quick test, about, privacy policy (#92), share viewer (#74) and a `/get` link that opens the right store +- The worker answers the site's origin so its quick test can run +- `npm run package` builds one upload for Chrome, Edge and Opera, one for Firefox, and a source archive +- The site is now several pages (Home, How it works, Features, Questions, About) and runs the extension's own popup, side panel and settings with sample data; the home page plays a test as you scroll +- Get MeterX page: every store on the MeterX dial with the needle pointing at the visitor's browser, live and upcoming stores (Chrome Web Store with Brave, Vivaldi and Arc, Firefox for Android, Safari) with their logos. `/get/?go` still jumps straight to the right store +- Questions show the open answer in a panel beside the list on wide screens; How it works compares ping under load on a grade C line and a grade A line +- Contribute page with live repo stats, issues for newcomers and contributors from GitHub, plus a four step setup +- `npm run dev:site` serves the site on port 8788 and `npm run dev:ext` builds the extension and runs the worker on port 8787, for local testing +- Site SEO: every page has Open Graph URL, site name and a large link card, the home page names the site in structured data, and Get MeterX is in the sitemap and open to search engines + ### Changed +- Extension source split into `background/`, `lib/` and `pages/`; API constants, version and share links now live in `@meterx/shared` +- New look for the popup, side panel and settings page: one design system (`styles/app.css`), Geist font, light and dark themes +- Self-host server streams test files from memory instead of writing them to disk, and drains uploads without buffering them +- Extension build clears `dist/` first, so deleted files no longer ship in the zip +- PDF report redesigned to match the app: light page, vector text you can select and search, download trend chart, plan comparison and a results table that continues across pages. It is also much smaller, and html2canvas is gone +- CSV and JSON exports redesigned: column names carry units, local time sits next to UTC, numbers are rounded, each row has its quality, and the JSON has a versioned `format` and a summary. Files are now named `meterx-results-` +- The side panel fits the window: the summary, chart and plan stay in view and only the results list scrolls, with its column names pinned. "Show all" is gone because every result is in the list +- One version for everything, read from the root `package.json` and stamped into the manifest at build time (#89) +- Export file names use the browser's own date; every time shown in the extension follows the browser's locale and time zone +- Extension name is now "MeterX by ajithakdev", and empty values show a plain hyphen +- Repository renamed to `ajithakdev/meterx` and split into `apps/`, `services/` and `packages/`; the PDF, CSV and JSON builders became `@meterx/reports` + +### Fixed +- Site: demo frames never scroll at any browser zoom, the side panel demo scales to fit the window height, there is no blank band after the pinned hero, and phones get an un-pinned hero, a menu button and full-height panel demos +- Empty figures show a short muted bar with the unit hidden, instead of a stray hyphen next to "Mbps" +- The side panel no longer scrolls when an ISP plan is set: plan rows show the percentage on one line, the status moved into the Results header, and the chart shrinks on shorter panels +- Rows no longer show above the pinned column names while the results list scrolls +- Down, Up and Ping columns in the side panel results have even spacing +- Same-day charts label the axis with times instead of repeating the date, in the browser's own 12 or 24 hour format +- Exporting a PDF no longer restyles the popup while the report renders, and the report no longer ends with a blank page +- Popup fits Chrome's 600px height limit with no scrollbar; upload, ping and loss figures no longer spill into the next cell, and "Good enough for" labels are no longer cut off +- Side panel averages switch to a 2×2 grid on narrow panels instead of overlapping, and the panel never scrolls sideways at 320px - CHANGELOG moved to Keep a Changelog format and past releases tagged as `v2026.0.0`, `v2026.1.0` and `v2026.2.0` (#91) +### Removed +- html2canvas, jsdom and unused constants in the extension and site +- IDE project files from the repository +- `privacy-policy.md` now only points to the policy on the site, so older store links still reach it + ## [2026.2.0] - 2026-04-23 Released on GitHub only. It never went to a store; stores stay on 2026.3.20.1 until 2027.0.0. @@ -36,14 +79,14 @@ Released on GitHub only. It never went to a store; stores stay on 2026.3.20.1 un Shipped to Firefox Add-ons, Edge Add-ons and Opera as store version 2026.3.20.1 ("Edge Migration"). ### Added -- Cloudflare Worker (`packages/worker/`) — serverless edge speed test server +- Cloudflare Worker (`services/worker/`): serverless edge speed test server - Streaming download via ReadableStream (1/5/10/25 MB) - Upload endpoint (drains body, no storage) - Ping endpoint (HEAD/GET) for latency - Health endpoint with edge location from cf-ray header - CORS support for browser extensions - Deployed at 300+ edge locations globally, zero cold starts -- Use-case readiness labels — Calls, Streaming, Gaming, Uploads with color indicators +- Use-case readiness labels: Calls, Streaming, Gaming, Uploads with color indicators - PDF health report export (html2canvas + jsPDF) - Dark theme matching the extension UI - Quality assessment, trend analysis, anomaly detection @@ -94,7 +137,7 @@ Shipped to Firefox Add-ons, Edge Add-ons and Opera as store version 2026.3.20.1 First store release, store version 2026.3.19.1 ("Major Rewrite One"). ### Added -- Monorepo structure with npm workspaces (`packages/server`, `packages/extension`) +- Monorepo structure with npm workspaces (`services/server`, `apps/extension`) - TypeScript for both server and extension with esbuild bundling - Compact UI with speed bars, quality badge, and glassmorphic dark theme - Test history persisted to `chrome.storage.local` (last 200 results) @@ -130,8 +173,9 @@ First store release, store version 2026.3.19.1 ("Major Rewrite One"). - Express.js server with test file generation - Simple popup UI -[Unreleased]: https://github.com/ajithakdev/meterx-server/compare/v2026.2.0...HEAD -[2026.2.0]: https://github.com/ajithakdev/meterx-server/compare/v2026.1.0...v2026.2.0 -[2026.1.0]: https://github.com/ajithakdev/meterx-server/compare/v2026.0.0...v2026.1.0 -[2026.0.0]: https://github.com/ajithakdev/meterx-server/compare/v0.2.0...v2026.0.0 -[0.2.0]: https://github.com/ajithakdev/meterx-server/releases/tag/v0.2.0 +[Unreleased]: https://github.com/ajithakdev/meterx/compare/v2027.0.0...HEAD +[2027.0.0]: https://github.com/ajithakdev/meterx/compare/v2026.2.0...v2027.0.0 +[2026.2.0]: https://github.com/ajithakdev/meterx/compare/v2026.1.0...v2026.2.0 +[2026.1.0]: https://github.com/ajithakdev/meterx/compare/v2026.0.0...v2026.1.0 +[2026.0.0]: https://github.com/ajithakdev/meterx/compare/v0.2.0...v2026.0.0 +[0.2.0]: https://github.com/ajithakdev/meterx/releases/tag/v0.2.0 diff --git a/CLAUDE.md b/CLAUDE.md index 26d4589..e18c0e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,52 +8,58 @@ Before reading code, check `docs/CODEMAP.md` (file map with line counts and the ## Commands -npm workspaces monorepo (`packages/*`). Run from the root unless noted. +npm workspaces monorepo. Run from the root unless noted. ```bash npm install -npm run build # all workspaces: extension -> packages/extension/dist, server -> packages/server/dist/server.js -npm test # vitest in extension + server (worker and shared have no tests) +npm run build # every workspace: extension -> apps/extension/dist, site -> apps/site/dist (needs the extension build), server -> services/server/dist +npm test # vitest in every workspace, plus scripts/ npm run lint # eslint (TS, CSS, Markdown) +npm run package # store uploads in release/: one zip for Chrome, Edge and Opera, one for Firefox, and a source zip +npm run dev:site # build and serve the site on :8788 +npm run dev:ext # build the extension and run the worker on :8787; load apps/extension/dist unpacked npm start # self-host Express server on :3000 (needs a prior build) -# Single test file / test name -cd packages/server && npx vitest run src/app.test.ts -cd packages/extension && npx vitest run src/quality.test.ts -t "excellent" +# One package +cd apps/extension && npx vitest run src/lib/quality.test.ts -t "excellent" +cd packages/reports && npx tsc --noEmit -# Type check (CI does this for server and extension only) -cd packages/server && npx tsc --noEmit -cd packages/extension && npx tsc --noEmit - -# Worker -cd packages/worker && npx wrangler dev # local -cd packages/worker && npx wrangler deploy # deploy to Cloudflare (free plan only) +# Worker and site deploys (free plans only) +cd services/worker && npx wrangler deploy +npm run deploy -w @meterx/site # Cloudflare Pages project meterx-site ``` -Load the extension unpacked from `packages/extension/dist/` (rebuild after every change; `static/` is copied verbatim into `dist/`). - -CI (`.github/workflows/ci.yml`) runs lint, both type checks, build and tests on Node 22, and on pushes to `main` uploads `meterx-extension.zip`. +Load the extension unpacked from `apps/extension/dist/` (rebuild after every change; `static/` is copied verbatim into `dist/`). Preview the site with `npx wrangler pages dev apps/site/dist`. ## Architecture -Three runtimes share one contract: +```text +apps/extension Manifest V3 extension (popup, side panel, settings, service worker) +apps/site meterx.ajithakdev.com: static pages plus the extension's own pages in demo mode under /app/ +services/worker Cloudflare Worker, the default test server (streams bytes, stores nothing) +services/server Express self-host server with the same API +packages/shared the contract: types, API constants, version, quality rules, formatting, share links (no browser APIs) +packages/reports PDF, CSV and JSON builders and their stats (pure; used by the extension, testable in Node) +brand/ logo masters and build.mjs, which makes every icon, store image and social card +scripts/ package-stores.mjs for store uploads +``` -- **`packages/extension`**: Manifest V3 extension, bundled by `build.mjs` (esbuild, ESM, 4 entry points: `background`, `popup`, `options`, `sidepanel`). All measurement logic lives in `src/background.ts` (service worker). The UI pages are thin: they send `chrome.runtime` messages (`startTest`, `cancelTest`, `getServerUrl`, `setServerUrl`, `rescheduleTests`, `openSidePanel`) and listen for `testProgress` / `testComplete` / `testError` broadcasts. Helpers (`quality`, `history`, `isp`, `share`, `chart`, `sparkline`, `export-*`) are imported by the pages. -- **`packages/worker`**: Cloudflare Worker, the default backend (`DEFAULT_SERVER_URL` in `background.ts` and `DEFAULT_URL` in `options.ts`, plus `host_permissions` in `static/manifest.json`; change all three together). Streams generated bytes, no storage. -- **`packages/server`**: Express 5 self-host fallback. `app.ts` exports the app (tested with supertest in `app.test.ts`); `server.ts` only listens. Generates zero-filled `test-files/*.bin` on demand. CORS allows extension origins plus `CORS_ORIGINS` (comma list). `src/test.ts` is a legacy CommonJS smoke script, not part of `npm test`. -- **`packages/shared`**: types and thresholds only (`QUALITY_THRESHOLDS`, `USE_CASE_REQUIREMENTS`, `TestResults`, `HistoryEntry`, ...), consumed as raw TS (`main: ./src/index.ts`) and bundled into each package. +- **Extension** (`apps/extension`): esbuild bundles `src/background/index.ts` (service worker; measurement in `background/runner.ts` and `measure.ts`) and `src/pages/{popup,sidepanel,options}.ts`. Pages are thin: they send `chrome.runtime` messages (`startTest`, `cancelTest`, `getState`, `rescheduleTests`) and listen for `testProgress` / `testComplete` / `testError`. Browser-only helpers live in `src/lib/` (history, isp, export downloads, ui, tooltips, browser). +- **Site** (`apps/site`): `build.mjs` copies `static/`, fills the `` and `` partials, and copies the built extension pages into `dist/app/` with `src/demo-shim.ts` injected before their scripts. The shim stands in for the extension APIs with labelled sample data (`src/sample.ts`); the home page drives the popup's sample test from scroll through `postMessage`. The scroll engine is `static/sc/` (scroll-craft, unmodified). +- **Worker and server**: same API, kept in sync. CORS answers extension origins, the site origin and localhost dev ports (`isAllowedOrigin` in shared). -**Server API contract** (worker and Express must stay in sync): `GET/HEAD /ping`, `GET /test-file/{1|5|10|25}MB.bin`, `POST /upload` (`application/octet-stream`, body discarded), `GET /health`, `GET /ip`. Allowed sizes are duplicated in `worker/src/index.ts` (`ALLOWED_SIZES`) and `server/src/app.ts` (`ALLOWED_FILE_SIZES`); the extension always downloads the 25 MB file. Responses must send `Timing-Allow-Origin` so the extension can read DNS timing. +**Server API contract**: `GET/HEAD /ping`, `GET /test-file/{1|5|10|25}MB.bin`, `POST /upload` (`application/octet-stream`, body discarded), `GET /health`, `GET /ip`. Sizes come from `ALLOWED_SIZES_MB` / `TEST_FILE_RE` in shared. Responses send `Timing-Allow-Origin` so the extension can read DNS timing. -**Measurement** (`background.ts`): parallel streams (4 download, 3 upload) for a fixed duration (`MEASURE_DURATION_MS`), with ramp-up and tail trimmed from the throughput samples, plus loaded-latency pings during load for bufferbloat. The server rate limits in `app.ts` are sized for this burst pattern; raise them if you add streams. +**Measurement**: 4 download and 3 upload streams for a fixed time, ramp-up and tail trimmed, loaded-latency pings for the bufferbloat grade. The rate limits in the worker and server are sized for this burst; raise them if you add streams. -**Storage**: `chrome.storage.sync` holds settings (`serverUrl`, `ispPlan`, `scheduleMinutes`, `notifyOnDrop`); `chrome.storage.local` holds `history` (capped at `MAX_HISTORY` = 200). Scheduled tests use `chrome.alarms` and are re-registered on install/startup. +**Storage**: `chrome.storage.sync` holds settings (`serverUrl`, `ispPlan`, `scheduleMinutes`, `notifyOnDrop`); `chrome.storage.local` holds `history` (capped at 200). Scheduled tests use `chrome.alarms`. -Custom server URLs need a runtime host-permission grant (`optional_host_permissions`); the options page requests it. +Custom server URLs need a runtime host-permission grant (`optional_host_permissions`); the settings page requests it. ## Gotchas -- Version strings are hard-coded in several places (root and package `package.json`s, file headers, `export-data.ts`, `/health` in `app.ts` and its assertion in `app.test.ts`). `manifest.json` uses its own Chrome-compatible version. Grep for the old version when bumping. +- The version lives only in the root `package.json`. `@meterx/shared` exports it as `VERSION` (used by `/health`, the exports and the settings footer), and `build.mjs` stamps it into `dist/manifest.json`, so it must be 1 to 4 dot-separated numbers. Bump that one line to release. - `manifest.json` description must stay within the store's 132-character limit. -- `.junie/guidelines.md` describes the old pre-monorepo JS layout and a Render URL; it is stale, trust the code. +- Store packages: Chromium stores share one zip; Firefox gets a converted manifest (sidebar, background script, id `meterx@ajithakdev`). The default worker URL is `WORKER_URL` in shared, and also sits in `host_permissions` in the extension's `static/manifest.json` and the site's `static/_headers` CSP; change all three together. +- Nothing deploys on merge. The worker (`wrangler deploy`), the site (Pages direct upload, not git-connected) and the stores are released by hand on Sunday, so docs-only merges are just merges. - License is AGPL-3.0; see `TRADEMARK.md` before touching branding. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2cfbd6b..b3e1ff0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,8 @@ Here's how to get set up and contribute. ## Dev Setup ```bash -git clone https://github.com/ajithakdev/meterx-server.git -cd meterx-server +git clone https://github.com/ajithakdev/meterx.git +cd meterx npm install npm run build ``` @@ -16,14 +16,14 @@ npm run build 1. Go to `chrome://extensions` (or `edge://extensions`) 2. Enable "Developer mode" 3. Click "Load unpacked" -4. Select `packages/extension/dist/` +4. Select `apps/extension/dist/` Rebuild with `npm run build` after changes, then hit reload on the extensions page. ## Run the Worker Locally ```bash -cd packages/worker +cd services/worker npx wrangler dev ``` @@ -40,12 +40,16 @@ Starts the Express.js self-host server. Useful for LAN testing or when you don't ## Project Structure ```text -packages/ - extension/ # Browser extension (TypeScript, Manifest V3, esbuild) - worker/ # Cloudflare Worker — primary serverless backend - server/ # Express.js server — self-host fallback +apps/extension Browser extension (TypeScript, Manifest V3, esbuild) +apps/site meterx.ajithakdev.com +services/worker Cloudflare Worker, the default test server +services/server Express server, for self-hosting +packages/shared Types, API constants and rules every part shares +packages/reports PDF, CSV and JSON builders ``` +The full map is in [docs/CODEMAP.md](docs/CODEMAP.md). + ## Commands | Command | What it does | @@ -57,12 +61,12 @@ packages/ | `npm run lint` | ESLint check | | `npm run lint:fix` | Auto-fix lint issues | | `npm -w @meterx/server run test` | Run server tests only | -| `cd packages/worker && npx wrangler dev` | Run worker locally | -| `cd packages/worker && npx wrangler deploy` | Deploy worker to Cloudflare | +| `cd services/worker && npx wrangler dev` | Run worker locally | +| `cd services/worker && npx wrangler deploy` | Deploy worker to Cloudflare | ## Code Style -- TypeScript everywhere — no plain JS +- TypeScript everywhere, no plain JS - ESLint with strict rules, run `npm run lint` before committing - Prefix unused parameters with `_` - Keep functions short and focused @@ -73,7 +77,7 @@ packages/ 1. Fork the repo, branch from `main` 2. Make your changes -3. Run `npm run lint && npm run build && npm test` — all must pass +3. Run `npm run lint && npm run build && npm test`: all must pass 4. Open a PR with a clear description of what changed and why 5. One approval required to merge diff --git a/LICENSE b/LICENSE index 16cb34b..52a6d38 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -MeterX — Open Source Network Health Tool +MeterX: Open Source Network Health Tool Copyright (c) 2026 ajithakdev (https://github.com/ajithakdev) This program is free software: you can redistribute it and/or modify diff --git a/README.md b/README.md index b818399..23e91f5 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,86 @@ -# MeterX +

+ MeterX +

-Every link and how-to is in [RESOURCES-AND-GUIDE.md](RESOURCES-AND-GUIDE.md). +

+ Your internet, honestly measured.
+ A free, open-source speed test that lives in your browser toolbar. +

-[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) -[![CI](https://github.com/ajithakdev/meterx-server/actions/workflows/ci.yml/badge.svg?branch=add/dev/2026.0.MAR.1)](https://github.com/ajithakdev/meterx-server/actions) +

+ Website · + Install · + Links and how-tos · + Contribute +

-Open-source speed test browser extension. Measures download, upload, ping, jitter, and packet loss from your toolbar — powered by Cloudflare's edge network. +--- -## Features +Speed tests give you a number. MeterX tells you what it means. -- **Full speed test** — download, upload, ping, jitter, packet loss -- **Use-case readiness** — labels for Calls, Streaming, Gaming, Uploads -- **Connection quality badge** — live rating on the extension icon -- **PDF health report** — export a full network report as PDF -- **Test history** — last 7 displayed, 200 stored locally -- **Cloudflare edge** — serverless backend at 300+ PoPs worldwide -- **Self-hostable server** — Express.js fallback for LAN or self-hosting -- **Settings page** — configure custom server URL -- **Animated UI** — progress steps, speed bars, smooth transitions -- **No tracking, no accounts** — everything stays in your browser +Click the icon, wait about twenty seconds, and you get download, upload, ping, jitter, packet loss and the one most tests skip: **latency under load**. That is why your video call stutters while someone else downloads a game, and MeterX grades it from A to F. -## Install from Source +No account. No tracking. Your results never leave your browser unless you share them. -```bash -git clone https://github.com/ajithakdev/meterx-server.git -cd meterx-server -npm install -npm run build -``` +## Install -**Load the extension:** -1. Go to `chrome://extensions` (or `edge://extensions`) -2. Enable "Developer mode" -3. Click "Load unpacked" and select `packages/extension/dist/` +| Browser | Where | +| --- | --- | +| Firefox | [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/meterx-by-ajithakdev/) | +| Microsoft Edge | [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/glkpolbkpachomgmmhdkmjnghejbhlfc) | +| Opera | Listing link coming soon | +| Chrome, Brave, Vivaldi | Planned | -**Run the server locally (optional):** -```bash -npm start -``` +Not sure which? [meterx.ajithakdev.com/get](https://meterx.ajithakdev.com/get/) picks the right store for you. -## Architecture +## What you get -```text -meterx-server/ - packages/ - extension/ # Browser extension (TypeScript, Manifest V3, esbuild) - src/ - background.ts # Service worker — test orchestration - popup.ts # Popup UI, history, PDF export - static/ # HTML, CSS, manifest, icons - build.mjs # esbuild bundler - - worker/ # Cloudflare Worker — primary backend (serverless) - src/index.ts # Edge handler — download, upload, ping, health - wrangler.toml # Wrangler config - - server/ # Express.js server — self-host fallback - src/ - app.ts # Express app (routes, middleware) - server.ts # Entry point - app.test.ts # Vitest + supertest (8 tests) -``` +- **A real test, fast.** Four download and three upload streams for a fixed time, with the slow start trimmed off. +- **Bufferbloat, graded.** Ping measured while the line is busy, A to F. +- **A plain answer.** Calls, 4K streaming, gaming, big uploads: MeterX says in words what your connection can handle. +- **Memory.** Up to 200 results in the side panel, a trend chart, and how much of your ISP plan actually arrives. +- **Tests on a timer.** Every 30 minutes, 2 hours or daily, with a notice when quality drops. +- **Proof you can hand over.** A PDF report with a report ID that changes if the results are edited, or CSV and JSON with units in every column. +- **Share links with no server.** The whole result rides inside the link. +- **Your own server, if you like.** Tests run against Cloudflare's edge by default, or against one you host. + +## Under the hood ```text -Browser Extension --> Cloudflare Worker (default, 300+ PoPs) - --> Express Server (self-host / LAN fallback) +apps/extension the extension: popup, side panel, settings, service worker +apps/site meterx.ajithakdev.com, running the real extension pages on sample data +services/worker Cloudflare Worker test server (streams bytes, stores nothing) +services/server Express server with the same API, for self-hosting +packages/shared the contract: types, API, version, quality rules, share links +packages/reports PDF, CSV and JSON builders +brand/ logo and the script that makes every icon and store image +scripts/ store packaging, local dev runner, code map ``` -## Server Endpoints +The deep dive is in [CLAUDE.md](CLAUDE.md) and [docs/CODEMAP.md](docs/CODEMAP.md). -Both the worker and self-host server expose the same API: - -| Endpoint | Method | Purpose | -|----------|--------|---------| -| `/ping` | GET/HEAD | Latency measurement | -| `/test-file/:size` | GET | Download test (1, 5, 10, 25 MB) | -| `/upload` | POST | Upload drain | -| `/health` | GET | Health check (includes edge location on worker) | - -All endpoints are rate-limited. CORS restricted to browser extensions and configured origins. - -## Development +## Hack on it ```bash -npm install # Install all workspace deps -npm run build # Build all packages -npm start # Run self-host server -npm test # Run all tests -cd packages/worker && npx wrangler dev # Run worker locally -npm run lint # ESLint +git clone https://github.com/ajithakdev/meterx.git +cd meterx +npm install +npm run dev:ext # build the extension, run a local test server on :8787 +npm run dev:site # build and serve the site on :8788 +npm test && npm run lint +npm run package # store uploads in release/ ``` -See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. +To load it, open `chrome://extensions`, turn on Developer mode, choose Load unpacked and pick `apps/extension/dist/`. In MeterX settings, set Custom server to `http://localhost:8787` to test against your local worker. After a change, run `npm run build -w @meterx/extension` and reload. -## Tech Stack - -- **Extension:** TypeScript, Manifest V3, esbuild -- **Worker:** Cloudflare Workers, TypeScript, Wrangler -- **Server:** TypeScript, Express.js 5, esbuild -- **Testing:** Vitest, supertest -- **CI:** GitHub Actions +To self-host the test server, run `npm start` (port 3000) or deploy `services/worker` to your own Cloudflare account. ## Privacy -MeterX collects zero personal data. All results are stored locally in your browser. Network requests go only to the configured test server. See [privacy-policy.md](privacy-policy.md). +MeterX collects no personal data. Results stay in your browser and the default test server keeps no logs. Full policy: [meterx.ajithakdev.com/privacy](https://meterx.ajithakdev.com/privacy/). ## License -[AGPL-3.0](LICENSE) — see [TRADEMARK.md](TRADEMARK.md) for brand usage policy. - -## Author - -**Ajith Kumar K** ([@ajithakdev](https://github.com/ajithakdev)) +[AGPL-3.0](LICENSE). The name and logo have their own rules in [TRADEMARK.md](TRADEMARK.md). -- GitHub: [github.com/ajithakdev](https://github.com/ajithakdev) -- Email: ajithkumarks2579@gmail.com +Made by Ajith Kumar K ([@ajithakdev](https://github.com/ajithakdev)). diff --git a/RESOURCES-AND-GUIDE.md b/RESOURCES-AND-GUIDE.md index 72b5568..089a700 100644 --- a/RESOURCES-AND-GUIDE.md +++ b/RESOURCES-AND-GUIDE.md @@ -1,14 +1,14 @@ # MeterX: resources and guide -Every link for the speed test extension and server, what it is for, and how to run and release it. Passwords and keys are never written here. +Every link for MeterX, what it is for, and how to run, package and release it. Passwords and keys are never written here. ## TL;DR -- The repo contains the browser extension, Cloudflare Worker backend, and Express fallback server. -- Cloudflare Worker at https://meterx-speedtest.meterx-ajithakdev.workers.dev serves as the default edge backend. -- Run `npm run build` to compile all packages, and `npm test` to run tests. -- Pushes to `main` build the extension and package `meterx-extension.zip` in GitHub Actions CI. -- The extension options page allows switching to a local Express server at http://localhost:3000. +- Website: https://meterx.ajithakdev.com (one link for any browser: https://meterx.ajithakdev.com/get/) +- Code: https://github.com/ajithakdev/meterx (was `meterx-server`; GitHub redirects the old URL) +- `npm run build`, `npm test`, `npm run lint` from the root; `npm run package` writes the store uploads to `release/`. +- Releases are weekly on Sunday: tag, GitHub release, `wrangler deploy` for the worker, store uploads, site deploy. +- Default test server: https://meterx-speedtest.meterx-ajithakdev.workers.dev ## Links and what each one is for @@ -16,63 +16,80 @@ Every link for the speed test extension and server, what it is for, and how to r | Link | Use it for | | --- | --- | -| https://meterx-speedtest.meterx-ajithakdev.workers.dev | Default Cloudflare Worker speed test edge backend | -| https://meterx.dev/share | Shared speed test results viewer | -| https://github.com/ajithakdev | Author GitHub profile | +| https://meterx.ajithakdev.com | Product site: home, how it works, features, questions, about | +| https://meterx.ajithakdev.com/get/ | Sends each visitor to their own browser's store. Use this link in posts and bios | +| https://meterx.ajithakdev.com/contribute/ | Contribute: live issues for newcomers and contributors from GitHub | +| https://meterx.ajithakdev.com/privacy/ | Privacy policy (the stores link here) | +| https://meterx.ajithakdev.com/r/ | Share viewer: opens links made by the Share button | +| https://addons.mozilla.org/firefox/addon/meterx-by-ajithakdev/ | Firefox listing (add-on id `meterx@ajithakdev`) | +| https://microsoftedge.microsoft.com/addons/detail/glkpolbkpachomgmmhdkmjnghejbhlfc | Microsoft Edge listing | +| Opera Add-ons | Listing link not found yet; add it to `apps/site/src/stores.ts` when known | +| https://meterx-speedtest.meterx-ajithakdev.workers.dev | Default test server (Cloudflare Worker) | ### Behind the scenes | Link | Use it for | | --- | --- | -| https://github.com/ajithakdev/meterx-server | Source code, issues, and pull requests | -| https://github.com/ajithakdev/meterx-server/actions | GitHub Actions CI workflow runs | -| https://github.com/ajithakdev/meterx-server/issues | Bug reports and issue tracker | +| https://github.com/ajithakdev/meterx | Source code, issues, pull requests, releases | +| https://github.com/ajithakdev/meterx/actions | CI runs | +| https://addons.mozilla.org/developers/ | Firefox uploads and review | +| https://partner.microsoft.com/dashboard/microsoftedge/ | Edge uploads | +| https://addons.opera.com/developer/ | Opera uploads | +| https://dash.cloudflare.com | Worker, Pages project `meterx-site`, DNS for `ajithakdev.com` | ## Accounts | Service | Account | Role | | --- | --- | --- | | GitHub | `ajithakdev` | Owner | -| Cloudflare | `meterx-ajithakdev` | Worker backend host | +| Cloudflare | personal account (`meterx-ajithakdev` workers subdomain) | Worker, Pages, DNS; free plan only | +| Firefox, Edge, Opera developer accounts | Ajith Kumar K | Store listings | ## User guide -Click the MeterX icon in the browser toolbar to open the popup. Select Start Test to measure download speed, upload speed, ping, jitter, and packet loss. Results are saved locally in browser storage. +1. Install MeterX from your browser's store (https://meterx.ajithakdev.com/get/). +2. Click the MeterX icon in the toolbar and press Run test. It takes about twenty seconds. +3. Open the side panel (the panel icon in the popup) to see your history, trend and plan comparison. +4. In settings (the sliders icon), enter your ISP plan, choose a test schedule, or point MeterX at your own server. +5. Export or Share from the popup or side panel to save a PDF, CSV or JSON, or send someone a link. -To point the extension at a self-hosted server, click the settings gear icon in the popup. Select Custom Server under Server. Enter the server URL, such as http://localhost:3000, and click Test to verify the connection. Click Save. Plain http is permitted for localhost, 127.0.0.1, and .local domains. - -## Run and release +## Admin guide | Job | How | | --- | --- | -| Install dependencies | `npm install` | -| Build all packages | `npm run build` | -| Run Express server | `npm start` | -| Run Worker locally | `cd packages/worker && npx wrangler dev` | -| Run tests | `npm test` | -| Lint codebase | `npm run lint` | -| Load unpacked extension | Open `chrome://extensions`, enable Developer mode, and load `packages/extension/dist/` | -| Package extension zip | `cd packages/extension/dist && zip -r ../../../meterx-extension.zip .` | -| Deploy Worker | `cd packages/worker && npx wrangler deploy` | +| Install | `npm install` | +| Build everything | `npm run build` (the site build needs the extension build, which runs first) | +| Test, lint, type check | `npm test`, `npm run lint`, `npx tsc --noEmit` in a package | +| Load the extension | `chrome://extensions`, Developer mode, Load unpacked: `apps/extension/dist/` | +| Preview the site | `npm run dev:site` (builds, serves on http://localhost:8788) | +| Test the extension | `npm run dev:ext` (builds, runs the worker on http://localhost:8787; load `apps/extension/dist` with Load unpacked) | +| Package for the stores | `npm run package`: `release/meterx--chromium.zip` (Chrome, Edge, Opera), `-firefox.zip`, `-source.zip` (upload with the Firefox build) | +| Deploy the worker | `cd services/worker && npx wrangler deploy` | +| Deploy the site | `npm run deploy -w @meterx/site`: builds the extension and site and uploads to Cloudflare Pages project `meterx-site`. Not connected to git, so merges (docs or code) never deploy | +| First site deploy (once) | `npx wrangler pages project create meterx-site --production-branch main`, then `npm run deploy -w @meterx/site`, then in the Cloudflare dashboard: Workers and Pages, `meterx-site`, Custom domains, add `meterx.ajithakdev.com` (Cloudflare adds the CNAME itself). Then set the GitHub repo's website to the site | +| Submit to the stores | `npm run package`, then upload `release/meterx--chromium.zip` to Edge and Opera, and `-firefox.zip` plus `-source.zip` to Firefox. Images are in `brand/png/store/`. All three stores are free; the Chrome Web Store needs a one-time paid developer fee, so it waits | +| Bump the version | Edit `version` in the root `package.json` only; the build stamps it everywhere | +| Release | Move CHANGELOG Unreleased under the version, commit `chore: release vX.Y.Z`, tag, push the tag, `gh release create`, then deploy the worker, deploy the site and upload the store zips. Merging to `main` deploys nothing | +| Regenerate brand images | `npm run build`, then `node brand/build.mjs` (see the header of that file for the env vars) | +| Regenerate the code map | `node scripts/codemap.mjs` | +| Logo history | `brand/alternates/` keeps the other logo options that were considered (rising bars, up and down arrows) | ## When something goes wrong | Problem | Fix | | --- | --- | -| Express rate limit error behind reverse proxy | Set `app.set('trust proxy', 1)` in Express to read client IPs through proxy headers | -| Custom server URL rejected | Use https for remote servers. Plain http is only allowed for localhost, 127.0.0.1, or .local domains | -| Bufferbloat inflates ping after upload | A buffer drain delay separates the upload test and ping test in the background worker | -| Blank PDF report export | Render the container offscreen with visible styling instead of using opacity zero | -| Server returns 429 Too Many Requests | Wait one minute for the rate limit window to reset | +| Site quick test or settings check says it cannot reach the server | The worker must allow the site origin: deploy it with `wrangler deploy` | +| Custom server URL rejected | Use https for remote servers. Plain http is only allowed for localhost, 127.0.0.1 and .local | +| Server returns 429 Too Many Requests | Wait a minute for the rate limit window to reset | +| Firefox upload rejected for a missing id | Upload the `-firefox.zip` from `npm run package`, not the Chromium zip | +| Site build fails with "Build the extension first" | Run `npm run build` from the root, or build `@meterx/extension` before `@meterx/site` | +| The demo pages on the site look out of date | Rebuild: the site copies the current extension build into `/app/` | ## More detail -- docs/STATE.md: What is in progress and what comes next. -- docs/KNOWN-ISSUES.md: Open bugs by area, each linked to its GitHub issue. -- docs/CODEMAP.md: File map with line counts and the extension's message protocol. -- README.md: Architecture overview, feature list, and server endpoint details. -- CONTRIBUTING.md: Development setup, commands, code style, and pull request guidelines. -- CHANGELOG.md: Version history and migration notes. -- privacy-policy.md: Extension and server data handling policy. -- TRADEMARK.md: Brand usage rules for the MeterX name and logo. -- LICENSE: AGPL-3.0 terms for the codebase. +- docs/STATE.md: what is in progress and what comes next +- docs/CODEMAP.md: every source file, with the message protocol +- docs/SITE-PLAN.md: the site's pages, hosting and brand assets +- docs/KNOWN-ISSUES.md: open bugs by area +- docs/SEO.md: the site's SEO setup and release checklist +- CHANGELOG.md, CONTRIBUTING.md, TRADEMARK.md, LICENSE diff --git a/apps/extension/build.mjs b/apps/extension/build.mjs new file mode 100644 index 0000000..66d21f6 --- /dev/null +++ b/apps/extension/build.mjs @@ -0,0 +1,40 @@ +import { build } from 'esbuild'; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Start clean so files deleted from static/ don't linger in dist/ +rmSync(resolve(__dirname, 'dist'), { recursive: true, force: true }); + +// Bundle TypeScript source files +await build({ + entryPoints: { + background: resolve(__dirname, 'src/background/index.ts'), + popup: resolve(__dirname, 'src/pages/popup.ts'), + options: resolve(__dirname, 'src/pages/options.ts'), + sidepanel: resolve(__dirname, 'src/pages/sidepanel.ts'), + }, + bundle: true, + // jsPDF's optional extras (doc.html, SVG, sanitising); the report draws natively and never loads them. + external: ['html2canvas', 'dompurify', 'canvg'], + outdir: resolve(__dirname, 'dist'), + target: 'chrome110', + format: 'esm', + minify: false, + sourcemap: false, +}); + +// Copy static assets to dist +mkdirSync(resolve(__dirname, 'dist/images'), { recursive: true }); +cpSync(resolve(__dirname, 'static'), resolve(__dirname, 'dist'), { recursive: true }); + +// The root package.json is the only place the version lives; stamp it into the manifest. +const { version } = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8')); +if (!/^\d+(\.\d+){0,3}$/.test(version)) throw new Error(`Version ${version} is not valid for a browser manifest (use 1 to 4 numbers)`); +const manifestPath = resolve(__dirname, 'dist/manifest.json'); +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); +writeFileSync(manifestPath, JSON.stringify({ ...manifest, version }, null, 2) + '\n'); + +console.log(`Extension ${version} built to dist/`); diff --git a/packages/extension/package.json b/apps/extension/package.json similarity index 55% rename from packages/extension/package.json rename to apps/extension/package.json index 6c2e54f..ea6c270 100644 --- a/packages/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,5 @@ { "name": "@meterx/extension", - "version": "2026.0.319-MR1.0", "private": true, "license": "AGPL-3.0", "scripts": { @@ -8,12 +7,10 @@ "test": "npx vitest run" }, "devDependencies": { - "@types/chrome": "^0.0.287", - "jsdom": "^29.0.2" + "@types/chrome": "^0.0.287" }, "dependencies": { - "@meterx/shared": "*", - "html2canvas": "^1.4.1", - "jspdf": "^4.2.1" + "@meterx/reports": "*", + "@meterx/shared": "*" } } diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts new file mode 100644 index 0000000..9226407 --- /dev/null +++ b/apps/extension/src/background/index.ts @@ -0,0 +1,119 @@ +/** + * MeterX service worker: runs tests, saves history, schedules, notifies. + * Pages talk to it with chrome.runtime messages (see docs/CODEMAP.md). + * @author ajithakdev (https://github.com/ajithakdev) + * @license AGPL-3.0 + */ + +/// + +import type { BroadcastMessage, HistoryEntry, TestProgress } from '@meterx/shared'; +import { getServerUrl } from '../lib/browser'; +import { saveHistory } from '../lib/history'; +import { getQuality, QUALITY_COLORS } from '../lib/quality'; +import { CancelledError, HttpError, runTest } from './runner'; + +const ALARM = 'meterx-scheduled-test'; +const DROP_NOTIFICATION = 'meterx-quality-drop'; + +type Source = 'user' | 'schedule' | 'shortcut'; + +let controller: AbortController | null = null; +let progress: TestProgress = {}; + +function broadcast(message: BroadcastMessage): void { + chrome.runtime.sendMessage(message).catch(() => { /* no page open */ }); +} + +function report(p: TestProgress): void { + progress = { ...progress, ...p }; + broadcast({ action: 'testProgress', data: p }); +} + +function describeError(e: unknown): string { + if (e instanceof HttpError && e.status === 429) return 'The server is busy. Try again in a minute.'; + if (e instanceof HttpError) return `The server answered with an error (HTTP ${e.status}).`; + if (e instanceof TypeError) return "Can't reach the test server. Check your connection or the server in Settings."; + return e instanceof Error ? e.message : 'The test failed.'; +} + +async function setBadge(entry: HistoryEntry): Promise { + if (entry.downloadSpeed === undefined) return; + const dl = entry.downloadSpeed; + await chrome.action.setBadgeText({ text: dl >= 100 ? String(Math.round(dl)) : dl.toFixed(dl >= 10 ? 0 : 1) }); + const q = getQuality(entry.downloadSpeed, entry.uploadSpeed ?? 0, entry.ping ?? 999); + await chrome.action.setBadgeBackgroundColor({ color: QUALITY_COLORS[q.cls] }); +} + +/** Notify only when a scheduled test drops to Poor from something better (#67). */ +async function maybeNotifyDrop(entry: HistoryEntry, previous?: HistoryEntry): Promise { + const { notifyOnDrop } = await chrome.storage.sync.get('notifyOnDrop'); + if (!notifyOnDrop || entry.downloadSpeed === undefined) return; + const grade = (e: HistoryEntry) => getQuality(e.downloadSpeed ?? 0, e.uploadSpeed ?? 0, e.ping ?? 999).cls; + if (grade(entry) !== 'poor' || (previous && grade(previous) === 'poor')) return; + await chrome.notifications.create(DROP_NOTIFICATION, { + type: 'basic', + iconUrl: chrome.runtime.getURL('images/icon-128.png'), + title: 'Your connection dropped to Poor', + message: `${entry.downloadSpeed.toFixed(1)} Mbps down, ${(entry.ping ?? 0).toFixed(0)} ms ping. Open MeterX for details.`, + }); +} + +async function startTest(source: Source): Promise { + if (controller) return; + controller = new AbortController(); + progress = {}; + try { + const results = await runTest(await getServerUrl(), controller.signal, report); + const { serverUrl: _server, ...rest } = results; + const entry: HistoryEntry = { ...rest, timestamp: Date.now() }; + const previous = await saveHistory(entry); + await setBadge(entry); + broadcast({ action: 'testComplete', data: entry }); + if (source === 'schedule') await maybeNotifyDrop(entry, previous); + } catch (e) { + const cancelled = e instanceof CancelledError || controller.signal.aborted; + if (!cancelled) console.error('MeterX test failed:', e); + broadcast({ action: 'testError', error: cancelled ? 'Test cancelled' : describeError(e), cancelled }); + } finally { + controller = null; + progress = {}; + } +} + +/** Creates the alarm only when it is missing or its period changed, so browser restarts don't reset it (#66). */ +async function syncAlarm(force = false): Promise { + const minutes = Number((await chrome.storage.sync.get('scheduleMinutes')).scheduleMinutes) || 0; + const existing = await chrome.alarms.get(ALARM); + if (!minutes) { if (existing) await chrome.alarms.clear(ALARM); return; } + if (!force && existing?.periodInMinutes === minutes) return; + await chrome.alarms.create(ALARM, { periodInMinutes: minutes, delayInMinutes: minutes }); +} + +chrome.alarms.onAlarm.addListener(alarm => { if (alarm.name === ALARM) startTest('schedule'); }); +chrome.runtime.onInstalled.addListener(() => { syncAlarm(); }); +chrome.runtime.onStartup.addListener(() => { syncAlarm(); }); +chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'sync' && 'scheduleMinutes' in changes) syncAlarm(true); +}); +chrome.commands?.onCommand.addListener(command => { if (command === 'run-test') startTest('shortcut'); }); +chrome.notifications?.onClicked.addListener(id => { + if (id === DROP_NOTIFICATION) chrome.tabs.create({ url: chrome.runtime.getURL('sidepanel.html') }); +}); + +chrome.runtime.onMessage.addListener((message: { action?: string }, _sender, sendResponse) => { + switch (message.action) { + case 'startTest': + startTest('user'); + sendResponse({ running: true }); + return false; + case 'cancelTest': + controller?.abort(); + sendResponse({ running: false }); + return false; + case 'getState': + sendResponse({ running: !!controller, progress }); + return false; + } + return false; +}); diff --git a/apps/extension/src/background/measure.test.ts b/apps/extension/src/background/measure.test.ts new file mode 100644 index 0000000..c1d743c --- /dev/null +++ b/apps/extension/src/background/measure.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { toMbps, bytesAtTime, steadyMbps, median, latencyStats, gradeBufferbloat, uploadChunkBytes, backoffMs } from './measure'; + +const opts = { rampMs: 1000, tailMs: 500, durationMs: 8000 }; + +describe('toMbps', () => { + it('uses decimal megabits', () => { + expect(toMbps(125_000_000, 1000)).toBe(1000); // 125 MB in 1 s = 1 Gbps + }); + it('returns 0 for zero time', () => { + expect(toMbps(100, 0)).toBe(0); + }); +}); + +describe('bytesAtTime', () => { + const s = [{ t: 0, bytes: 0 }, { t: 1000, bytes: 1000 }, { t: 2000, bytes: 3000 }]; + it('interpolates between samples', () => { + expect(bytesAtTime(s, 1500)).toBe(2000); + }); + it('returns the exact sample when the window ends on it', () => { + expect(bytesAtTime(s, 1000)).toBe(1000); + }); + it('clamps outside the range', () => { + expect(bytesAtTime(s, -5)).toBe(0); + expect(bytesAtTime(s, 9999)).toBe(3000); + expect(bytesAtTime([], 10)).toBe(0); + }); +}); + +describe('steadyMbps', () => { + it('measures only the steady window', () => { + // 1 MB/s after a slow first second: steady rate is 8 Mbps. + const s = [{ t: 0, bytes: 0 }, { t: 1000, bytes: 100_000 }, { t: 8000, bytes: 7_100_000 }]; + expect(steadyMbps(s, 8000, opts)).toBeCloseTo(8, 5); + }); + it('reads 0, not NaN, when nothing completed', () => { + expect(steadyMbps([{ t: 0, bytes: 0 }], 8000, opts)).toBe(0); + }); + it('falls back to total over elapsed for a short phase', () => { + expect(steadyMbps([{ t: 0, bytes: 0 }, { t: 500, bytes: 500_000 }], 500, opts)).toBeCloseTo(8, 5); + }); +}); + +describe('latencyStats', () => { + it('takes the median and mean consecutive change', () => { + expect(latencyStats([10, 20, 10, 30], 4)).toEqual({ ping: 15, jitter: (10 + 10 + 20) / 3, packetLoss: 0 }); + }); + it('counts lost probes', () => { + const r = latencyStats([20, 22, 24, 26], 5); + expect(r.packetLoss).toBe(20); + expect(r.ping).toBe(23); + }); + it('has zero jitter for a single ping', () => { + expect(latencyStats([40], 1).jitter).toBe(0); + }); + it('median of nothing is NaN', () => { + expect(median([])).toBeNaN(); + }); +}); + +describe('gradeBufferbloat', () => { + it.each([[0, 'A'], [5, 'A'], [30, 'B'], [60, 'C'], [200, 'D'], [201, 'F']] as const)('%d ms -> %s', (d, g) => { + expect(gradeBufferbloat(d)).toBe(g); + }); +}); + +describe('uploadChunkBytes', () => { + it('uses small bodies on slow links so requests finish (#60)', () => { + expect(uploadChunkBytes(10, 3)).toBe(256 * 1024); + expect(uploadChunkBytes(undefined, 3)).toBe(256 * 1024); + }); + it('caps bodies on fast links', () => { + expect(uploadChunkBytes(5000, 3)).toBe(8 * 1024 * 1024); + }); + it('targets about 4 completions per second per stream', () => { + // 300 Mbps over 3 streams = 12.5 MB/s per stream, quarter of that per body. + expect(uploadChunkBytes(300, 3)).toBe(3_125_000); + }); +}); + +describe('backoffMs', () => { + it('doubles and caps', () => { + expect([1, 2, 3, 4, 10].map(backoffMs)).toEqual([250, 500, 1000, 2000, 2000]); + }); +}); diff --git a/apps/extension/src/background/measure.ts b/apps/extension/src/background/measure.ts new file mode 100644 index 0000000..17792aa --- /dev/null +++ b/apps/extension/src/background/measure.ts @@ -0,0 +1,76 @@ +/** + * Pure measurement math. No I/O, so it is unit tested (measure.test.ts). + * @license AGPL-3.0 + */ + +import { gradeBufferbloat, median } from '@meterx/shared'; + +export { gradeBufferbloat, median }; + +/** Cumulative bytes received at time t (ms since the phase started). */ +export interface ByteSample { t: number; bytes: number } + +/** Decimal megabits per second, the unit ISPs advertise (#64). */ +export function toMbps(bytes: number, ms: number): number { + return ms > 0 ? (bytes * 8) / (ms * 1000) : 0; +} + +/** Bytes at tTarget by linear interpolation between samples ordered by t. */ +export function bytesAtTime(samples: ByteSample[], tTarget: number): number { + if (samples.length === 0) return 0; + if (tTarget <= samples[0].t) return samples[0].bytes; + for (let i = 1; i < samples.length; i++) { + const b = samples[i]; + if (b.t >= tTarget) { + const a = samples[i - 1]; + return a.bytes + (b.bytes - a.bytes) * ((tTarget - a.t) / (b.t - a.t || 1)); + } + } + return samples[samples.length - 1].bytes; +} + +/** + * Steady-state throughput: drops the ramp-up and the tail, then measures + * the bytes moved in the window between. Falls back to total/elapsed when + * the phase ended too early for a window. + */ +export function steadyMbps(samples: ByteSample[], elapsedMs: number, opts: { rampMs: number; tailMs: number; durationMs: number }): number { + const end = Math.min(opts.durationMs - opts.tailMs, elapsedMs); + const last = samples.length ? samples[samples.length - 1].bytes : 0; + if (end <= opts.rampMs) return toMbps(last, elapsedMs); + return toMbps(bytesAtTime(samples, end) - bytesAtTime(samples, opts.rampMs), end - opts.rampMs); +} + + +/** Median ping, mean absolute change between consecutive pings, and % of probes lost. */ +export function latencyStats(latencies: number[], attempts: number): { ping: number; jitter: number; packetLoss: number } { + let diff = 0; + for (let i = 1; i < latencies.length; i++) diff += Math.abs(latencies[i] - latencies[i - 1]); + return { + ping: median(latencies), + jitter: latencies.length > 1 ? diff / (latencies.length - 1) : 0, + packetLoss: attempts > 0 ? ((attempts - latencies.length) / attempts) * 100 : 0, + }; +} + + +const KB = 1024; +const MIN_UPLOAD_CHUNK = 256 * KB; +const MAX_UPLOAD_CHUNK = 8 * 1024 * KB; + +/** + * Upload body size so each stream finishes about 4 requests a second. + * Bytes only count when a request completes, so fixed 5 MB bodies read + * 0 Mbps on slow uplinks (#60). Sized from the download speed, which is + * known by then and is usually the higher of the two. + */ +export function uploadChunkBytes(downloadMbps: number | undefined, streams: number): number { + const perStreamBytesPerSec = ((downloadMbps || 0) * 1e6) / 8 / Math.max(1, streams); + const target = Math.round(perStreamBytesPerSec / 4); + return Math.min(MAX_UPLOAD_CHUNK, Math.max(MIN_UPLOAD_CHUNK, target)); +} + +/** Delay before retrying a failed transfer request: 250 ms, doubling, capped at 2 s (#65). */ +export function backoffMs(failures: number): number { + return Math.min(2000, 250 * 2 ** Math.max(0, failures - 1)); +} diff --git a/apps/extension/src/background/runner.ts b/apps/extension/src/background/runner.ts new file mode 100644 index 0000000..d1c425a --- /dev/null +++ b/apps/extension/src/background/runner.ts @@ -0,0 +1,226 @@ +/** + * One speed test: connect, idle latency, download, upload. + * Runs in the service worker. The math is in measure.ts. + * @author ajithakdev (https://github.com/ajithakdev) + * @license AGPL-3.0 + */ + +/// + +import type { IpInfo, TestProgress, TestResults } from '@meterx/shared'; +import { + type ByteSample, backoffMs, bytesAtTime, gradeBufferbloat, latencyStats, + median, steadyMbps, toMbps, uploadChunkBytes, +} from './measure'; + +const DOWNLOAD_PATH = '/test-file/25MB.bin'; +const DOWNLOAD_STREAMS = 4; +const UPLOAD_STREAMS = 3; +// Multi-stream, fixed-duration phases (the M-Lab / Ookla approach). +// The worker and Express rate limits are sized for this burst; raise them if you add streams. +const MEASURE_DURATION_MS = 8000; +const WINDOW = { rampMs: 1200, tailMs: 500, durationMs: MEASURE_DURATION_MS }; +const PING_COUNT = 10; +const PING_GAP_MS = 100; +const PING_TIMEOUT_MS = 3000; +const LOADED_PING_INTERVAL_MS = 400; +const PROGRESS_EVERY_MS = 200; + +export type Reporter = (progress: TestProgress) => void; + +export class CancelledError extends Error { + constructor() { super('Test cancelled'); this.name = 'CancelledError'; } +} + +export class HttpError extends Error { + constructor(readonly status: number) { super(`Server answered HTTP ${status}`); this.name = 'HttpError'; } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise(resolve => { + if (signal.aborted) return resolve(); + const done = () => { clearTimeout(t); signal.removeEventListener('abort', done); resolve(); }; + const t = setTimeout(done, ms); + signal.addEventListener('abort', done, { once: true }); + }); +} + +async function fetchWithTimeout(url: string, init: RequestInit, ms: number, signal: AbortSignal): Promise { + return fetch(url, { cache: 'no-store', ...init, signal: AbortSignal.any([signal, AbortSignal.timeout(ms)]) }); +} + +const bust = (url: string, tag: string) => `${url}${url.includes('?') ? '&' : '?'}t=${Date.now()}_${tag}`; + +/** Fires HEAD /ping on an interval while a transfer runs; the median rise over idle is bufferbloat. */ +function startLoadedProbe(baseUrl: string, signal: AbortSignal): () => Promise { + const samples: number[] = []; + const stop = new AbortController(); + const both = AbortSignal.any([signal, stop.signal]); + const loop = (async () => { + while (!both.aborted) { + const t0 = performance.now(); + try { + const res = await fetchWithTimeout(bust(`${baseUrl}/ping`, 'load'), { method: 'HEAD' }, PING_TIMEOUT_MS, both); + if (res.ok) samples.push(performance.now() - t0); + } catch { /* a lost probe only lowers the sample count */ } + await sleep(LOADED_PING_INTERVAL_MS, both); + } + })(); + return async () => { stop.abort(); await loop; return samples.length ? median(samples) : undefined; }; +} + +/** + * Runs `streams` parallel request loops for MEASURE_DURATION_MS and returns steady-state Mbps. + * `one` performs a single request and calls `credit(bytes)` as bytes move. + */ +async function runStreams( + signal: AbortSignal, + streams: number, + one: (signal: AbortSignal, credit: (bytes: number) => void) => Promise, + onLive: (mbps: number) => void, +): Promise { + const phase = new AbortController(); + const both = AbortSignal.any([signal, phase.signal]); + const timer = setTimeout(() => phase.abort(), MEASURE_DURATION_MS); + const start = performance.now(); + const samples: ByteSample[] = [{ t: 0, bytes: 0 }]; + let total = 0; + let lastLive = 0; + let lastError: unknown; + + const credit = (bytes: number) => { + if (both.aborted) return; + total += bytes; + const t = performance.now() - start; + samples.push({ t, bytes: total }); + if (t - lastLive >= PROGRESS_EVERY_MS && t > 500) { + lastLive = t; + const span = Math.min(1000, t); + onLive(toMbps(total - bytesAtTime(samples, t - span), span)); + } + }; + + const loop = async () => { + let failures = 0; + while (!both.aborted) { + try { + await one(both, credit); + failures = 0; + } catch (e) { + if (both.aborted) break; + lastError = e; + await sleep(backoffMs(++failures), both); + } + } + }; + + try { + await Promise.all(Array.from({ length: streams }, loop)); + } finally { + clearTimeout(timer); + } + if (signal.aborted) throw new CancelledError(); + if (total === 0) throw lastError instanceof Error ? lastError : new Error('No data reached the server'); + return steadyMbps(samples, Math.min(performance.now() - start, MEASURE_DURATION_MS), WINDOW); +} + +async function measureLatency(baseUrl: string, signal: AbortSignal, report: Reporter) { + const latencies: number[] = []; + for (let i = 0; i < PING_COUNT; i++) { + if (signal.aborted) throw new CancelledError(); + const t0 = performance.now(); + try { + const res = await fetchWithTimeout(bust(`${baseUrl}/ping`, `p${i}`), { method: 'HEAD' }, PING_TIMEOUT_MS, signal); + if (res.ok) { + latencies.push(performance.now() - t0); + report({ phase: 'latency', ping: median(latencies) }); + } + } catch { if (signal.aborted) throw new CancelledError(); } + await sleep(PING_GAP_MS, signal); + } + if (signal.aborted) throw new CancelledError(); + if (latencies.length === 0) throw new Error(`All ${PING_COUNT} pings failed`); + return latencyStats(latencies, PING_COUNT); +} + +/** First request of the test: warms the connection, reads DNS time and server-side connection facts. */ +async function connect(baseUrl: string, signal: AbortSignal): Promise<{ info?: IpInfo; dnsMs?: number }> { + performance.clearResourceTimings(); // DNS must come from this test, not an earlier one (#69) + const url = bust(`${baseUrl}/ip`, 'connect'); + let info: IpInfo | undefined; + try { + const res = await fetchWithTimeout(url, {}, 8000, signal); + if (res.status === 429) throw new HttpError(429); + if (res.ok) info = await res.json() as IpInfo; + } catch (e) { + if (signal.aborted) throw new CancelledError(); + if (e instanceof HttpError) throw e; + // Older self-hosted servers have no /ip; /ping decides whether the server is reachable at all. + const ping = await fetchWithTimeout(bust(`${baseUrl}/ping`, 'connect'), { method: 'HEAD' }, 8000, signal); + if (!ping.ok) throw new HttpError(ping.status); + } + const entry = performance.getEntriesByName(url).at(-1) as PerformanceResourceTiming | undefined; + const dns = entry ? entry.domainLookupEnd - entry.domainLookupStart : 0; + return { info, dnsMs: dns > 0 ? dns : undefined }; +} + +export async function runTest(baseUrl: string, signal: AbortSignal, report: Reporter): Promise { + report({ phase: 'connect', status: 'Connecting' }); + const { info, dnsMs } = await connect(baseUrl, signal); + const results: TestResults = { + serverUrl: baseUrl, + dnsLookupMs: dnsMs, + edge: info?.edge, + isp: info?.isp, + ipv4Ok: info?.family === 'ipv4' || undefined, + ipv6Ok: info?.family === 'ipv6' || undefined, + }; + + report({ phase: 'latency', status: 'Measuring latency' }); + Object.assign(results, await measureLatency(baseUrl, signal, report)); + report({ phase: 'latency', ping: results.ping, jitter: results.jitter, packetLoss: results.packetLoss }); + + report({ phase: 'download', status: 'Testing download' }); + let stopProbe = startLoadedProbe(baseUrl, signal); + try { + results.downloadSpeed = await runStreams(signal, DOWNLOAD_STREAMS, async (s, credit) => { + const res = await fetch(bust(`${baseUrl}${DOWNLOAD_PATH}`, 'd'), { cache: 'no-store', signal: s }); + if (!res.ok || !res.body) throw new HttpError(res.status); + const reader = res.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + credit(value.byteLength); + } + }, mbps => report({ phase: 'download', downloadSpeed: mbps })); + } finally { + results.loadedPingDown = await stopProbe(); + } + report({ phase: 'download', downloadSpeed: results.downloadSpeed }); + + report({ phase: 'upload', status: 'Testing upload' }); + const payload = new Uint8Array(uploadChunkBytes(results.downloadSpeed, UPLOAD_STREAMS)); + stopProbe = startLoadedProbe(baseUrl, signal); + try { + results.uploadSpeed = await runStreams(signal, UPLOAD_STREAMS, async (s, credit) => { + const res = await fetch(bust(`${baseUrl}/upload`, 'u'), { + method: 'POST', cache: 'no-store', signal: s, + headers: { 'Content-Type': 'application/octet-stream' }, body: payload, + }); + if (!res.ok) throw new HttpError(res.status); + await res.arrayBuffer(); + // fetch() has no upload progress, so bytes count when each body completes. + credit(payload.byteLength); + }, mbps => report({ phase: 'upload', uploadSpeed: mbps })); + } finally { + results.loadedPingUp = await stopProbe(); + } + + const loaded = Math.max(results.loadedPingDown ?? -Infinity, results.loadedPingUp ?? -Infinity); + if (Number.isFinite(loaded) && results.ping !== undefined) { + results.bufferbloatDelta = Math.max(0, loaded - results.ping); + results.bufferbloatGrade = gradeBufferbloat(results.bufferbloatDelta); + } + report({ phase: 'done', uploadSpeed: results.uploadSpeed }); + return results; +} diff --git a/apps/extension/src/lib/browser.test.ts b/apps/extension/src/lib/browser.test.ts new file mode 100644 index 0000000..80d3c39 --- /dev/null +++ b/apps/extension/src/lib/browser.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { validateServerUrl, normalizeServerUrl } from './browser'; + +describe('validateServerUrl', () => { + it.each(['https://speed.example.com', 'http://localhost:3000', 'http://127.0.0.1:3000', 'http://nas.local'])('accepts %s', (u) => { + expect(validateServerUrl(u)).toBeNull(); + }); + it.each(['http://speed.example.com', 'ftp://x.test', 'not a url', ''])('rejects %s', (u) => { + expect(validateServerUrl(u)).toEqual(expect.any(String)); + }); +}); + +describe('normalizeServerUrl', () => { + it('drops trailing slashes, query and hash (#68)', () => { + expect(normalizeServerUrl('https://host.test/')).toBe('https://host.test'); + expect(normalizeServerUrl(' https://host.test/meterx//?a=1#x ')).toBe('https://host.test/meterx'); + }); +}); diff --git a/apps/extension/src/lib/browser.ts b/apps/extension/src/lib/browser.ts new file mode 100644 index 0000000..7c059dd --- /dev/null +++ b/apps/extension/src/lib/browser.ts @@ -0,0 +1,54 @@ +/** + * Settings and cross-browser helpers used by the service worker and the pages. + * Chromium (Chrome, Edge, Opera) has chrome.sidePanel; Firefox has sidebarAction. + * @license AGPL-3.0 + */ + +/// + +/** Default backend. Also in static/manifest.json host_permissions; change both together. */ +export { WORKER_URL as DEFAULT_SERVER_URL } from '@meterx/shared'; +import { WORKER_URL as DEFAULT_SERVER_URL } from '@meterx/shared'; + +/** Returns an error message, or null when the URL is usable (matches optional_host_permissions). */ +export function validateServerUrl(raw: string): string | null { + let u: URL; + try { u = new URL(raw.trim()); } catch { return 'That is not a valid URL.'; } + if (u.protocol === 'https:') return null; + if (u.protocol === 'http:') { + const h = u.hostname; + if (h === 'localhost' || h === '127.0.0.1' || h.endsWith('.local')) return null; + return 'Use https:// for remote servers. Plain http:// works only for localhost, 127.0.0.1 and *.local.'; + } + return 'The URL must start with https:// (or http:// for localhost).'; +} + +/** Origin plus any path prefix, without a trailing slash, so `${base}/ping` never becomes `//ping` (#68). */ +export function normalizeServerUrl(raw: string): string { + const u = new URL(raw.trim()); + return (u.origin + u.pathname).replace(/\/+$/, ''); +} + +export async function getServerUrl(): Promise { + try { + const { serverUrl } = await chrome.storage.sync.get('serverUrl'); + if (typeof serverUrl === 'string' && serverUrl && !validateServerUrl(serverUrl)) return normalizeServerUrl(serverUrl); + } catch { /* storage unavailable */ } + return DEFAULT_SERVER_URL; +} + +interface SidebarAction { open(): Promise } +const firefoxSidebar = (): SidebarAction | undefined => + (globalThis as unknown as { browser?: { sidebarAction?: SidebarAction } }).browser?.sidebarAction; + +export function panelSupported(): boolean { + return !!(chrome.sidePanel?.open || firefoxSidebar()); +} + +/** Must run inside a click handler: both browsers require a user gesture. */ +export async function openPanel(): Promise { + const ff = firefoxSidebar(); + if (ff) return ff.open(); + const win = await chrome.windows.getCurrent(); + if (win.id !== undefined) await chrome.sidePanel.open({ windowId: win.id }); +} diff --git a/apps/extension/src/lib/export-data.ts b/apps/extension/src/lib/export-data.ts new file mode 100644 index 0000000..2e0f155 --- /dev/null +++ b/apps/extension/src/lib/export-data.ts @@ -0,0 +1,27 @@ +/** + * CSV and JSON downloads. The files themselves are built by @meterx/reports. + * @license AGPL-3.0 + */ + +import { localDate, type HistoryEntry } from '@meterx/shared'; +import { toCsv, toJson } from '@meterx/reports'; + +export function triggerDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 5000); +} + +export function exportCsv(history: HistoryEntry[]): void { + // The BOM makes Excel read the file as UTF-8. + triggerDownload(new Blob(['\uFEFF', toCsv(history)], { type: 'text/csv;charset=utf-8' }), `meterx-results-${localDate()}.csv`); +} + +export function exportJson(history: HistoryEntry[]): void { + triggerDownload(new Blob([JSON.stringify(toJson(history), null, 2)], { type: 'application/json' }), `meterx-results-${localDate()}.json`); +} diff --git a/apps/extension/src/lib/export-pdf.ts b/apps/extension/src/lib/export-pdf.ts new file mode 100644 index 0000000..4d0161c --- /dev/null +++ b/apps/extension/src/lib/export-pdf.ts @@ -0,0 +1,15 @@ +/** + * PDF download: reads the saved plan, builds the report with @meterx/reports, saves it. + * @license AGPL-3.0 + */ + +import { localDate, type HistoryEntry } from '@meterx/shared'; +import { buildReport } from '@meterx/reports'; +import { loadIspPlan } from './isp'; + +export async function exportPdfReport(hist: HistoryEntry[]): Promise { + if (hist.length === 0) throw new Error('No data to export'); + const plan = await loadIspPlan().catch(() => null); + const doc = await buildReport(hist, plan); + doc.save(`meterx-report-${localDate()}.pdf`); +} diff --git a/apps/extension/src/lib/format.ts b/apps/extension/src/lib/format.ts new file mode 100644 index 0000000..8408679 --- /dev/null +++ b/apps/extension/src/lib/format.ts @@ -0,0 +1,6 @@ +/** + * Formatting lives in @meterx/shared so the reports and the site use the same rules. + * @license AGPL-3.0 + */ + +export { NONE, fmt, fmtSpeed, localDate, getTimeAgo, fmtWhen } from '@meterx/shared'; diff --git a/apps/extension/src/lib/history.ts b/apps/extension/src/lib/history.ts new file mode 100644 index 0000000..3049df1 --- /dev/null +++ b/apps/extension/src/lib/history.ts @@ -0,0 +1,28 @@ +/** + * Test history in chrome.storage.local (newest first). Written only by the service worker. + * @license AGPL-3.0 + */ + +/// + +import type { HistoryEntry } from '@meterx/shared'; + +export const MAX_HISTORY = 200; + +export async function loadHistory(limit = MAX_HISTORY): Promise { + const { history } = await chrome.storage.local.get('history'); + return (Array.isArray(history) ? history as HistoryEntry[] : []).slice(0, limit); +} + +/** Prepends the entry and returns the previous newest entry. */ +export async function saveHistory(entry: HistoryEntry): Promise { + const history = await loadHistory(); + const previous = history[0]; + history.unshift(entry); + await chrome.storage.local.set({ history: history.slice(0, MAX_HISTORY) }); + return previous; +} + +export async function clearHistory(): Promise { + await chrome.storage.local.remove('history'); +} diff --git a/apps/extension/src/lib/isp.ts b/apps/extension/src/lib/isp.ts new file mode 100644 index 0000000..a652254 --- /dev/null +++ b/apps/extension/src/lib/isp.ts @@ -0,0 +1,24 @@ +/** + * The user's advertised ISP plan, in chrome.storage.sync. + * @license AGPL-3.0 + */ + +/// + +import type { IspPlan } from '@meterx/shared'; + +const KEY = 'ispPlan'; + +export async function loadIspPlan(): Promise { + const r = await chrome.storage.sync.get(KEY); + const plan = r[KEY] as IspPlan | undefined; + return plan && (plan.downloadMbps > 0 || plan.uploadMbps > 0) ? plan : null; +} + +export async function saveIspPlan(plan: IspPlan | null): Promise { + if (plan && (plan.downloadMbps > 0 || plan.uploadMbps > 0)) await chrome.storage.sync.set({ [KEY]: plan }); + else await chrome.storage.sync.remove(KEY); +} + + +export { planShare } from '@meterx/shared'; diff --git a/packages/extension/src/quality.test.ts b/apps/extension/src/lib/quality.test.ts similarity index 74% rename from packages/extension/src/quality.test.ts rename to apps/extension/src/lib/quality.test.ts index 41d4ea9..8f688fc 100644 --- a/packages/extension/src/quality.test.ts +++ b/apps/extension/src/lib/quality.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getQuality, getQualityClass, scoreUseCases } from './quality'; +import { getQuality, qualityOf, scoreUseCases } from './quality'; describe('getQuality', () => { it('grades excellent for 100/50/10ms', () => { @@ -19,12 +19,13 @@ describe('getQuality', () => { }); }); -describe('getQualityClass', () => { - it('returns q-fair for undefined inputs', () => { - expect(getQualityClass(undefined, undefined, undefined)).toBe('q-fair'); +describe('qualityOf', () => { + it('returns null when a measurement is missing', () => { + expect(qualityOf({ downloadSpeed: 100, uploadSpeed: undefined, ping: 10 })).toBeNull(); + expect(qualityOf(undefined)).toBeNull(); }); - it('prefixes q-', () => { - expect(getQualityClass(100, 50, 10)).toBe('q-excellent'); + it('grades a complete entry', () => { + expect(qualityOf({ downloadSpeed: 100, uploadSpeed: 50, ping: 10 })?.cls).toBe('excellent'); }); }); diff --git a/apps/extension/src/lib/quality.ts b/apps/extension/src/lib/quality.ts new file mode 100644 index 0000000..1deaab4 --- /dev/null +++ b/apps/extension/src/lib/quality.ts @@ -0,0 +1,16 @@ +/** + * Quality rules live in @meterx/shared so the site uses the same ones; this file adds the toolbar badge colours. + * @license AGPL-3.0 + */ + +import type { Quality } from '@meterx/shared'; + +export { getQuality, qualityOf, scoreUseCases, USE_CASES, type QualityLabel, type UseCaseScore, type UseCaseScores } from '@meterx/shared'; + +/** Toolbar badge colours. Kept in sync with the --q-* tokens in static/styles/tokens.css. */ +export const QUALITY_COLORS: Record = { + excellent: '#0f9f6e', + good: '#2f7de1', + fair: '#c27c0e', + poor: '#d64545', +}; diff --git a/apps/extension/src/lib/tooltips.ts b/apps/extension/src/lib/tooltips.ts new file mode 100644 index 0000000..2553575 --- /dev/null +++ b/apps/extension/src/lib/tooltips.ts @@ -0,0 +1,44 @@ +/** + * Plain-language explanations for metric names, from [data-tip] attributes. + * The text is linked with aria-describedby, so screen readers still read the value first (#73). + * @license AGPL-3.0 + */ + +export const TOOLTIP_COPY: Record = { + download: 'How fast data reaches you. Higher means faster pages and video.', + upload: 'How fast you can send data. Matters for calls and backups.', + ping: 'Time for a round trip to the server when the line is quiet. Under 30 ms feels instant.', + jitter: 'How much ping changes from moment to moment. Low means steady calls.', + 'packet-loss': 'Share of test requests that never came back. Over 1% causes stutters.', + bufferbloat: 'How much ping rises while the line is busy. A is smooth, F means lag during downloads.', +}; + +export function bindTooltips(root: ParentNode): void { + let tip: HTMLElement | null = null; + const hide = () => { if (tip) tip.hidden = true; }; + root.querySelectorAll('[data-tip]').forEach((el, i) => { + const text = TOOLTIP_COPY[el.dataset.tip!]; + if (!text) return; + const desc = document.createElement('span'); + desc.id = `tip-${i}`; + desc.className = 'sr-only'; + desc.textContent = text; + el.append(desc); + el.setAttribute('aria-describedby', desc.id); + if (!el.hasAttribute('tabindex')) el.tabIndex = 0; + const show = () => { + tip ??= Object.assign(document.body.appendChild(document.createElement('div')), { className: 'mx-tooltip', role: 'presentation' }); + tip.textContent = text; + tip.hidden = false; + const r = el.getBoundingClientRect(); + const w = tip.offsetWidth; + tip.style.left = `${Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2))}px`; + tip.style.top = `${r.bottom + 6}px`; + }; + el.addEventListener('mouseenter', show); + el.addEventListener('focus', show); + el.addEventListener('mouseleave', hide); + el.addEventListener('blur', hide); + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') hide(); }); +} diff --git a/apps/extension/src/lib/ui.ts b/apps/extension/src/lib/ui.ts new file mode 100644 index 0000000..6a349c4 --- /dev/null +++ b/apps/extension/src/lib/ui.ts @@ -0,0 +1,132 @@ +/** + * Rendering shared by the popup and the side panel. DOM only, no storage. + * @license AGPL-3.0 + */ + +import type { Grade, HistoryEntry, IspPlan, Phase } from '@meterx/shared'; +import { NONE, fmt } from './format'; +import { USE_CASES, qualityOf, scoreUseCases, type UseCaseScores } from './quality'; + +export { USE_CASES }; + +// Where each phase starts on a progress bar and how long it usually takes. +const PHASE_PLAN: Partial> = { + connect: { from: 0, to: 4, ms: 600 }, + latency: { from: 4, to: 12, ms: 2000 }, + download: { from: 12, to: 56, ms: 8600 }, + upload: { from: 56, to: 100, ms: 8600 }, +}; + +/** Glides a progress fill across a phase's share of the bar. Returns the phase's start percent. */ +export function animatePhase(fill: HTMLElement, p: Phase): number | undefined { + const plan = PHASE_PLAN[p]; + if (!plan) return undefined; + fill.style.transition = 'none'; + fill.style.width = `${plan.from}%`; + void fill.offsetWidth; // restart the transition from this phase's start + fill.style.transition = `width ${plan.ms}ms linear`; + fill.style.width = `${plan.to}%`; + return plan.from; +} + +/** The MeterX mark: a dial with download (blue) and upload arrows crossing into an X. Uses the theme's tokens. */ +export const MARK_SVG = ``; + +const EDGE_NAMES: Record = { + BOM: 'Mumbai', MAA: 'Chennai', DEL: 'Delhi', BLR: 'Bengaluru', HYD: 'Hyderabad', CCU: 'Kolkata', + AMD: 'Ahmedabad', COK: 'Kochi', NAG: 'Nagpur', PNQ: 'Pune', SIN: 'Singapore', HKG: 'Hong Kong', + NRT: 'Tokyo', KIX: 'Osaka', ICN: 'Seoul', SYD: 'Sydney', MEL: 'Melbourne', LHR: 'London', + FRA: 'Frankfurt', CDG: 'Paris', AMS: 'Amsterdam', MAD: 'Madrid', MXP: 'Milan', IAD: 'Washington', + EWR: 'Newark', SJC: 'San Jose', LAX: 'Los Angeles', SEA: 'Seattle', ORD: 'Chicago', DFW: 'Dallas', + ATL: 'Atlanta', MIA: 'Miami', YYZ: 'Toronto', GRU: 'São Paulo', JNB: 'Johannesburg', DXB: 'Dubai', + KUL: 'Kuala Lumpur', BKK: 'Bangkok', CGK: 'Jakarta', MNL: 'Manila', DAC: 'Dhaka', CMB: 'Colombo', KTM: 'Kathmandu', +}; + +export function edgeName(code?: string): string | undefined { + return code ? (EDGE_NAMES[code] ? `${EDGE_NAMES[code]} (${code})` : code) : undefined; +} + +/** "Jio · tested via Mumbai (BOM)" */ +export function scopeLine(e: HistoryEntry | undefined, custom: boolean): string { + const via = custom ? 'your own server' : edgeName(e?.edge) ? `Cloudflare ${edgeName(e?.edge)}` : 'Cloudflare edge'; + return e?.isp ? `${e.isp} · via ${via}` : `Testing via ${via}`; +} + +/** Sets a figure, or a grey dash at the same size when it is missing. */ +export function setFigure(el: HTMLElement, value: string): void { + el.textContent = value; + el.classList.toggle('dash', value === NONE); +} + + +export function useCasesOf(e: HistoryEntry | undefined): UseCaseScores | null { + if (!e || e.downloadSpeed === undefined || e.uploadSpeed === undefined || e.ping === undefined) return null; + return scoreUseCases(e.downloadSpeed, e.uploadSpeed, e.ping, e.jitter, e.packetLoss); +} + +/** Fills a .ready list, keeping every row in place (a dash when there is no result). */ +export function renderReady(list: HTMLElement, e: HistoryEntry | undefined): void { + const s = useCasesOf(e); + list.innerHTML = USE_CASES.map(u => { + const score = s?.[u.key]; + return `
  • ${u.label}${score ? u.words[score] : NONE}
  • `; + }).join(''); +} + +/** The one-line answer under the figure: share of the plan, then what the connection is good for. */ +export function findingFor(e: HistoryEntry | undefined, plan: IspPlan | null): string { + const s = useCasesOf(e); + if (!e || !s) return 'Run a test to see what this connection can handle.'; + const parts: string[] = []; + if (plan && plan.downloadMbps > 0 && e.downloadSpeed !== undefined) { + parts.push(`${Math.round((e.downloadSpeed / plan.downloadMbps) * 100)}% of your ${plan.downloadMbps} Mbps plan.`); + } + const good = USE_CASES.filter(u => s[u.key] === 'good'); + const worst = USE_CASES.find(u => s[u.key] === 'poor') ?? USE_CASES.find(u => s[u.key] === 'okay'); + if (good.length === USE_CASES.length) parts.push('Ready for calls, 4K, gaming and big uploads.'); + else if (good.length && worst) parts.push(`Good for ${good.map(u => u.label.toLowerCase()).join(', ')}; ${worst.label.toLowerCase()}: ${worst.words[s[worst.key]].toLowerCase()}.`); + else if (worst) parts.push(`${worst.label}: ${worst.words[s[worst.key]].toLowerCase()}. Try closer to the router.`); + return parts.join(' '); +} + +export function setQualityPill(el: HTMLElement, e: HistoryEntry | undefined): void { + const q = qualityOf(e); + el.className = `pill ${q ? `q-${q.cls}` : 'is-empty'}`; + el.textContent = q ? q.label : 'Quality'; +} + +/** Idle vs loaded latency rows; bars share one scale so the rise is visible. */ +export function renderLoad(rows: HTMLElement, gradeEl: HTMLElement, e: Partial | undefined): void { + const vals = [e?.ping, e?.loadedPingDown, e?.loadedPingUp]; + const max = Math.max(1, ...vals.filter((v): v is number => v !== undefined)); + rows.querySelectorAll('.load-row').forEach((row, i) => { + const v = vals[i]; + (row.querySelector('.track > i') as HTMLElement).style.width = v === undefined ? '0%' : `${Math.max(2, (v / max) * 100)}%`; + setFigure(row.querySelector('.v') as HTMLElement, v === undefined ? NONE : `${fmt(v, 0)} ms`); + }); + const g = e?.bufferbloatGrade; + gradeEl.className = `pill ${g ? gradeClass(g) : 'is-empty'}`; + gradeEl.textContent = g ? `Grade ${g}` : 'Grade'; +} + +const gradeClass = (g: Grade) => (g === 'A' || g === 'B' ? 's-good' : g === 'C' ? 's-okay' : 's-poor'); + +/** Wires a button to a floating menu: click toggles, Escape and outside clicks close, focus moves in. */ +export function bindMenu(button: HTMLButtonElement, menu: HTMLElement, onPick: (value: string) => void): void { + const close = () => { menu.hidden = true; button.setAttribute('aria-expanded', 'false'); }; + button.addEventListener('click', (ev) => { + ev.stopPropagation(); + const open = menu.hidden; + menu.hidden = !open; + button.setAttribute('aria-expanded', String(open)); + if (open) menu.querySelector('button')?.focus(); + }); + menu.addEventListener('click', (ev) => { + const item = (ev.target as HTMLElement).closest('button[data-value]'); + if (!item) return; + close(); + onPick(item.dataset.value!); + }); + document.addEventListener('click', (ev) => { if (!menu.contains(ev.target as Node)) close(); }); + document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !menu.hidden) { close(); button.focus(); } }); +} diff --git a/packages/extension/src/options.ts b/apps/extension/src/pages/options.ts similarity index 96% rename from packages/extension/src/options.ts rename to apps/extension/src/pages/options.ts index f944963..eddc293 100644 --- a/packages/extension/src/options.ts +++ b/apps/extension/src/pages/options.ts @@ -6,9 +6,9 @@ /// -import type { IspPlan } from '@meterx/shared'; +import { VERSION, WORKER_URL, type IspPlan } from '@meterx/shared'; -const DEFAULT_URL = 'https://meterx-speedtest.meterx-ajithakdev.workers.dev'; +const DEFAULT_URL = WORKER_URL; const ISP_KEY = 'ispPlan'; const SCHEDULE_KEY = 'scheduleMinutes'; const NOTIFY_KEY = 'notifyOnDrop'; @@ -25,6 +25,7 @@ const EDGE_NAMES: Record = { }; document.addEventListener('DOMContentLoaded', async () => { + (document.getElementById('version') as HTMLElement).textContent = VERSION; const statusDot = document.getElementById('statusDot') as HTMLElement; const statusLabel = document.getElementById('statusLabel') as HTMLElement; const statusDetail = document.getElementById('statusDetail') as HTMLElement; @@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', async () => { urlInput.value = currentUrl; (document.querySelector('input[value="custom"]') as HTMLInputElement).checked = true; customSection.classList.remove('hidden'); + customSection.inert = false; } // Highlight selected radio @@ -94,6 +96,7 @@ document.addEventListener('DOMContentLoaded', async () => { radio.addEventListener('change', () => { const isCustom = radio.value === 'custom'; customSection.classList.toggle('hidden', !isCustom); + customSection.inert = !isCustom; updateRadioStyles(); }); }); @@ -160,7 +163,7 @@ document.addEventListener('DOMContentLoaded', async () => { ? `Connected to Cloudflare${edgeName ? ` (${edgeName})` : ''}` : `Connected to custom server`; statusDetail.textContent = isDefault && edgeName - ? `Edge: ${result.edge} — auto-routed to nearest location` + ? `Edge ${result.edge}, routed to the nearest location` : url.replace(/^https?:\/\//, ''); statusPing.textContent = `${result.ping}ms`; } else { @@ -184,7 +187,7 @@ document.addEventListener('DOMContentLoaded', async () => { const result = await testConnection(url); if (result.ok) { - testStatus.textContent = `Connected (${result.ping}ms)${result.edge ? ` — edge: ${result.edge}` : ''}`; + testStatus.textContent = `Connected in ${result.ping} ms${result.edge ? `, edge ${result.edge}` : ''}`; testStatus.className = 'test-status success'; } else { testStatus.textContent = result.error || 'Connection failed'; diff --git a/apps/extension/src/pages/popup.ts b/apps/extension/src/pages/popup.ts new file mode 100644 index 0000000..2bd9777 --- /dev/null +++ b/apps/extension/src/pages/popup.ts @@ -0,0 +1,170 @@ +/** + * Popup: run a test, see the latest result. + * The service worker runs the test and saves history; this page only renders. + * @author ajithakdev (https://github.com/ajithakdev) + * @license AGPL-3.0 + */ + +/// + +import { buildShareUrl, type BroadcastMessage, type HistoryEntry, type Phase, type TestProgress } from '@meterx/shared'; +import { openPanel, panelSupported } from '../lib/browser'; +import { exportCsv, exportJson } from '../lib/export-data'; +import { exportPdfReport } from '../lib/export-pdf'; +import { NONE, fmt, fmtSpeed, getTimeAgo } from '../lib/format'; +import { loadHistory } from '../lib/history'; +import { loadIspPlan } from '../lib/isp'; +import { bindTooltips } from '../lib/tooltips'; +import { MARK_SVG, animatePhase, bindMenu, findingFor, renderLoad, renderReady, scopeLine, setFigure, setQualityPill } from '../lib/ui'; + +const $ = (id: string) => document.getElementById(id) as T; + +const els = { + scope: $('scope'), download: $('download'), upload: $('upload'), ping: $('ping'), jitter: $('jitter'), loss: $('loss'), + quality: $('quality'), finding: $('finding'), grade: $('grade'), loadRows: $('loadRows'), ready: $('ready'), + progress: $('progress'), fill: $('progressFill'), run: $('runBtn'), status: $('status'), +}; + +let running = false; +let phase: Phase | undefined; +let latest: HistoryEntry | undefined; +let customServer = false; + +function setStatus(text: string, error = false): void { + els.status.textContent = text; + els.status.classList.toggle('is-error', error); +} + +function restingStatus(): void { + setStatus(latest ? `Last test ${getTimeAgo(new Date(latest.timestamp))}` : 'No tests yet'); +} + +function updateRunButton(): void { + els.run.textContent = running ? 'Stop test' : 'Run test'; + // Stop stays available; Start needs a connection (#77). + els.run.disabled = !running && !navigator.onLine; +} + +async function renderResult(e: HistoryEntry | undefined): Promise { + setFigure(els.download, fmtSpeed(e?.downloadSpeed)); + setFigure(els.upload, fmtSpeed(e?.uploadSpeed)); + setFigure(els.ping, fmt(e?.ping, 0)); + setFigure(els.jitter, fmt(e?.jitter, 1)); + setFigure(els.loss, fmt(e?.packetLoss, 0)); + setQualityPill(els.quality, e); + renderLoad(els.loadRows, els.grade, e); + renderReady(els.ready, e); + els.finding.textContent = findingFor(e, await loadIspPlan()); + els.scope.textContent = scopeLine(e, customServer); +} + +function setPhase(p: Phase): void { + if (p === phase) return; + phase = p; + const from = animatePhase(els.fill, p); + if (from !== undefined) els.progress.setAttribute('aria-valuenow', String(from)); +} + +function setRunning(on: boolean): void { + running = on; + els.progress.classList.toggle('is-running', on); + if (!on) phase = undefined; + updateRunButton(); +} + +function applyProgress(p: TestProgress): void { + if (p.phase) setPhase(p.phase); + if (p.status) setStatus(`${p.status}…`); + if (p.downloadSpeed !== undefined) setFigure(els.download, fmtSpeed(p.downloadSpeed)); + if (p.uploadSpeed !== undefined) setFigure(els.upload, fmtSpeed(p.uploadSpeed)); + if (p.ping !== undefined) { setFigure(els.ping, fmt(p.ping, 0)); renderLoad(els.loadRows, els.grade, { ping: p.ping }); } + if (p.jitter !== undefined) setFigure(els.jitter, fmt(p.jitter, 1)); + if (p.packetLoss !== undefined) setFigure(els.loss, fmt(p.packetLoss, 0)); +} + +/** Clears figures at the start of a run, keeping every block where it is. */ +function startView(): void { + for (const el of [els.download, els.upload, els.ping, els.jitter, els.loss]) setFigure(el, NONE); + setQualityPill(els.quality, undefined); + renderLoad(els.loadRows, els.grade, undefined); + renderReady(els.ready, undefined); + els.finding.textContent = 'Measuring latency first, then download and upload. About 20 seconds.'; +} + +chrome.runtime.onMessage.addListener((msg: BroadcastMessage) => { + if (msg.action === 'testProgress') { + if (!running) { setRunning(true); startView(); } + applyProgress(msg.data); + } else if (msg.action === 'testComplete') { + setRunning(false); + latest = msg.data; + renderResult(latest); + setStatus('Test complete'); + } else if (msg.action === 'testError') { + setRunning(false); + renderResult(latest); + if (msg.cancelled) restingStatus(); else setStatus(msg.error, true); + } +}); + +document.addEventListener('DOMContentLoaded', async () => { + $('mark').outerHTML = MARK_SVG; + bindTooltips(document); + + customServer = !!(await chrome.storage.sync.get('serverUrl')).serverUrl; + latest = (await loadHistory(1))[0]; + await renderResult(latest); + restingStatus(); + + // A test may already be running (scheduled, shortcut, or started before the popup closed). + const state = await chrome.runtime.sendMessage({ action: 'getState' }).catch(() => null) as { running: boolean; progress: TestProgress } | null; + if (state?.running) { setRunning(true); startView(); applyProgress(state.progress); } + updateRunButton(); + if (!navigator.onLine) setStatus("You're offline", true); + + els.run.addEventListener('click', () => { + if (running) { chrome.runtime.sendMessage({ action: 'cancelTest' }); return; } + setRunning(true); + startView(); + setStatus('Connecting…'); + chrome.runtime.sendMessage({ action: 'startTest' }).catch(() => { + setRunning(false); + setStatus("Couldn't start the test. Reopen MeterX and try again.", true); + }); + }); + + window.addEventListener('online', () => { updateRunButton(); if (!running) restingStatus(); }); + window.addEventListener('offline', () => { updateRunButton(); if (!running) setStatus("You're offline", true); }); + + $('settingsBtn').addEventListener('click', () => chrome.runtime.openOptionsPage()); + $('panelBtn').addEventListener('click', () => { + if (panelSupported()) openPanel().then(() => window.close()).catch(() => chrome.tabs.create({ url: 'sidepanel.html' })); + else chrome.tabs.create({ url: 'sidepanel.html' }); + }); + + $('shareBtn').addEventListener('click', async () => { + if (!latest) { setStatus('Run a test first, then share it'); return; } + const url = buildShareUrl(latest); + try { + await navigator.clipboard.writeText(url); + setStatus('Share link copied'); + } catch { + chrome.tabs.create({ url }); + } + }); + + bindMenu($('exportBtn'), $('exportMenu'), async (kind) => { + const all = await loadHistory(); + if (all.length === 0) { setStatus('Nothing to export yet'); return; } + try { + if (kind === 'pdf') { + setStatus('Building the report…'); + await exportPdfReport(all); + } else if (kind === 'csv') exportCsv(all); + else exportJson(all); + setStatus(`${kind.toUpperCase()} saved`); + } catch (e) { + setStatus(e instanceof Error ? `Export failed: ${e.message}` : 'Export failed', true); + } + }); +}); diff --git a/apps/extension/src/pages/sidepanel.ts b/apps/extension/src/pages/sidepanel.ts new file mode 100644 index 0000000..7e38db6 --- /dev/null +++ b/apps/extension/src/pages/sidepanel.ts @@ -0,0 +1,243 @@ +/** + * Side panel: history, trend and the plan comparison. Also opens as a tab + * on browsers without a side panel API. + * @license AGPL-3.0 + */ + +/// + +import type { BroadcastMessage, HistoryEntry, IspPlan, TestProgress } from '@meterx/shared'; +import { exportCsv, exportJson } from '../lib/export-data'; +import { exportPdfReport } from '../lib/export-pdf'; +import { NONE, fmt, fmtSpeed, fmtWhen } from '../lib/format'; +import { loadHistory } from '../lib/history'; +import { loadIspPlan, planShare } from '../lib/isp'; +import { qualityOf } from '../lib/quality'; +import { bindTooltips } from '../lib/tooltips'; +import { MARK_SVG, animatePhase, bindMenu, scopeLine, setFigure } from '../lib/ui'; +import { median } from '../background/measure'; + +interface ChartsApi { + line(el: HTMLElement, opts: Record): { legend: HTMLElement | null }; + fmt: { dec(d: number): (v: number) => string }; +} +declare const Charts: ChartsApi; + +const $ = (id: string) => document.getElementById(id) as T; + +let days = 7; +let running = false; + +const avg = (a: number[]) => (a.length ? a.reduce((s, v) => s + v, 0) / a.length : undefined); +const values = (rows: HistoryEntry[], k: 'downloadSpeed' | 'uploadSpeed' | 'ping') => + rows.map(r => r[k]).filter((v): v is number => v !== undefined); + +function inRange(all: HistoryEntry[]): HistoryEntry[] { + if (!days) return all; + const since = Date.now() - days * 86_400_000; + return all.filter(e => e.timestamp >= since); +} + +function rangeWords(): string { + return days === 1 ? 'in the last 24 hours' : days ? `in the last ${days} days` : 'in all saved results'; +} + +function finding(rows: HistoryEntry[], plan: IspPlan | null): string { + const dl = values(rows, 'downloadSpeed'); + if (dl.length === 0) return `No tests ${rangeWords()}. Run one to start the history.`; + const mean = avg(dl)!; + const slowest = rows.reduce((a, b) => ((b.downloadSpeed ?? Infinity) < (a.downloadSpeed ?? Infinity) ? b : a)); + let s = `Download averaged ${fmtSpeed(mean)} Mbps over ${dl.length} test${dl.length === 1 ? '' : 's'} ${rangeWords()}`; + const share = plan ? planShare(mean, plan.downloadMbps) : null; + s += share !== null ? `, ${Math.round(share)}% of your ${plan!.downloadMbps} Mbps plan.` : '.'; + if (dl.length > 2 && slowest.downloadSpeed !== undefined && slowest.downloadSpeed < mean * 0.6) { + s += ` Slowest: ${fmtSpeed(slowest.downloadSpeed)} Mbps, ${fmtWhen(slowest.timestamp).toLowerCase()}.`; + } + return s; +} + +function renderChart(rows: HistoryEntry[], plan: IspPlan | null): void { + const chart = $('chart'); + const pts = [...rows].reverse().slice(-120); + const empty = pts.length < 2; + chart.hidden = empty; + $('chartEmpty').hidden = !empty; + $('legend').replaceChildren(); + if (empty) return; + // Times when the points span one day, dates otherwise; both in the browser's own format. + const dayOf = (t: number) => new Date(t).toDateString(); + const short = days === 1 || dayOf(pts[0].timestamp) === dayOf(pts[pts.length - 1].timestamp); + const labels = pts.map(e => { + const d = new Date(e.timestamp); + return short ? d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + : d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); + }); + const { legend } = Charts.line(chart, { + labels, + series: [ + { name: 'Download', values: pts.map(e => e.downloadSpeed ?? null) }, + { name: 'Upload', values: pts.map(e => e.uploadSpeed ?? null) }, + ], + format: (v: number) => `${fmtSpeed(v)} Mbps`, + tickFormat: (v: number) => String(Math.round(v)), + // Shorter chart on short panels so the results list keeps room without the page scrolling. + height: innerHeight < 780 ? 96 : innerHeight < 900 ? 118 : 140, + reference: plan && plan.downloadMbps > 0 ? { value: plan.downloadMbps, label: `Plan ${plan.downloadMbps}` } : undefined, + }); + if (legend) $('legend').append(legend); +} + +function renderPlan(rows: HistoryEntry[], plan: IspPlan | null): void { + $('planEmpty').hidden = !!plan; + $('planSetup').hidden = !!plan; + $('planName').textContent = plan?.providerName ?? ''; + const box = $('planRows'); + box.hidden = !plan; + if (!plan) return; + const items = [ + { cls: 'down', label: 'Download', planned: plan.downloadMbps, got: avg(values(rows, 'downloadSpeed')) }, + { cls: 'up', label: 'Upload', planned: plan.uploadMbps, got: avg(values(rows, 'uploadSpeed')) }, + ].filter(i => i.planned > 0); + box.innerHTML = items.map(i => { + const share = planShare(i.got, i.planned); + const scale = Math.max(i.planned, i.got ?? 0) * 1.05; + const width = i.got === undefined ? 0 : (i.got / scale) * 100; + return `
    +
    ${i.label}${fmtSpeed(i.got)} of ${i.planned} Mbps${share === null ? '' : ` `}
    + +
    `; + }).join(''); +} + +function renderRows(rows: HistoryEntry[]): void { + $('rows').innerHTML = rows.map(e => { + const q = qualityOf(e); + return `${fmtWhen(e.timestamp)}${fmtSpeed(e.downloadSpeed)}` + + `${fmtSpeed(e.uploadSpeed)}${fmt(e.ping, 0)}` + + `${q ? `${q.label}` : `${NONE}`}`; + }).join(''); + $('rowsEmpty').hidden = rows.length > 0; +} + +async function refresh(): Promise { + if (running) return; // the live view owns the summary until the test ends + const [all, plan, { serverUrl }] = await Promise.all([loadHistory(), loadIspPlan(), chrome.storage.sync.get('serverUrl')]); + const rows = inRange(all); + $('scope').textContent = scopeLine(all[0], !!serverUrl); + $('finding').textContent = finding(rows, plan); + setFigure($('avgDown'), fmtSpeed(avg(values(rows, 'downloadSpeed')))); + setFigure($('avgUp'), fmtSpeed(avg(values(rows, 'uploadSpeed')))); + const pings = values(rows, 'ping'); + setFigure($('medPing'), pings.length ? fmt(median(pings), 0) : NONE); + $('count').textContent = String(rows.length); + $('countUnit').textContent = ''; + $('title').textContent = 'Connection history'; + $('lDown').textContent = 'Download, average'; + $('lUp').textContent = 'Upload, average'; + $('lPing').textContent = 'Ping, median'; + $('lCount').textContent = 'Tests'; + renderChart(rows, plan); + renderPlan(rows, plan); + renderRows(rows); +} + +function setStatus(text: string, error = false): void { + $('status').textContent = text; + $('status').classList.toggle('is-error', error); +} + +const PHASE_WORDS: Record = { + connect: 'Connecting to the server…', latency: 'Measuring ping and jitter…', + download: 'Measuring download speed…', upload: 'Measuring upload speed…', +}; +let phase: string | undefined; +let started = 0; +let ticker: number | undefined; + +/** Switches the summary card between range averages and the running test, keeping every block in place. */ +function setRunning(on: boolean): void { + if (on === running) return; + running = on; + document.body.classList.toggle('is-running', on); + $('runBtn').textContent = on ? 'Stop test' : 'Run test'; + clearInterval(ticker); + phase = undefined; + if (on) { + started = Date.now(); + $('title').textContent = 'Testing your connection'; + $('finding').textContent = 'Starting the test. It takes about 20 seconds.'; + $('lDown').textContent = 'Download, now'; + $('lUp').textContent = 'Upload, now'; + $('lPing').textContent = 'Ping'; + $('lCount').textContent = 'Elapsed'; + for (const id of ['avgDown', 'avgUp', 'medPing']) setFigure($(id), NONE); + const tick = () => { $('count').textContent = String(Math.round((Date.now() - started) / 1000)); $('countUnit').textContent = 's'; }; + tick(); + ticker = window.setInterval(tick, 500); + } else { + const fill = $('progressFill'); + fill.style.transition = 'none'; + fill.style.width = '0'; + refresh(); + } +} + +function applyProgress(p: TestProgress): void { + if (p.phase && p.phase !== phase) { + phase = p.phase; + if (PHASE_WORDS[p.phase]) $('finding').textContent = PHASE_WORDS[p.phase]; + const from = animatePhase($('progressFill'), p.phase); + if (from !== undefined) $('progress').setAttribute('aria-valuenow', String(from)); + } + if (p.downloadSpeed !== undefined) setFigure($('avgDown'), fmtSpeed(p.downloadSpeed)); + if (p.uploadSpeed !== undefined) setFigure($('avgUp'), fmtSpeed(p.uploadSpeed)); + if (p.ping !== undefined) setFigure($('medPing'), fmt(p.ping, 0)); +} + +document.addEventListener('DOMContentLoaded', async () => { + $('mark').outerHTML = MARK_SVG; + bindTooltips(document); + await refresh(); + + const state = await chrome.runtime.sendMessage({ action: 'getState' }).catch(() => null) as { running: boolean; progress: TestProgress } | null; + if (state?.running) { setRunning(true); applyProgress(state.progress); } + + $('runBtn').addEventListener('click', () => { + chrome.runtime.sendMessage({ action: running ? 'cancelTest' : 'startTest' }); + if (!running) { setStatus(''); setRunning(true); } + }); + $('settingsBtn').addEventListener('click', () => chrome.runtime.openOptionsPage()); + $('planSetup').addEventListener('click', () => chrome.runtime.openOptionsPage()); + + $('range').addEventListener('click', (ev) => { + const tab = (ev.target as HTMLElement).closest('button[data-days]'); + if (!tab) return; + days = Number(tab.dataset.days); + $('range').querySelectorAll('button').forEach(b => b.setAttribute('aria-selected', String(b === tab))); + refresh(); + }); + + bindMenu($('exportBtn'), $('exportMenu'), async (kind) => { + const all = await loadHistory(); + if (all.length === 0) { setStatus('Nothing to export yet'); return; } + try { + if (kind === 'pdf') { setStatus('Building the report…'); await exportPdfReport(all); } + else if (kind === 'csv') exportCsv(all); + else exportJson(all); + setStatus(`${kind.toUpperCase()} saved`); + } catch (e) { + setStatus(e instanceof Error ? `Export failed: ${e.message}` : 'Export failed', true); + } + }); + + chrome.runtime.onMessage.addListener((msg: BroadcastMessage) => { + if (msg.action === 'testProgress') { setRunning(true); applyProgress(msg.data); } + if (msg.action === 'testComplete') { setRunning(false); setStatus('Test complete'); } + if (msg.action === 'testError') { setRunning(false); setStatus(msg.cancelled ? '' : msg.error, !msg.cancelled); } + }); + let resizing: number | undefined; + window.addEventListener('resize', () => { clearTimeout(resizing); resizing = window.setTimeout(refresh, 150); }); + chrome.storage.onChanged.addListener((changes, area) => { + if ((area === 'local' && 'history' in changes) || (area === 'sync' && ('ispPlan' in changes || 'serverUrl' in changes))) refresh(); + }); +}); diff --git a/apps/extension/static/fonts/Geist-Variable.woff2 b/apps/extension/static/fonts/Geist-Variable.woff2 new file mode 100644 index 0000000..543899b Binary files /dev/null and b/apps/extension/static/fonts/Geist-Variable.woff2 differ diff --git a/apps/extension/static/fonts/OFL.txt b/apps/extension/static/fonts/OFL.txt new file mode 100644 index 0000000..8d003fe --- /dev/null +++ b/apps/extension/static/fonts/OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2023 Vercel, in collaboration with basement.studio + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/extension/static/images/icon-128.png b/apps/extension/static/images/icon-128.png new file mode 100644 index 0000000..6fdb3aa Binary files /dev/null and b/apps/extension/static/images/icon-128.png differ diff --git a/apps/extension/static/images/icon-16.png b/apps/extension/static/images/icon-16.png new file mode 100644 index 0000000..0e9f02b Binary files /dev/null and b/apps/extension/static/images/icon-16.png differ diff --git a/apps/extension/static/images/icon-32.png b/apps/extension/static/images/icon-32.png new file mode 100644 index 0000000..3446f1c Binary files /dev/null and b/apps/extension/static/images/icon-32.png differ diff --git a/apps/extension/static/images/icon-48.png b/apps/extension/static/images/icon-48.png new file mode 100644 index 0000000..1a6b92a Binary files /dev/null and b/apps/extension/static/images/icon-48.png differ diff --git a/packages/extension/static/manifest.json b/apps/extension/static/manifest.json similarity index 74% rename from packages/extension/static/manifest.json rename to apps/extension/static/manifest.json index 9a29ed8..834b872 100644 --- a/packages/extension/static/manifest.json +++ b/apps/extension/static/manifest.json @@ -1,8 +1,7 @@ { "manifest_version": 3, - "name": "MeterX — by ajithakdev", + "name": "MeterX by ajithakdev", "author": "ajithakdev (https://github.com/ajithakdev)", - "version": "2026.3.20.1", "description": "Open-source speed test. Measure download, upload, ping, jitter, packet loss with readiness labels and PDF reports.", "permissions": [ "storage", @@ -23,9 +22,10 @@ "action": { "default_popup": "popup.html", "default_icon": { - "16": "images/icons16.png", - "48": "images/icons48.png", - "128": "images/icons128.png" + "16": "images/icon-16.png", + "32": "images/icon-32.png", + "48": "images/icon-48.png", + "128": "images/icon-128.png" } }, "side_panel": { @@ -35,9 +35,10 @@ "service_worker": "background.js" }, "icons": { - "16": "images/icons16.png", - "48": "images/icons48.png", - "128": "images/icons128.png" + "16": "images/icon-16.png", + "32": "images/icon-32.png", + "48": "images/icon-48.png", + "128": "images/icon-128.png" }, "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" diff --git a/apps/extension/static/options.html b/apps/extension/static/options.html new file mode 100644 index 0000000..c4b5103 --- /dev/null +++ b/apps/extension/static/options.html @@ -0,0 +1,114 @@ + + + + + + MeterX settings + + + + + +
    +
    + +
    +

    MeterX settings

    +
    Server, ISP plan and scheduled tests
    +
    +
    + +
    +
    + +
    +
    + +
    +
    Checking connection...
    +
    +
    +
    +
    + +
    +
    +

    Server

    +
    +
    + + +
    + +
    +
    + +
    +

    Your ISP plan

    Optional
    +
    +

    Enter your advertised speeds to see how much of what you pay for you actually get.

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

    Scheduled tests

    +
    +

    Run a quick check on a timer and get notified if quality drops.

    +
    + + + + +
    + +
    +
    +
    + +
    + +
    + + +
    +
    + + + diff --git a/apps/extension/static/popup.html b/apps/extension/static/popup.html new file mode 100644 index 0000000..3cd737a --- /dev/null +++ b/apps/extension/static/popup.html @@ -0,0 +1,86 @@ + + + + + MeterX + + + + + +
    + +
    +
    MeterX
    +
    Testing via Cloudflare edge
    +
    +
    + + +
    +
    + +
    +
    +
    +

    Download

    + Quality +
    +
    + -Mbps +
    +

    Run a test to see what this connection can handle.

    +
    + +
    +
    Upload
    -Mbps
    +
    Ping
    -ms
    +
    Jitter
    -ms
    +
    Loss
    -%
    +
    + +
    +
    +

    Latency under load

    + Grade +
    +
    +
    Idle-
    +
    Downloading-
    +
    Uploading-
    +
    +
    + +
    +

    Good enough for

    +
      +
      +
      + +
      +
      + +
      + +
      +

      No tests yet

      +
      + + +
      +
      +
      + + + diff --git a/apps/extension/static/sidepanel.html b/apps/extension/static/sidepanel.html new file mode 100644 index 0000000..450bb51 --- /dev/null +++ b/apps/extension/static/sidepanel.html @@ -0,0 +1,96 @@ + + + + + + MeterX history + + + + + +
      +
      + +
      +
      MeterX
      +
      Testing via Cloudflare edge
      +
      + + + +
      + +
      +
      +
      +

      Connection history

      +

      No tests yet. Run one to start the history.

      +
      +
      + + + + +
      +
      + +
      +
      Download, average
      -Mbps
      +
      Upload, average
      -Mbps
      +
      Ping, median
      -ms
      +
      Tests
      0
      +
      + +
      +
      +

      Speed over time

      + +
      +
      +
      + +
      +
      + +
      +
      +

      Against your plan

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

      Results

      +

      + +
      +
      + + + +
      WhenDownUpPingQuality
      + +
      +
      +
      +
      + + + + diff --git a/apps/extension/static/styles/app.css b/apps/extension/static/styles/app.css new file mode 100644 index 0000000..d281ce3 --- /dev/null +++ b/apps/extension/static/styles/app.css @@ -0,0 +1,224 @@ +/* + * MeterX design system (popup, side panel, settings). + * Style: achromatic ledger. The chrome is ink and grey; the one data hue (--data) + * marks download, the main figure; status colour appears only on quality pills. + * Badge colours in src/lib/quality.ts mirror --positive / --warning / --negative. + */ + +@font-face { + font-family: "Geist"; + src: url("../fonts/Geist-Variable.woff2") format("woff2"); + font-weight: 100 900; + font-display: block; +} + +:root { + color-scheme: light dark; + --ground: #f2f4f7; + --surface: #ffffff; + --raised: #eaedf1; + --border: #e1e4e8; + --rule: #eceef1; + --control: #cdd2d8; + --text: #0a0a0a; + --muted: #5b6068; + --data: #1d5fbf; + --data-track: #dbe5f4; + --primary: #141414; + --on-primary: #ffffff; + --positive: #247b50; + --negative: #c4242a; + --warning: #92400e; + --pill-positive-bg: #dcfae5; --pill-positive-ink: #04250e; + --pill-negative-bg: #ffedea; --pill-negative-ink: #831c1e; + --pill-warning-bg: #fff4d6; --pill-warning-ink: #7a3a0b; + --pill-neutral-bg: #eaedf1; --pill-neutral-ink: #3d4148; + --focus: #1d5fbf; + + /* charts.js reads these */ + --chart-1: var(--data); + --chart-2: #8a8f96; + --chart-muted: #8a8f96; + --grid: #e6e8eb; + --axis-ink: #5b6068; + --ink: var(--text); + + --radius-card: 8px; + --radius-control: 6px; + --gap: 12px; + --pad: 16px; + --control-height: 36px; + --fs-figure: 40px; + --fs-kpi: 24px; + --fs-title: 18px; + --fs-section: 14px; + --fs-body: 14px; + --fs-small: 12px; + --font: "Geist", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --ease: cubic-bezier(.2, .7, .2, 1); +} + +@media (prefers-color-scheme: dark) { + :root { + --ground: #0b0c0e; + --surface: #151618; + --raised: #1f2124; + --border: #26292d; + --rule: #1f2124; + --control: #3a3e44; + --text: #f4f5f6; + --muted: #a4a8ae; + --data: #74a6ff; + --data-track: #1b2a44; + --primary: #f4f5f6; + --on-primary: #0b0c0e; + --positive: #4cc38a; + --negative: #f0717a; + --warning: #f0b45a; + --pill-positive-bg: #12291d; --pill-positive-ink: #6fdca5; + --pill-negative-bg: #2e1517; --pill-negative-ink: #ff9aa1; + --pill-warning-bg: #2d2210; --pill-warning-ink: #f4c27a; + --pill-neutral-bg: #1f2124; --pill-neutral-ink: #c9ccd1; + --focus: #74a6ff; + --chart-2: #8d9198; + --chart-muted: #8d9198; + --grid: #26292d; + --axis-ink: #a4a8ae; + } +} + +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + background: var(--ground); + color: var(--text); + font: 400 var(--fs-body)/20px var(--font); + -webkit-font-smoothing: antialiased; +} +h1, h2, h3, p, dl, dd, ul, ol { margin: 0; padding: 0; } +ul, ol { list-style: none; } +a { color: inherit; text-underline-offset: 3px; } +button, input, select { font: inherit; color: inherit; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 4px; } +.num { font-variant-numeric: tabular-nums; } +.muted { color: var(--muted); } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +[hidden] { display: none !important; } + +/* --- Shell bar: mark, name, scope, tools --- */ +.bar { display: flex; align-items: center; gap: 10px; min-height: 52px; } +.mark { width: 24px; height: 24px; flex: none; color: var(--text); } +.who { min-width: 0; flex: 1; } +.who .name { font-weight: 600; font-size: 14px; line-height: 18px; } +.who .scope { color: var(--muted); font-size: var(--fs-small); line-height: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tools { display: flex; gap: 2px; } + +/* --- Controls --- */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + height: var(--control-height); padding: 0 14px; border-radius: var(--radius-control); + border: 1px solid var(--control); background: var(--surface); color: var(--text); + font-weight: 500; cursor: pointer; white-space: nowrap; + transition: background-color 120ms var(--ease), transform 80ms var(--ease), border-color 120ms var(--ease); +} +.btn:hover { background: var(--raised); } +.btn:active { transform: scale(.98); } +.btn:disabled { cursor: not-allowed; color: var(--muted); background: var(--raised); } +.btn-primary { background: var(--primary); border-color: var(--primary); color: var(--on-primary); } +.btn-primary:hover { background: color-mix(in srgb, var(--primary) 86%, var(--ground)); } +.btn-primary:disabled { background: var(--raised); border-color: var(--control); color: var(--muted); } +.btn-text { border-color: transparent; background: transparent; padding: 0 8px; color: var(--muted); } +.btn-text:hover { background: var(--raised); color: var(--text); } +.btn-danger { color: var(--negative); border-color: var(--control); } +.btn-wide { width: 100%; } +.icon-btn { + width: 32px; height: 32px; display: inline-grid; place-items: center; border-radius: var(--radius-control); + border: 0; background: transparent; color: var(--muted); cursor: pointer; + transition: background-color 120ms var(--ease), color 120ms var(--ease); +} +.icon-btn:hover { background: var(--raised); color: var(--text); } +.icon-btn svg { width: 18px; height: 18px; } + +.field { display: grid; gap: 8px; } +.field label, .field .label { font-weight: 500; } +.input { + height: 40px; width: 100%; min-width: 0; padding: 0 12px; border-radius: var(--radius-control); + border: 1px solid var(--control); background: var(--surface); color: var(--text); +} +.input::placeholder { color: var(--muted); } +.input:hover { border-color: var(--muted); } +.input[aria-invalid="true"] { border-color: var(--negative); } + +.choice { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border-radius: var(--radius-control); cursor: pointer; } +.choice:hover { background: var(--raised); } +.choice input { margin: 3px 0 0; accent-color: var(--primary); width: 16px; height: 16px; flex: none; } +.choice .t { font-weight: 500; display: block; } +.choice .d { color: var(--muted); display: block; } + +/* --- Pills (status beside a value only) --- */ +.pill { + display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; + font-size: var(--fs-small); font-weight: 500; white-space: nowrap; + background: var(--pill-neutral-bg); color: var(--pill-neutral-ink); +} +.pill.q-excellent, .pill.q-good, .pill.s-good { background: var(--pill-positive-bg); color: var(--pill-positive-ink); } +.pill.q-fair, .pill.s-okay { background: var(--pill-warning-bg); color: var(--pill-warning-ink); } +.pill.q-poor, .pill.s-poor { background: var(--pill-negative-bg); color: var(--pill-negative-ink); } +.pill.is-empty { visibility: hidden; } + +/* --- Cards and strips --- */ +.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); } +.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 14px var(--pad) 0; } +.card-head h2 { font-size: var(--fs-section); font-weight: 600; line-height: 20px; } +.card-body { padding: 10px var(--pad) 14px; } + +/* One container split by dividers; each cell a label over a figure. */ +.strip { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; } +.strip > div { padding: 12px 14px; min-width: 0; } +.strip > div + div { border-left: 1px solid var(--rule); } +.strip dt { color: var(--muted); font-size: var(--fs-small); line-height: 16px; white-space: nowrap; } +.strip dd { font-size: 20px; line-height: 28px; font-weight: 500; font-variant-numeric: tabular-nums; white-space: nowrap; } +.unit { color: var(--muted); font-size: var(--fs-small); font-weight: 400; margin-left: 2px; } +/* No value yet: a short muted bar sized to the figure instead of a dash character; the unit keeps its space but hides. */ +.dash { display: inline-block; position: relative; width: 1.1em; } +/* Hide the hyphen itself; listed with the figure classes so their colours don't win. */ +.dash, .figure.dash, .strip .lead.dash { color: transparent; } +.dash::after { content: ""; position: absolute; left: 0; right: 0.15em; top: 50%; height: 0.1em; min-height: 2px; margin-top: -0.05em; border-radius: 1px; background: var(--control); } +.dash + .unit { visibility: hidden; } + +/* Latency under load: idle vs loaded, the bufferbloat evidence. */ +.load-rows { display: grid; gap: 8px; } +.load-row { display: grid; grid-template-columns: 92px 1fr 64px; align-items: center; gap: 10px; } +.load-row .k { color: var(--muted); } +.load-row .v { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.track { height: 6px; border-radius: 3px; background: var(--raised); overflow: hidden; } +.track > i { display: block; height: 100%; width: 0; background: var(--chart-2); border-radius: 3px; transition: width 400ms var(--ease); } +.load-row.idle .track > i { background: var(--text); } + +/* Readiness list */ +.ready { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px; } +.ready li { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; } +.ready .k { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* Floating menu (the one place a shadow is allowed) */ +.menu-wrap { position: relative; } +.menu { + position: absolute; z-index: 10; min-width: 200px; padding: 4px; border-radius: var(--radius-card); + background: var(--surface); border: 1px solid var(--border); + box-shadow: 0 8px 24px rgb(0 0 0 / .14); +} +.menu button { display: block; width: 100%; text-align: left; padding: 8px 10px; border: 0; border-radius: var(--radius-control); background: transparent; cursor: pointer; } +.menu button:hover, .menu button:focus-visible { background: var(--raised); } +.menu small { display: block; color: var(--muted); font-size: var(--fs-small); line-height: 16px; } + +/* Tooltip for plain-language metric explanations */ +.mx-tooltip { + position: fixed; z-index: 20; max-width: 220px; padding: 6px 8px; border-radius: var(--radius-control); + background: var(--primary); color: var(--on-primary); font-size: var(--fs-small); line-height: 16px; + pointer-events: none; box-shadow: 0 4px 16px rgb(0 0 0 / .16); +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition-duration: 0ms !important; animation-duration: 0ms !important; } +} diff --git a/apps/extension/static/styles/options.css b/apps/extension/static/styles/options.css new file mode 100644 index 0000000..3b9990f --- /dev/null +++ b/apps/extension/static/styles/options.css @@ -0,0 +1,41 @@ +/* Settings page (opened in a tab). Toggling a choice never moves anything outside it. */ + +.settings .wrap { max-width: 1040px; margin: 0 auto; padding: 4px 16px 32px; } +.settings main { display: grid; gap: var(--gap); } +.settings .bar h1.name { font-size: var(--fs-section); } + +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: var(--gap); align-items: start; } +.hint { margin-bottom: 10px; } +.choices { display: grid; gap: 2px; margin: 0 -12px; } +.choice.selected .t { color: var(--text); } +.choice.check { margin: 8px -12px 0; align-items: center; } +.choice.check input { margin-top: 0; } + +/* Custom URL stays in the layout when off (dimmed and inert) so the cards below never jump. */ +.custom-url { margin-top: 10px; transition: opacity 120ms var(--ease); } +.custom-url.hidden { opacity: .45; } +.input-row { display: flex; gap: 8px; } +.input-row .btn { height: 40px; } +.test-status { min-height: 20px; margin-top: 6px; font-size: var(--fs-small); line-height: 20px; color: var(--muted); } +.test-status.success { color: var(--positive); } +.test-status.error { color: var(--negative); } + +.isp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.isp-grid .wide { grid-column: 1 / -1; } +.row-end { display: flex; justify-content: flex-end; margin-top: 8px; } + +.status-card { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad); } +.status-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--muted); } +.status-dot.online { background: var(--positive); } +.status-dot.offline { background: var(--negative); } +.status-dot.checking { background: var(--warning); animation: mx-pulse 1s ease-in-out infinite; } +.status-info { flex: 1; min-width: 0; } +.status-label, .status-detail { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.status-label { font-weight: 500; } +.status-detail { font-size: var(--fs-small); line-height: 16px; min-height: 16px; } +.status-ping { color: var(--muted); } +@keyframes mx-pulse { 50% { opacity: .35; } } + +.save-row { display: flex; justify-content: flex-end; } +.save-row .btn { min-width: 96px; } +.foot { display: grid; gap: 2px; font-size: var(--fs-small); line-height: 16px; margin-top: 8px; } diff --git a/apps/extension/static/styles/panel.css b/apps/extension/static/styles/panel.css new file mode 100644 index 0000000..433bf9c --- /dev/null +++ b/apps/extension/static/styles/panel.css @@ -0,0 +1,106 @@ +/* Side panel (usually 320-480px) and the same page opened in a tab. */ + +.panel .wrap { max-width: 880px; margin: 0 auto; padding: 4px 16px 24px; } +.panel .bar { position: sticky; top: 0; z-index: 5; background: var(--ground); } +.panel .bar .btn { height: 32px; } +.panel main { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--gap); } + +.title-row { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px; margin-top: 8px; } +.title-row > div { flex: 1 1 240px; min-width: 0; } +.title-row h1 { font-size: var(--fs-title); line-height: 26px; font-weight: 600; letter-spacing: -0.01em; } +.finding { margin-top: 4px; min-height: 40px; max-width: 60ch; } + +.tabs { display: flex; gap: 2px; padding: 2px; border-radius: var(--radius-control); background: var(--raised); } +.tabs button { + height: 28px; padding: 0 10px; border: 0; border-radius: 4px; background: transparent; color: var(--muted); + font-size: var(--fs-small); font-weight: 500; cursor: pointer; white-space: nowrap; +} +.tabs button:hover { color: var(--text); } +.tabs button[aria-selected="true"] { background: var(--surface); color: var(--text); } + +.strip { grid-auto-flow: row; grid-template-columns: repeat(4, 1fr); } +.strip dd { font-size: 20px; } +.strip .lead { font-size: 28px; line-height: 32px; color: var(--data); letter-spacing: -0.01em; } +.strip dd { display: flex; align-items: baseline; min-height: 32px; } +@media (max-width: 600px) { + .strip { grid-template-columns: 1fr 1fr; } + .strip > div:nth-child(3) { border-left: 0; } + .strip > div:nth-child(n + 3) { border-top: 1px solid var(--rule); } +} + +.legend { display: flex; gap: 12px; color: var(--muted); font-size: var(--fs-small); } +.legend > * { display: inline-flex; align-items: center; gap: 6px; } +.chart { min-height: 96px; } +.empty { + display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; + min-height: 40px; padding: 8px 12px; border: 1px dashed var(--control); border-radius: var(--radius-card); color: var(--muted); +} +#chartEmpty { min-height: 96px; justify-content: center; text-align: center; } +.link { color: var(--text); text-decoration: underline; } + +.plan-rows { display: grid; gap: 10px; } +.plan-row .top { display: flex; justify-content: space-between; gap: 8px; } +.plan-row .top b { font-weight: 500; font-variant-numeric: tabular-nums; } +.plan-row .bar-track { position: relative; height: 8px; margin-top: 6px; border-radius: 4px; background: var(--raised); } +.plan-row .bar-track i { position: absolute; inset: 0 auto 0 0; border-radius: 4px; background: var(--text); } +.plan-row.down .bar-track i { background: var(--data); } +.plan-row .bar-track .target { position: absolute; top: -3px; bottom: -3px; width: 2px; background: var(--text); } +.plan-row .share { margin-left: 6px; } +.plan-row .share.is-low { color: var(--negative); } + +.results { width: 100%; border-collapse: collapse; } +.results th { text-align: left; color: var(--muted); font-size: var(--fs-small); font-weight: 400; padding: 0 0 6px; } +.results td { padding: 9px 0; border-top: 1px solid var(--rule); white-space: nowrap; } +.results th + th, .results td + td { padding-left: 10px; } +.results .num { text-align: right; font-variant-numeric: tabular-nums; } +/* Equal number columns so the gaps between Down, Up and Ping match; When takes the rest. */ +.results th.num, .results td.num { width: 3.6em; } +.results td.when { width: 100%; } +.results td.when { color: var(--muted); } +.results td.down { color: var(--data); font-weight: 500; } +/* Status sits in the Results header, between the title and Export, so it never needs its own row. */ +.card-head .status { flex: 1; min-width: 0; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font-size: var(--fs-small); } +.status.is-error { color: var(--negative); } +.card-head .menu { right: 0; top: calc(100% + 4px); } + +/* Narrowest panel (320px): tighter columns, and the table scrolls inside its card, never the page. */ +.results-body { overflow-x: auto; } +@media (max-width: 360px) { + .results th + th, .results td + td { padding-left: 6px; } + .results td.when { font-size: var(--fs-small); } +} + +/* Fit the window: everything stays in place and only the results list scrolls, under pinned column names. + Short windows (under 740px) fall back to scrolling the whole page. */ +@media (min-height: 740px) { + .panel .wrap { height: 100vh; display: flex; flex-direction: column; padding-bottom: 12px; } + .panel main { flex: 1; min-height: 0; gap: 10px; grid-template-rows: auto auto auto auto minmax(132px, 1fr); } + .results-card { display: flex; flex-direction: column; min-height: 0; } + .results-body { flex: 1; min-height: 0; overflow-y: auto; padding-top: 0; margin-top: 10px; } + /* No padding above the pinned names, or rows show through the gap while scrolling. */ + .panel .card-body.results-body { padding-top: 0; } + .results thead th { position: sticky; top: 0; z-index: 1; background: var(--surface); padding-top: 4px; box-shadow: 0 1px 0 var(--rule); } +} + +/* Thin scrollbars in the theme's colours instead of the platform's grey bars. */ +* { scrollbar-width: thin; scrollbar-color: var(--control) transparent; } + +/* Compact cells so the results list keeps room in a typical side panel. */ +.panel .strip > div { padding: 8px 12px; } +.panel .card-head .btn { height: 28px; margin: -4px -8px -4px 0; } + +/* Live test: the header gets a progress line and the summary card shows the running figures in place. */ +.panel .bar { position: sticky; } +.live-track { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: var(--raised); visibility: hidden; overflow: hidden; } +.live-track i { display: block; height: 100%; width: 0; background: var(--data); } +.panel.is-running .live-track { visibility: visible; } +.panel.is-running .strip { border-color: var(--data); } + +/* Short panels: tighter cards so the results list keeps a few rows in view. */ +@media (max-height: 900px) { + .panel main { gap: 8px; } + .panel .card-head { padding-top: 10px; } + .panel .card-body { padding: 8px var(--pad) 10px; } + .panel .strip .lead { font-size: 24px; line-height: 28px; } + .panel .strip dd { min-height: 28px; } +} diff --git a/apps/extension/static/styles/popup.css b/apps/extension/static/styles/popup.css new file mode 100644 index 0000000..a35e01a --- /dev/null +++ b/apps/extension/static/styles/popup.css @@ -0,0 +1,52 @@ +/* Popup: 360px wide. Every block keeps its size in every state (no layout shift). */ + +body.popup { + width: 360px; + padding: 4px 12px 12px; +} +.popup main { display: grid; gap: 8px; } + +.answer { padding: 14px var(--pad) 14px; } +.answer-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; height: 22px; } +.answer .label { font-size: var(--fs-section); font-weight: 500; line-height: 20px; color: var(--muted); } +.figure-row { display: flex; align-items: baseline; gap: 4px; height: 48px; margin-top: 4px; } +.figure { + font-size: var(--fs-figure); line-height: 48px; font-weight: 500; letter-spacing: -0.02em; + color: var(--data); +} +.figure-row .unit { font-size: var(--fs-body); } +/* Two lines reserved so a longer finding never pushes the cards down. */ +.finding { margin-top: 6px; min-height: 40px; color: var(--text); } + +/* Upload carries the longest figure ("999.9 Mbps"), loss the shortest. */ +.strip { grid-template-columns: 1.3fr 1fr 1fr .85fr; grid-auto-flow: row; } +.strip > div { padding: 10px; } +.strip dd { font-size: 18px; } +.strip .unit { margin-left: 1px; } + + +.actions { display: grid; gap: 8px; margin-top: 10px; } + +/* Thin track above the button; the status line below names the phase. */ +.progress { height: 3px; } +.progress-track { display: block; height: 3px; border-radius: 2px; background: var(--raised); overflow: hidden; } +.progress-track i { display: block; height: 100%; width: 0; background: var(--data); transform-origin: left; } +.progress:not(.is-running) .progress-track { visibility: hidden; } + +.foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; height: 32px; } +.status { color: var(--muted); font-size: var(--fs-small); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.status.is-error { color: var(--negative); } +.foot-tools { display: flex; flex: none; } +.foot-tools .btn { height: 32px; } +.foot .menu { right: 0; bottom: calc(100% + 6px); } + +/* Chrome caps popups at 600px tall; these keep the whole popup inside it with no scrollbar. */ +.popup .bar { min-height: 44px; } +.popup main { gap: 6px; } +.popup .card-head { padding-top: 10px; } +.popup .card-body { padding: 8px var(--pad) 10px; } +.popup .load-rows { gap: 4px; } +.popup .ready { gap: 4px 12px; } +.popup .answer { padding: 12px var(--pad); } +.popup .actions { margin-top: 6px; gap: 6px; } +body.popup { padding-bottom: 6px; } diff --git a/apps/extension/static/vendor/charts.js b/apps/extension/static/vendor/charts.js new file mode 100644 index 0000000..56bc1e4 --- /dev/null +++ b/apps/extension/static/vendor/charts.js @@ -0,0 +1,618 @@ +// Vendored from tasteskill (MIT, Copyright (c) 2026 Leonxlnx), https://github.com/Leonxlnx/taste-skill +// tasteskill / charts.js: small, dependency-free charts for app screens, drawn in SVG from your data. +// They follow references/app-screens.md: one question per chart, one series that matters, 3 to 5 quiet +// horizontal gridlines, round ticks that top out above the largest value, 2px straight lines with no +// markers, square bars, no gradients, a bare tooltip, tabular figures. Colours come from CSS variables: +// --chart-1 … --chart-4 (series), --chart-muted (other series), --chart-mark (a marked period; ink by default), +// --chart-span (the opacity of a shaded span: about .06 on light, .12 on dark), --chart-track (a time strip's track), +// --grid, --axis-ink, --ink, --surface, --raised, --rule, --negative +// Usage (after this file loads): +// Charts.line(el, { labels: ['1 Oct', …], series: [{ name: 'Requests', values: [...] }], format: Charts.fmt.compact }) +// Charts.bars(el, { labels, values, highlight: 6, format }) one bar (or [indices]) may carry the accent; dim: [indices] +// Charts.diverging(el, { labels, up, down, above: 'Sold', below: 'Bought', mark: [3, 9], markLabel: 'Car charged' }) +// Charts.ring(el, { value: 0.72, label: '72%', sub: 'of capacity' }) a progress ring with its total inside +// Charts.barList(el, [{ label: 'Berlin', value: 412 }, …], { format, thin }) a ranked list where the row is the bar +// Charts.plan(el, [{ label: 'Groceries', planned: 600, spent: 412, committed: 60 }, …], { scale: 'row' | 'plan' }) planned against spent +// Charts.bullet(el, [{ label: 'Bonds', value: 0.31, target: 0.25 }, …]) share against a target +// Charts.timeline(el, rows, kinds, { days, today, weekends }) who is away when, each kind of booking its own mark +// Charts.matrix(el, rows, { cols, comfort: 2, today }) slack against a minimum, team by day +// Charts.timeStrip(el, rows, { open: '08:00', close: '15:00', compareLabel: 'Last Sunday' }) when, within the day +// line, bars and diverging take annotations: [{ at: index, label: 'Deploy a3f09c1', short: 'a3f09c1' }] or spans +// [{ from, to, label: 'Cloud' }]. Their labels sit in rows above the plot, measured once the fonts have loaded, and no +// leader line crosses another label; the rows are reserved only when a label exists. reference: { value, label }, its +// label set where no series passes. +// line takes step: true for a balance that only changes on given days; today: index (the forecast after it is dashed) +// with todayLabel: 'Now 20:34' (set clear of the line); endLabel: true (the last value at the line's end); max: caps a +// flat series with one spike (the peak is labelled); yTicks: fixed ticks for a bounded quantity (0 to 100%); tickFormat: +// axis labels apart from the tooltip format; xAt: the indices to label. Without xAt the x labels keep one step. +// diverging, timeline, matrix and timeStrip return { svg, legend }, and line returns { legend } for two series or more: +// the legend is drawn from the same style objects as the marks, so a swatch is always the mark scaled down. Put it in +// the card's header, or pass legend: element (or a selector) to have it filled. +// Numbers follow the page's language (). Every chart gets role="img" and an aria-label that states what it shows. +(() => { + const NS = 'http://www.w3.org/2000/svg', MINUS = '\u2212', ROW = 14, LANE = (rows) => 8 + rows * ROW + (rows ? 4 : 0); + const css = (el, name, fb) => getComputedStyle(el).getPropertyValue(name).trim() || fb; + const cssNum = (el, name, fb) => { const v = parseFloat(css(el, name, '')); return Number.isFinite(v) ? v : fb; }; + const svg = (tag, attrs = {}, parent) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, v); if (parent) parent.appendChild(n); return n; }; + const text = (parent, str, attrs) => { const t = svg('text', attrs, parent); t.textContent = str; return t; }; + const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; }; + // numbers follow the page's language (), so separators match the rest of the screen + const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o); + const fmt = { + int: (v) => nf({ maximumFractionDigits: 0 }).format(v), + dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).format(v), + pct: (d = 1) => (v) => nf({ style: 'percent', minimumFractionDigits: d, maximumFractionDigits: d }).format(v), + money: (cur = 'EUR', d = 0) => (v) => nf({ style: 'currency', currency: cur, minimumFractionDigits: d, maximumFractionDigits: d }).format(v), + compact: (v) => nf({ notation: 'compact', maximumFractionDigits: 1 }).format(v), + ms: (v) => `${nf({ maximumFractionDigits: 0 }).format(v)} ms`, + // a sign on every change, with a true minus: fmt.signed() → '+2', '0', '−1'; fmt.signed(fmt.dec(1)) → '+0.4' + signed: (f = (v) => nf({ maximumFractionDigits: 0 }).format(v)) => (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + f(Math.abs(v)), + // minutes since midnight as a 24-hour time: 680 → '11:20' + clock: (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(Math.round(m % 60)).padStart(2, '0')}`, // taste-skill-ignore: a clock, not a counter + }; + const minutes = (t) => (t == null || t === '' ? null : typeof t === 'number' ? t : (([hh, mm]) => hh * 60 + (mm || 0))(String(t).split(':').map(Number))); + // Round ticks (steps of 1, 2, 2.5 or 5 × 10^n) whose top sits above the largest value. + function ticks(min, max, count = 4) { + if (max === min) max = min + 1; + const raw = (max - min) / count, mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || 10 * mag; + const lo = Math.floor(min / step) * step; let hi = Math.ceil(max / step) * step; if (hi <= max) hi += step; + const out = []; for (let v = lo; v <= hi + step / 2; v += step) out.push(+v.toFixed(10)); + return out; + } + // x labels keep one step all the way: every k-th label, with k chosen so the labels fit the width, and a step that + // lands on the last label when one gives enough labels. Never a forced end label that breaks the step. Clock labels + // ('08:30') step in whole hours (or halves and quarters) from a round time, so a half-hourly axis reads 06:00, 09:00. + function xPicks(labels, span, at) { + const n = labels.length; + if (at) return at.filter((i) => i >= 0 && i < n); + if (n <= 1) return n ? [0] : []; + const longest = Math.max(...labels.map((l) => String(l).length)); + const cap = Math.max(2, Math.min(8, Math.floor(span / (longest * 7 + 18)))); + if (n <= cap) return [...Array(n).keys()]; + const mins = labels.every((l) => /^\d{1,2}:\d{2}$/.test(String(l))) ? labels.map(minutes) : null; + const d = mins ? mins[1] - mins[0] : 0; + let first = null, land = null; + for (let k = 1; k < n; k++) { + const stepM = k * d, clock = mins && d > 0; + if (clock && (stepM >= 60 ? ![1, 2, 3, 4, 6, 12].includes(stepM / 60) : 60 % stepM !== 0)) continue; + // start on a round time: a multiple of the step (09:00 for three hours), or a whole hour for shorter steps + let s = clock ? mins.findIndex((m) => m % Math.max(60, stepM) === 0) : 0; + if (s < 0) s = Math.max(0, mins.findIndex((m) => m % 60 === 0)); + const count = Math.floor((n - 1 - s) / k) + 1; + if (count > cap) continue; + if (first == null) first = { k, s }; + if ((n - 1 - s) % k === 0) { land = { k, s, count }; break; } + } + const pick = land && land.count >= Math.max(3, Math.ceil(cap * 0.6)) ? land : first || { k: n - 1, s: 0 }; + const out = []; for (let i = pick.s; i < n; i += pick.k) out.push(i); + return out; + } + // colours: tokens resolved to rgb, so a mark can be mixed with the surface (a pale fill, a darker step) + const swatchProbe = document.createElement('span'); swatchProbe.style.display = 'none'; + const rgb = (el, value) => { + el.appendChild(swatchProbe); swatchProbe.style.color = ''; swatchProbe.style.color = value; + const c = getComputedStyle(swatchProbe).color; swatchProbe.remove(); + const m = c.match(/-?[\d.]+/g); if (!m || m.length < 3) return null; + return c.startsWith('color(') ? m.slice(0, 3).map((v) => Math.round(+v * 255)) : m.slice(0, 3).map(Number); + }; + const mix = (el, a, b, t) => { const p = rgb(el, a), q = rgb(el, b); return p && q ? `rgb(${p.map((v, i) => Math.round(v * t + q[i] * (1 - t))).join(',')})` : a; }; + // text width, measured in the chart's own face (falls back to an estimate when the chart is hidden) + const measure = (s, str, size = 11, weight = 400) => { const t = text(s, str, { x: -999, y: -999, 'font-size': size, 'font-weight': weight, visibility: 'hidden' }); let wd = 0; try { wd = t.getComputedTextLength(); } catch {} t.remove(); return wd > 0 ? wd : String(str).length * size * 0.56; }; + const fit = (s, str, max, size, weight) => { str = String(str); if (measure(s, str, size, weight) <= max) return str; while (str.length > 1 && measure(s, str + '\u2026', size, weight) > max) str = str.slice(0, -1); return str.trimEnd() + '\u2026'; }; + function frame(el, h) { + el.innerHTML = ''; + const w = Math.max(240, el.clientWidth || 600); + const s = svg('svg', { viewBox: `0 0 ${w} ${h}`, width: '100%', height: h, role: 'img', style: 'display:block;overflow:visible;font-variant-numeric:tabular-nums' }, el); + return { s, w, h }; + } + function tooltip(el) { + let t = el.querySelector('.chart-tip'); + if (!t) { t = document.createElement('div'); t.className = 'chart-tip'; t.style.cssText = 'position:absolute;pointer-events:none;padding:6px 8px;font:inherit;font-size:12px;line-height:1.35;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity 120ms ease;background:var(--surface,#fff);color:var(--ink,#111);border:1px solid var(--grid,#e5e7eb)'; if (getComputedStyle(el).position === 'static') el.style.position = 'relative'; el.appendChild(t); } + return t; + } + function axes(g, el, { w, h, padL, padB, yt, y, labels, x, band = false, at = null, tick, avoid = null }) { + const grid = css(el, '--grid', '#e8e8e8'), ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement; + for (const v of yt) { + const yy = y(v); + svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: grid, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); + text(g, tick(v), { x: padL - 8, y: yy + 4, 'text-anchor': 'end', 'font-size': 12, fill: ink }); + } + const n = labels.length; + for (const i of xPicks(labels, w - padL, at)) { + const lab = String(labels[i]), tw = measure(s, lab, 12), xx = x(i); + // the first and last labels are pulled inside the plot; none runs past either edge + let left = band ? xx - tw / 2 : i === 0 ? xx : i === n - 1 ? xx - tw : xx - tw / 2; + left = Math.max(band ? 0 : padL, Math.min(w - tw, left)); + if (avoid && left < avoid[1] + 8 && left + tw > avoid[0] - 8) continue; + text(g, lab, { x: left, y: h - padB + 18, 'font-size': 12, fill: ink }); + } + } + // Annotation labels sit in rows above the plot, never on the data. A label sits beside the top of its own leader + // line, right of it or left of it, in one of three rows. A place is free when no other label in that row overlaps + // it, no leader line passes through it, and its own leader passes through no label below it. The layout is searched + // (close markers often need the first label on the left so the next leader runs clear): full labels first, then + // short forms (`short`), and only then is a label dropped to its marker's tooltip, with a note in the console. + function lane(s, items, w, maxRows = 3) { + const gap = 6, rank = (it) => (it.fixed != null ? 0 : it.span ? 1 : 2); + // fixed captions first, then spans, then markers from left to right + const order = [...items].sort((a, b) => rank(a) - rank(b) || (a.x ?? a.x0) - (b.x ?? b.x0)); + const opts = order.map((it) => { + const out = []; + for (const str of [it.label, it.short].filter(Boolean)) { + const tw = measure(s, str, 11) + (it.glyph ? 9 : 0), mid = it.span ? (it.x0 + it.x1) / 2 : 0; + const cands = it.fixed != null ? [[it.fixed, it.fixed + tw]] : it.span ? [[mid - tw / 2, mid + tw / 2], [it.x0, it.x0 + tw], [it.x1 - tw, it.x1]] : [[it.x + 4, it.x + 4 + tw], [it.x - 4 - tw, it.x - 4]]; + for (let row = 0; row < maxRows; row++) for (const [x0, x1] of cands) if (x0 >= 0 && x1 <= w) out.push({ ...it, text: str, isShort: str !== it.label, row, x0, x1 }); + } + return out; + }); + const free = (c, placed) => placed.every((p) => p.dropped || (!(p.row === c.row && c.x0 < p.x1 + gap && c.x1 > p.x0 - gap) + && !(p.leader != null && p.row >= c.row && p.leader > c.x0 - 3 && p.leader < c.x1 + 3) + && !(c.leader != null && p.row <= c.row && c.leader > p.x0 - 3 && c.leader < p.x1 + 3))); + let best = null, steps = 0; + const solve = (i, placed, allowShort, allowDrop) => { + if (++steps > 20000) return false; + if (i === order.length) { best = placed.slice(); return true; } + for (const c of opts[i]) { if ((c.isShort && !allowShort) || !free(c, placed)) continue; placed.push(c); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); } + if (allowDrop) { placed.push({ ...order[i], dropped: true }); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); } + return false; + }; + for (const [sh, dr] of [[false, false], [true, false], [true, true]]) { steps = 0; if (solve(0, [], sh, dr)) break; } + const placed = (best || []).filter((p) => !p.dropped), dropped = (best || order.map((it) => ({ ...it, dropped: true }))).filter((p) => p.dropped); + return { placed, dropped, rows: placed.length ? Math.max(...placed.map((p) => p.row)) + 1 : 0 }; + } + function prepNotes(s, annotations = [], x, w, extra = []) { + const items = []; + for (const a of annotations || []) { + if (a.from != null) items.push({ span: true, x0: Math.min(x(a.from), x(a.to)), x1: Math.max(x(a.from), x(a.to)), label: a.label, short: a.short, colour: a.colour }); + else if (a.at != null) items.push({ x: x(a.at), at: a.at, leader: x(a.at), label: a.label, short: a.short, colour: a.colour }); + } + items.push(...extra); + return { items, ...lane(s, items.filter((it) => it.label), w) }; + } + // shaded spans go under the data; marker lines and the label rows over it + function notesUnder(g, el, N, { padT, h, padB }) { + const ink = css(el, '--axis-ink', '#6b6b6b'), op = cssNum(el, '--chart-span', 0.07); + for (const it of N.items) if (it.span) svg('rect', { x: it.x0, y: padT, width: it.x1 - it.x0, height: h - padT - padB, fill: ink, opacity: op }, g); + } + function notesOver(g, el, N, { padT, h, padB }) { + const ink = css(el, '--axis-ink', '#6b6b6b'); + // a marker line runs to the plot's foot, or stops above the bar it points at (it.end) + for (const it of N.items) if (it.leader != null && !it.drawn) svg('line', { x1: it.leader, x2: it.leader, y1: padT, y2: Math.max(padT, it.end ?? h - padB), stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g); + for (const p of N.placed) { + const base = padT - 9 - p.row * ROW; + if (p.leader != null) svg('line', { x1: p.leader, x2: p.leader, y1: base - 8, y2: padT, stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g); + if (p.glyph) svg('path', { d: p.glyph === 'up' ? `M${p.x0} ${base - 1}l3 -6l3 6z` : `M${p.x0} ${base - 7}l3 6l3 -6z`, fill: ink }, g); + text(g, p.text, { x: p.x0 + (p.glyph ? 9 : 0), y: base, 'font-size': 11, fill: p.colour || ink }); + } + const el2 = g.ownerSVGElement.parentNode; el2._warned = el2._warned || new Set(); + for (const d of N.dropped) { + if (d.leader != null) { const hit = svg('line', { x1: d.leader, x2: d.leader, y1: padT, y2: h - padB, stroke: 'transparent', 'stroke-width': 8 }, g); titled(hit, d.label, {}); } + if (!el2._warned.has(d.label)) { el2._warned.add(d.label); console.warn(`Charts: no room above the plot for "${d.label}". Give it a short form (short) or label fewer markers.`); } + } + } + // does a label box touch a drawn line? polylines are [[x, y], …]; a box is { x0, x1, y0, y1 } + function hits(b, lines) { + for (const pts of lines) for (let k = 1; k < pts.length; k++) { + const a = pts[k - 1], c = pts[k]; if (!a || !c) continue; + if (a[0] === c[0]) { if (a[0] >= b.x0 - 3 && a[0] <= b.x1 + 3 && Math.max(a[1], c[1]) >= b.y0 - 3 && Math.min(a[1], c[1]) <= b.y1 + 3) return true; continue; } + const lo = Math.max(b.x0 - 3, Math.min(a[0], c[0])), hi = Math.min(b.x1 + 3, Math.max(a[0], c[0])); + for (let xx = lo; xx <= hi; xx += 2) { const yy = a[1] + ((xx - a[0]) / (c[0] - a[0])) * (c[1] - a[1]); if (yy >= b.y0 - 3 && yy <= b.y1 + 3) return true; } + } + return false; + } + const boxHits = (b, boxes) => boxes.some((r) => b.x0 < r.x1 + 3 && b.x1 > r.x0 - 3 && b.y0 < r.y1 + 3 && b.y1 > r.y0 - 3); + // a reference line (a budget, an SLO) with its label at an end where no series passes + function references(g, el, refs, { y, padL, w, lines = [], boxes = [] }) { + const ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement; + for (const r of [].concat(refs || [])) { + const yy = y(r.value); + svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: ink, 'stroke-width': 1, 'stroke-dasharray': '4 4', opacity: 0.7 }, g); + if (!r.label) continue; + const tw = measure(s, r.label, 11), right = r.at !== 'left'; + const cand = [[right, -5], [right, 13], [!right, -5], [!right, 13]].map(([rt, dy]) => ({ x0: rt ? w - tw : padL + 4, x1: rt ? w : padL + 4 + tw, y0: yy + dy - 9, y1: yy + dy + 2, base: yy + dy })); + const b = cand.find((c) => !hits(c, lines) && !boxHits(c, boxes)) || cand[0]; + text(g, r.label, { x: b.x0, y: b.base, 'font-size': 11, fill: ink }); + } + } + // One style object per kind of mark draws both the mark and its legend swatch, so the two cannot drift apart. + function paintRect(parent, st, x, y, w, h, r = 0) { + let fill = st.fill || 'none'; + if (st.hatch) { + const s = parent.ownerSVGElement || parent, id = 'h' + Math.random().toString(36).slice(2, 8); + const p = svg('pattern', { id, width: 5, height: 5, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, svg('defs', {}, s)); + svg('rect', { width: 5, height: 5, fill: st.fill }, p); svg('line', { x1: 0, y1: 0, x2: 0, y2: 5, stroke: st.hatch, 'stroke-width': 1.2 }, p); + fill = `url(#${id})`; + } + const inset = st.stroke ? (st.width || 1) / 2 : 0; + const n = svg('rect', { x: x + inset, y: y + inset, width: Math.max(0, w - inset * 2), height: Math.max(0, h - inset * 2), rx: r, fill, stroke: st.stroke, 'stroke-width': st.stroke ? st.width || 1 : null, 'stroke-dasharray': st.dash, opacity: st.opacity, 'shape-rendering': r ? null : 'crispEdges' }, parent); + if (st.edge) svg('rect', { x, y, width: 3, height: h, rx: Math.min(r, 1.5), fill: st.edge }, parent); + return n; + } + const legendCss = ':where(.chart-legend){list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;line-height:16px;color:var(--muted,#5f6368)}:where(.chart-legend li){display:inline-flex;align-items:center;gap:6px;white-space:nowrap}:where(.chart-legend svg){flex:none;overflow:visible}@media (max-width:480px){:where(.chart-legend){display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}'; + function legendEl(items, target) { + if (!document.getElementById('charts-legend-css')) { const st = document.createElement('style'); st.id = 'charts-legend-css'; st.textContent = legendCss; document.head.appendChild(st); } + const ul = document.createElement('ul'); ul.className = 'chart-legend'; + for (const it of items) { + const li = document.createElement('li'), sw = svg('svg', { width: it.w || 14, height: 12, 'aria-hidden': 'true' }); + it.draw(sw); li.append(sw, document.createTextNode(it.label)); ul.appendChild(li); + } + const t = typeof target === 'string' ? document.querySelector(target) : target; + if (t) t.replaceChildren(ul); + return ul; + } + const rectSwatch = (st, w = 14, h = 10, r = 2) => (sw) => paintRect(sw, st, 0, (12 - h) / 2, w, h, r); + // charts are laid out with measured text: draw again once the page's fonts have arrived + function settle(draw) { if (document.fonts) { document.fonts.ready.then(draw); document.fonts.addEventListener?.('loadingdone', draw, { once: true }); } } + const heightOf = (h) => (typeof h === 'function' ? h() : h); + + function line(el, { labels, series, format = fmt.int, tickFormat = null, height = 220, fill = false, min, max, step = false, annotations, reference, today = null, todayLabel = 'Today', endLabel = false, yTicks = null, xAt = null, legend = null }) { + el._fmt = format; + const colourOf = (sr, k) => sr.colour || (k === 0 ? css(el, '--chart-1', '#2f6fdf') : css(el, `--chart-${k + 1}`, css(el, '--chart-muted', '#a3a3a3'))); + const draw = () => { + const { s, w, h } = frame(el, heightOf(height)); + const all = series.flatMap((x) => x.values.filter((v) => v != null)).concat([].concat(reference || []).map((r) => r.value)); + // max caps the range (a flat series with one spike): values above it are clipped and their peak labelled + const top = max ?? Math.max(...all); + const yt = yTicks || ticks(min ?? Math.min(0, ...all), top, 4); + const tick = tickFormat || format; + const lastVal = series[0].values.filter((v) => v != null).at(-1); + const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26, padR = endLabel ? 10 + measure(s, format(lastVal), 12, 600) : 0; + const x = (i) => padL + (i * (w - padL - padR)) / Math.max(1, labels.length - 1); + // the peaks clipped by max are labelled in the rows above the plot, where the line leaves it + const peaks = []; + series.forEach((sr, k) => sr.values.forEach((v, i) => { if (v != null && v > yt.at(-1) && (i === 0 || sr.values[i - 1] == null || sr.values[i - 1] <= yt.at(-1))) { let j = i, peak = v; while (j < sr.values.length && sr.values[j] != null && sr.values[j] > yt.at(-1)) { peak = Math.max(peak, sr.values[j]); j++; } peaks.push({ x: x(i), label: format(peak) + ' at ' + labels[i], colour: colourOf(sr, k) }); } })); + const scale = (N) => { const padT = LANE(N.rows); return { padT, y: (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0])) }; }; + const linesAt = (y) => series.map((sr) => { const out = []; sr.values.forEach((v, i) => { if (v == null) { out.push(null); return; } const p = [x(i), y(Math.min(v, yt.at(-1)))]; const prev = out.at(-1); if (step && prev) out.push([p[0], prev[1]]); out.push(p); }); return out; }).concat([].concat(reference || []).map((r) => [[padL, y(r.value)], [w, y(r.value)]])); + let N = prepNotes(s, annotations, x, w, peaks), { padT, y } = scale(N), todayBox = null; + // the today label goes inside the plot on whichever side of its line no series crosses; failing that, above + if (today != null && todayLabel) { + const cut = x(today), tw = measure(s, todayLabel, 11), lines = linesAt(y); + const cand = [[cut + 4, h - padB - 6], [cut - 4 - tw, h - padB - 6], [cut + 4, padT + 11], [cut - 4 - tw, padT + 11]].map(([x0, b]) => ({ x0, x1: x0 + tw, y0: b - 9, y1: b + 2, base: b })); + todayBox = cand.find((b) => b.x0 >= padL && b.x1 <= w && !hits(b, lines)) || null; + if (!todayBox) { N = prepNotes(s, annotations, x, w, [...peaks, { x: cut, leader: cut, drawn: true, label: todayLabel }]); ({ padT, y } = scale(N)); } + } + const g = svg('g', {}, s); + axes(g, el, { w, h, padL, padB, yt, y, labels, x, at: xAt, tick }); + notesUnder(g, el, N, { padT, h, padB }); + series.forEach((sr, k) => { + const col = colourOf(sr, k); + const pts = sr.values.map((v, i) => (v == null ? null : [x(i), y(Math.min(v, yt.at(-1)))])); + // step: a balance that only changes on given days holds its value until the next one + const d = pts.reduce((acc, p, i) => (p ? acc + (acc && pts[i - 1] ? (step ? `L${p[0].toFixed(1)} ${pts[i - 1][1].toFixed(1)}L` : 'L') : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1) : acc), ''); + if (fill && k === 0) { const first = pts.find(Boolean), last = [...pts].reverse().find(Boolean); svg('path', { d: `${d}L${last[0]} ${y(yt[0])}L${first[0]} ${y(yt[0])}Z`, fill: col, opacity: 0.12 }, g); } + if (today == null) svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }, g); + else { + // a forecast: the line after today is dashed, so a projection never reads as a fact + const cut = x(today), id = 'c' + k + Math.random().toString(36).slice(2, 8); + const defs = svg('defs', {}, g); + const a = svg('clipPath', { id: id + 'a' }, defs); svg('rect', { x: 0, y: 0, width: cut, height: h }, a); + const b = svg('clipPath', { id: id + 'b' }, defs); svg('rect', { x: cut, y: 0, width: w, height: h }, b); + svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'clip-path': 'url(#' + id + 'a)' }, g); + svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-dasharray': '5 4', opacity: 0.8, 'clip-path': 'url(#' + id + 'b)' }, g); + if (k === 0) { svg('line', { x1: cut, x2: cut, y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0.4 }, g); if (todayBox) text(g, todayLabel, { x: todayBox.x0, y: todayBox.base, 'font-size': 11, fill: css(el, '--axis-ink', '#6b6b6b') }); } + } + if (endLabel && k === 0) { const li = sr.values.map((v, i) => (v == null ? -1 : i)).filter((i) => i >= 0).at(-1); text(g, format(sr.values[li]), { x: x(li) + 6, y: y(Math.min(sr.values[li], yt.at(-1))) + 4, 'font-size': 12, 'font-weight': 600, fill: css(el, '--ink', '#111') }); } + }); + references(g, el, reference, { y, padL, w, lines: linesAt(y).slice(0, series.length) }); + notesOver(g, el, N, { padT, h, padB }); + // hover: a rule, a dot per series and a bare tooltip + const tip = tooltip(el), rule = svg('line', { y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0 }, g); + const dots = series.map((sr, k) => svg('circle', { r: 3.5, fill: css(el, '--surface', '#fff'), stroke: colourOf(sr, k), 'stroke-width': 2, opacity: 0 }, g)); + const hit = svg('rect', { x: padL, y: padT, width: w - padL, height: h - padT - padB, fill: 'transparent' }, s); + const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `
      ${labels[i]}
      ` + series.map((sr) => `
      ${sr.values[i] == null ? '-' : format(sr.values[i])} ${series.length > 1 ? sr.name : ''}
      `).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; }; + hit.addEventListener('pointermove', move); hit.addEventListener('pointerleave', () => { tip.style.opacity = 0; rule.setAttribute('opacity', 0); dots.forEach((d) => d.setAttribute('opacity', 0)); }); + const last = series[0].values.filter((v) => v != null).at(-1); + s.setAttribute('aria-label', `${series.map((x) => x.name).join(', ')}, ${labels[0]} to ${labels.at(-1)}; latest ${last == null ? 'no data' : format(last)}`); + }; + draw(); watch(el, draw); settle(draw); + const lg = series.length > 1 ? legendEl(series.map((sr, k) => ({ label: sr.name, w: 16, draw: (sw) => svg('line', { x1: 0, x2: 16, y1: 6, y2: 6, stroke: colourOf(sr, k), 'stroke-width': 2, 'stroke-dasharray': sr.dash ? '4 3' : null }, sw) })), legend) : null; + return { legend: lg }; + } + function bars(el, { labels, values, format = fmt.int, tickFormat = null, height = 200, highlight = null, dim = [], colour, mutedColour, annotations, reference, xAt = null }) { + el._fmt = format; + const lit = (i) => highlight == null || (Array.isArray(highlight) ? highlight.includes(i) : highlight === i); + const draw = () => { + const { s, w, h } = frame(el, heightOf(height)); + const yt = ticks(0, Math.max(...values, ...[].concat(reference || []).map((r) => r.value)), 4), tick = tickFormat || format; + const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26; + const band = (w - padL) / values.length, bw = Math.max(2, band * 0.62); + const x = (i) => padL + band * i + band / 2; + const N = prepNotes(s, annotations, x, w), padT = LANE(N.rows); + const y = (v) => padT + (h - padT - padB) * (1 - v / yt.at(-1)); + const g = svg('g', {}, s); + axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick }); + notesUnder(g, el, N, { padT, h, padB }); + for (const it of N.items) if (it.at != null && values[it.at] != null) it.end = y(values[it.at]) - 4; + const main = colour || css(el, '--chart-1', '#2f6fdf'), muted = mutedColour || css(el, '--chart-muted', '#c9c9c9'); + values.forEach((v, i) => { const r = svg('rect', { x: x(i) - bw / 2, y: y(v), width: bw, height: Math.max(0, y(0) - y(v)), fill: lit(i) ? main : muted, opacity: dim.includes(i) ? 0.45 : null, 'shape-rendering': 'crispEdges' }, g); titled(r, `${labels[i]}: ${format(v)}`, {}); }); + references(g, el, reference, { y, padL, w, boxes: values.map((v, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(v), y1: y(0) })) }); + notesOver(g, el, N, { padT, h, padB }); + s.setAttribute('aria-label', `${labels.length} bars from ${labels[0]} to ${labels.at(-1)}; highest ${format(Math.max(...values))}`); + }; + draw(); watch(el, draw); settle(draw); + } + // Two directions on one scale: what went out above the zero line and what came in below it (sold and bought, + // income and spend). The axis says which is which, with signed ticks and a caption on each side of zero. + // mark: [indices] draws those periods in a second tone with their own legend entry (the nights the car charged). + function diverging(el, { labels, up, down, values, above = 'Above', below = 'Below', mark = [], markLabel = null, markSide = 'below', partial = null, format = fmt.int, tickFormat = null, height = 200, annotations, reference, xAt = null, legend = null }) { + if (values) { up = values.map((v) => Math.max(0, v || 0)); down = values.map((v) => Math.max(0, -(v || 0))); } + up = up || labels.map(() => 0); down = down || labels.map(() => 0); + const st = { above: { fill: css(el, '--chart-1', '#2f6fdf') }, below: { fill: css(el, '--chart-muted', '#c9c9c9') }, mark: { fill: css(el, '--chart-mark', css(el, '--ink', '#111')) } }; + const marked = (i, side) => mark.includes(i) && (markSide === 'both' || markSide === side); + const tick = (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + (tickFormat || format)(Math.abs(v)); + const draw = () => { + const { s, w, h } = frame(el, heightOf(height)); + const hiUp = Math.max(0, ...up), hiDown = Math.max(0, ...down); + const yt = ticks(-hiDown, hiUp || 1, 4); const stp = yt[1] - yt[0]; if (hiDown > 0 && yt[0] >= -hiDown) yt.unshift(+(yt[0] - stp).toFixed(10)); + const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26; + const band = (w - padL) / labels.length, bw = Math.max(2, band * 0.62); + const x = (i) => padL + band * i + band / 2; + // the caption for the upper side opens the label rows above the plot; the lower one sits under the plot, at left + const N = prepNotes(s, annotations, x, w, [{ fixed: padL, glyph: 'up', label: above }]), padT = LANE(N.rows); + const y = (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0])); + const g = svg('g', {}, s), ink = css(el, '--axis-ink', '#6b6b6b'); + const bwid = measure(s, below, 11) + 9; + axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick, avoid: [padL, padL + bwid] }); + svg('path', { d: `M${padL} ${h - padB + 11}l3 6l3 -6z`, fill: ink }, g); text(g, below, { x: padL + 9, y: h - padB + 18, 'font-size': 11, fill: ink }); + notesUnder(g, el, N, { padT, h, padB }); + for (const it of N.items) if (it.at != null) it.end = y(up[it.at] || 0) - 4; + labels.forEach((lab, i) => { + const o = partial === i ? 0.5 : null; + if (up[i] > 0) { const r = paintRect(g, { ...(marked(i, 'above') ? st.mark : st.above), opacity: o }, x(i) - bw / 2, y(up[i]), bw, Math.max(0, y(0) - y(up[i]) - 1)); titled(r, `${lab}: ${above} ${format(up[i])}`, {}); } + if (down[i] > 0) { const r = paintRect(g, { ...(marked(i, 'below') ? st.mark : st.below), opacity: o }, x(i) - bw / 2, y(0) + 1, bw, Math.max(0, y(-down[i]) - y(0) - 1)); titled(r, `${lab}: ${below} ${format(down[i])}`, {}); } + }); + svg('line', { x1: padL, x2: w, y1: y(0), y2: y(0), stroke: ink, 'stroke-width': 1, opacity: 0.8, 'shape-rendering': 'crispEdges' }, g); + references(g, el, reference, { y, padL, w, boxes: labels.map((_, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(up[i]), y1: y(-down[i]) })) }); + notesOver(g, el, N, { padT, h, padB }); + s.setAttribute('aria-label', `${above} above the line and ${below} below it, ${labels[0]} to ${labels.at(-1)}`); + }; + draw(); watch(el, draw); settle(draw); + const items = []; + if (up.some((v) => v > 0)) items.push({ label: above, draw: rectSwatch(st.above) }); + if (down.some((v) => v > 0)) items.push({ label: below, draw: rectSwatch(st.below) }); + if (mark.length && markLabel) items.push({ label: markLabel, draw: rectSwatch(st.mark) }); + return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) }; + } + function ring(el, { value, label, sub, size = 132, stroke = 8, colour }) { + el.innerHTML = ''; + const r = (size - stroke) / 2, c = 2 * Math.PI * r, v = Math.max(0, Math.min(1, value)); + const wrap = document.createElement('div'); wrap.style.cssText = `position:relative;width:${size}px;height:${size}px`; + const s = svg('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, role: 'img', 'aria-label': `${label}${sub ? ' ' + sub : ''}` }, wrap); + svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: css(el, '--grid', '#e8e8e8'), 'stroke-width': stroke }, s); + svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: colour || css(el, '--chart-1', '#2f6fdf'), 'stroke-width': stroke, 'stroke-dasharray': `${c * v} ${c}`, transform: `rotate(-90 ${size / 2} ${size / 2})` }, s); + const t = document.createElement('div'); t.style.cssText = 'position:absolute;inset:0;display:grid;place-content:center;text-align:center;font-variant-numeric:tabular-nums'; + t.innerHTML = `
      ${label}
      ${sub ? `
      ${sub}
      ` : ''}`; + wrap.appendChild(t); el.appendChild(wrap); + } + // A ranked list. By default the row's fill is the bar (a one-figure list); thin: true puts a 2px grey bar under each + // label instead, ink for the top item, for a ranked list of money in a narrow card. + function barList(el, rows, { format = fmt.int, colour, thin = false } = {}) { + el.innerHTML = ''; + const max = Math.max(...rows.map((r) => r.value)) || 1; + const list = document.createElement('ol'); list.style.cssText = `list-style:none;margin:0;padding:0;display:grid;gap:${thin ? 10 : 2}px;font-variant-numeric:tabular-nums`; + const valueCol = Math.max(...rows.map((r) => String(format(r.value)).length)) + 1; + rows.forEach((r, k) => { + const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(0,1fr) ' + valueCol + 'ch;align-items:center;column-gap:12px'; + const a = document.createElement('span'); a.textContent = r.label; a.style.cssText = 'position:relative;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; + const b = document.createElement('span'); b.textContent = format(r.value); b.style.cssText = 'font-weight:500;text-align:right'; + if (thin) { + const bar = document.createElement('span'); bar.style.cssText = `grid-column:1 / -1;height:2px;margin-top:4px;width:${(r.value / max) * 100}%;background:${k === 0 ? colour || 'var(--ink,#111)' : 'var(--chart-muted,#c9c9c9)'}`; + li.append(a, b, bar); + } else { + const track = document.createElement('span'); track.style.cssText = 'position:relative;padding:10px 12px;border-radius:6px;isolation:isolate;min-width:0'; + const bar = document.createElement('span'); bar.style.cssText = `position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:${colour || 'var(--bar-fill, color-mix(in srgb, var(--chart-1, #2f6fdf) 12%, transparent))'};border-radius:inherit;z-index:-1`; + track.append(bar, a); li.append(track, b); + } + list.appendChild(li); + }); + el.appendChild(list); + } + // Planned against spent: the track is the plan, the fill what is spent, a lighter segment what is + // already committed (bills to come), and an overrun past the track's end in the negative colour. + // One row per category: label and figures on one line, the 8px track under them. + function plan(el, rows, { format = fmt.money('EUR', 0), scale = 'row', note = (r) => r.spent > r.planned ? `${format(r.spent - r.planned)} over` : `${format(r.planned - r.spent - (r.committed || 0))} left` } = {}) { + el.innerHTML = ''; + const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:14px;font-variant-numeric:tabular-nums'; + const max = Math.max(...rows.map((r) => Math.max(r.planned, r.spent + (r.committed || 0)))); + for (const r of rows) { + const over = r.spent > r.planned; + const li = document.createElement('li'); + const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px'; + top.innerHTML = `${r.label}${format(r.spent)} of ${format(r.planned)} · ${note(r)}`; + // scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan + const room = over ? 100 / (1 + Math.max(0.06, (r.spent - r.planned) / r.planned)) : 100; + const track = document.createElement('div'); track.style.cssText = 'position:relative;height:8px;border-radius:999px;background:var(--rule,#eceeef);overflow:visible;width:' + (scale === 'plan' ? (r.planned / max) * room : room) + '%'; + const pct = (v) => (v / r.planned) * 100; + const spent = Math.min(r.spent, r.planned); + track.innerHTML = `` + + (r.committed ? `` : '') + + (over ? `` : ''); + track.setAttribute('role', 'img'); track.setAttribute('aria-label', `${r.label}: ${format(r.spent)} of ${format(r.planned)}${r.committed ? `, ${format(r.committed)} committed` : ''}${over ? ', over plan' : ''}`); + li.append(top, track); list.appendChild(li); + } + el.appendChild(list); + } + // Share against a target: one row per part, the current share as a bar and a 1px tick at the target, + // so drift reads at a glance (never two stacked bars to compare). + function bullet(el, rows, { format = fmt.pct(1), max = Math.max(...rows.map((r) => Math.max(r.value, r.target))) * 1.15 } = {}) { + el.innerHTML = ''; + const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:12px;font-variant-numeric:tabular-nums'; + for (const r of rows) { + const drift = r.value - r.target; + const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(90px,30%) 1fr auto;align-items:center;gap:12px'; + li.innerHTML = `${r.label}${format(r.value)} ${drift >= 0 ? '+' : '−'}${format(Math.abs(drift))} vs ${format(r.target)}`; + li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`); + list.appendChild(li); + } + el.appendChild(list); + } + // shared by the row charts (timeline, matrix, time strip): a label column with an optional grey subline. + // A subline needs a 40-44px row pitch; one-line labels sit on 32px. + function rowLabel(g, s, el, r, { y0, pitch, lw }) { + const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'); + if (r.sub) { text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 - 3, 'font-size': 14, fill: ink }); text(g, fit(s, r.sub, lw - 12, 12), { x: 0, y: y0 + pitch / 2 + 13, 'font-size': 12, fill: muted }); } + else text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 + 5, 'font-size': 14, fill: ink }); + } + const dayText = (d) => (typeof d === 'object' && d ? d.label : String(d)); + // day headers: the full label when every one fits its column, else the short form ('19'), else every k-th short + // label with today always shown and its neighbours left out, so no two headers ever touch + function dayHeads(g, s, el, days, { x0, cw, y, today }) { + const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'); + const full = days.map(dayText), short = days.map((d) => (typeof d === 'object' && d ? d.short || dayText(d) : dayText(d).split(' ').at(-1))); + const wide = (labs) => Math.max(...labs.map((t) => measure(s, t, 12))) + 6; + const labs = wide(full) <= cw ? full : short, k = Math.max(1, Math.ceil(wide(labs) / cw)); + labs.forEach((t, i) => { + if (i !== today && (i % k || (today != null && k > 1 && Math.abs(i - today) < k))) return; + text(g, t, { x: x0 + i * cw + cw / 2, y, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': i === today ? 600 : 400, fill: i === today ? ink : muted }); + }); + } + // The style of each kind of booking: a pale fill, a 1px diagonal hatch, a darker grey step, an outline, or the one + // chart hue for the screen's main data element. tone: 'negative' turns the edge and the label red (it needs a decision). + function kindStyle(el, k = {}) { + const ink = css(el, '--ink', '#111'), surface = css(el, '--surface', '#fff'), neg = css(el, '--negative', '#c4242a'), hue = k.colour || css(el, '--chart-1', '#2f6fdf'); + const tone = k.tone === 'negative' ? { stroke: neg, width: 1.5 } : {}; + const dash = k.dash ? { dash: '3 2' } : {}; + if (k.mark === 'hatch') return { fill: mix(el, ink, surface, 0.04), hatch: mix(el, ink, surface, 0.32), ...tone, ...dash }; + if (k.mark === 'step') return { fill: mix(el, ink, surface, 0.24), ...tone, ...dash }; + if (k.mark === 'outline') return { fill: surface, stroke: mix(el, ink, surface, 0.55), width: 1, ...tone, ...dash }; + if (k.mark === 'hue') return { fill: mix(el, hue, surface, 0.18), edge: hue, ...tone, ...dash }; + return { fill: mix(el, ink, surface, 0.12), ...tone, ...dash }; + } + // Who is away when: people (or rooms, vans) down the left, days across, a bar per booking told apart by its kind. + // rows: [{ label: 'Lena Hoffmann', sub: 'Product design · Berlin', bars: [{ from: 3, to: 7, kind: 'holiday', label, note }] }] + // and { group: 'Brand' } rows for a team subheader + // kinds: { holiday: { label: 'Holiday', mark: 'fill' | 'hatch' | 'step' | 'outline' | 'hue', code: 'H', tone, dash } } + // days: ['Mon 19', …] or [{ label: 'Mon 19', short: '19' }]; weekends: [indices]; today: index; + // holidays: [{ day: index, label: 'All Saints’ Day (Lisbon)' }]; onSelect(bar, row) makes the bars buttons. + // A label that does not fit its bar becomes the kind's code inside it, then goes after the bar's end (never into the + // days before it), and is always in the bar's tooltip. + function timeline(el, rows, kinds = {}, { days = [], weekends = [], today = null, holidays = [], labelWidth = null, rowHeight = null, barHeight = 22, onSelect = null, legend = null, extras = true } = {}) { + const draw = () => { + const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 30, groupH = 30; + const height = head + rows.reduce((a, r) => a + (r.group ? groupH : pitch), 0) + 2; + const { s, w, h } = frame(el, height); + const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.2))); + const cw = (w - lw) / Math.max(1, days.length), col = (i) => lw + i * cw; + const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0'), raised = css(el, '--raised', '#f4f5f6'), hue = css(el, '--chart-1', '#2f6fdf'); + const g = svg('g', {}, s); + // weekends one tone step darker, faint day lines, the day header (today in ink) + for (const i of weekends) svg('rect', { x: col(i), y: head, width: cw, height: h - head, fill: raised }, g); + for (let i = 0; i <= days.length; i++) svg('line', { x1: col(i), x2: col(i), y1: head, y2: h, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); + dayHeads(g, s, el, days, { x0: lw, cw, y: head - 10, today }); + for (const hd of holidays) { const c = svg('circle', { cx: col(hd.day) + cw / 2, cy: head - 3, r: 2, fill: muted }, g); titled(c, hd.label, {}); } + if (today != null) svg('line', { x1: col(today) + cw / 2, x2: col(today) + cw / 2, y1: head, y2: h, stroke: hue, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); + let y0 = head; + for (const r of rows) { + if (r.group) { svg('line', { x1: 0, x2: w, y1: y0 + 0.5, y2: y0 + 0.5, stroke: rule, 'stroke-width': 1 }, g); text(g, r.group, { x: 0, y: y0 + 20, 'font-size': 12, 'font-weight': 500, fill: muted }); y0 += groupH; continue; } + rowLabel(g, s, el, r, { y0, pitch, lw }); + const bs = (r.bars || []).filter((b) => b.to >= 0 && b.from < days.length).sort((a, b) => a.from - b.from); + bs.forEach((b, k) => { + const kind = kinds[b.kind] || {}, st = kindStyle(el, kind); + const x0 = col(Math.max(0, b.from)) + 2, x1 = col(Math.min(days.length - 1, b.to) + 1) - 2, by = y0 + (pitch - barHeight) / 2; + const grp = svg('g', onSelect ? { tabindex: 0, role: 'button', style: 'cursor:pointer' } : {}, g); + paintRect(grp, st, x0, by, x1 - x0, barHeight, 4); + const label = b.label ?? kind.label ?? '', code = b.code ?? kind.code, tw = measure(s, label, 12), labInk = kind.tone === 'negative' ? css(el, '--negative', '#c4242a') : ink; + const next = bs[k + 1] ? col(Math.max(0, bs[k + 1].from)) : w; + // over a hatch the label keeps a halo of the bar's own fill, so the lines never run through the words + const halo = st.hatch ? { stroke: st.fill, 'stroke-width': 4, 'stroke-linejoin': 'round', 'paint-order': 'stroke' } : {}; + if (label && tw + 16 <= x1 - x0) text(grp, label, { x: x0 + 8 + (st.edge ? 2 : 0), y: by + barHeight / 2 + 4, 'font-size': 12, fill: labInk, ...halo }); + else if (code && measure(s, code, 12) + 8 <= x1 - x0) text(grp, code, { x: (x0 + x1) / 2, y: by + barHeight / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, fill: labInk, ...halo }); + else if (label && x1 + 6 + tw <= Math.min(next - 6, w)) text(grp, label, { x: x1 + 6, y: by + barHeight / 2 + 4, 'font-size': 12, fill: kind.tone === 'negative' ? labInk : muted }); + titled(grp, `${r.label}: ${label}${b.note ? ' \u00b7 ' + b.note : ''}`, {}); + if (onSelect) { grp.addEventListener('click', () => onSelect(b, r)); grp.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(b, r); } }); } + }); + y0 += pitch; + } + s.setAttribute('aria-label', `${rows.filter((r) => !r.group).length} rows across ${days.length} days, ${dayText(days[0])} to ${dayText(days.at(-1))}`); + }; + draw(); watch(el, draw); settle(draw); + const used = new Set(rows.flatMap((r) => (r.bars || []).map((b) => b.kind))); + const items = Object.entries(kinds).filter(([id]) => used.has(id)).map(([, k]) => ({ label: k.label, w: 22, draw: (sw) => { const st = kindStyle(el, k); paintRect(sw, st, 0, 0, 22, 12, 3); } })); + if (extras && today != null) items.push({ label: 'Today', w: 8, draw: (sw) => svg('line', { x1: 4, x2: 4, y1: 0, y2: 12, stroke: css(el, '--chart-1', '#2f6fdf'), 'stroke-width': 1.5 }, sw) }); + return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) }; + } + // Is there enough cover? A team-by-day grid of slack against the minimum (+2, +1, 0, −1). Cells with room to spare + // stay plain, and one hue ramps toward the negative colour as the slack runs out; the headcount is in the tooltip. + // rows: [{ label: 'Product design', sub: 'needs 7 of 10', values: [2, 1, 0, -1, null, …], titles: ['8 in', …] }] + // cols: ['Mon 19', …]; comfort: the slack that needs no attention (2); null is a day with no cover to count (a dash) + function matrix(el, rows, { cols = [], comfort = 2, today = null, format = fmt.signed(), labelWidth = null, rowHeight = null, levels = ['Room to spare', 'Tight', 'At the minimum', 'Short'], legend = null } = {}) { + const level = (v) => (v >= comfort ? 0 : v > 0 ? 1 : v === 0 ? 2 : 3); + const styles = () => { const neg = css(el, '--negative', '#c4242a'), surface = css(el, '--surface', '#fff'), ink = css(el, '--ink', '#111'); return [{ fill: surface, ink: css(el, '--axis-ink', '#6b6b6b'), stroke: css(el, '--grid', '#e5e5e5') }, { fill: mix(el, neg, surface, 0.1), ink }, { fill: mix(el, neg, surface, 0.26), ink }, { fill: neg, ink: surface }]; }; + const draw = () => { + const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 26; + const { s, w, h } = frame(el, head + rows.length * pitch); + const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.22))); + const cw = (w - lw) / Math.max(1, cols.length), ST = styles(), muted = css(el, '--axis-ink', '#6b6b6b'), ink = css(el, '--ink', '#111'); + const g = svg('g', {}, s); + dayHeads(g, s, el, cols, { x0: lw, cw, y: head - 10, today }); + rows.forEach((r, k) => { + const y0 = head + k * pitch; rowLabel(g, s, el, r, { y0, pitch, lw }); + const ch = Math.min(pitch - 6, 28), cy = y0 + (pitch - ch) / 2; + r.values.forEach((v, i) => { + const cx = lw + i * cw + 1; + if (v == null) { svg('rect', { x: cx + cw / 2 - 4, y: cy + ch / 2 - 1, width: 6, height: 2, fill: muted }, g); return; } + const st = ST[level(v)], cell = paintRect(g, { fill: st.fill }, cx, cy, cw - 2, ch, 3); + text(g, format(v), { x: cx + (cw - 2) / 2, y: cy + ch / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': level(v) === 3 ? 600 : 400, fill: st.ink }); + titled(cell, `${r.label}, ${dayText(cols[i])}: ${r.titles?.[i] || format(v) + ' against the minimum'}`, {}); + }); + }); + s.setAttribute('aria-label', `Cover against the minimum for ${rows.length} rows across ${cols.length} days`); + }; + draw(); watch(el, draw); settle(draw); + const seen = new Set(rows.flatMap((r) => r.values.filter((v) => v != null).map(level))); + const ST = styles(); + const items = [0, 1, 2, 3].filter((l) => seen.has(l)).map((l) => ({ label: levels[l], w: 14, draw: rectSwatch(l === 0 ? { fill: ST[0].fill, stroke: ST[0].stroke } : { fill: ST[l].fill }, 14, 12, 3) })); + return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) }; + } + // When, within the day: one row per item on a shared open to close axis. A 2px grey track, an ink dot at today's time and + // a hollow tick at the comparison day's, so "earlier than last Sunday" is drawn and not only written. + // rows: [{ label: 'White sourdough', sub: 'Headingley', at: '11:20', compare: '12:05', value: '11:20', note: '3 h 40 m before close' }] + // at or compare may be null (it did not sell out); step: minutes between axis labels (60). + function timeStrip(el, rows, { open = '08:00', close = '18:00', step = 60, todayLabel = 'Today', compareLabel = null, labelWidth = null, valueWidth = null, rowHeight = null, legend = null } = {}) { + const o = minutes(open), c = minutes(close); + const st = { today: { r: 4.5 }, compare: { w: 5, h: 12 } }; + const dot = (parent, cx, cy) => svg('circle', { cx, cy, r: st.today.r, fill: css(el, '--ink', '#111') }, parent); + const tick = (parent, cx, cy) => svg('rect', { x: cx - st.compare.w / 2, y: cy - st.compare.h / 2, width: st.compare.w, height: st.compare.h, rx: 2.5, fill: css(el, '--surface', '#fff'), stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1.5 }, parent); + const draw = () => { + const narrow = (el.clientWidth || 600) < 560, axisH = 24; + const pitch = rowHeight || (narrow ? 62 : rows.some((r) => r.sub || r.note) ? 44 : 32); + const { s, w, h } = frame(el, rows.length * pitch + axisH); + const lw = narrow ? 0 : labelWidth || Math.round(Math.min(200, Math.max(110, w * 0.26))); + const vw = narrow ? 0 : valueWidth ?? Math.min(w * 0.32, Math.max(0, ...rows.map((r) => Math.max(r.value ? measure(s, r.value, 13, 500) : 0, r.note ? measure(s, r.note, 12) : 0))) + (rows.some((r) => r.value || r.note) ? 16 : 0)); + const x0 = narrow ? 18 : lw + 6, x1 = narrow ? w - 18 : w - vw - 6, tx = (m) => x0 + ((m - o) / Math.max(1, c - o)) * (x1 - x0); + const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0'); + const track = css(el, '--chart-track', mix(el, ink, css(el, '--surface', '#fff'), 0.16)); + const g = svg('g', {}, s), plotH = rows.length * pitch; + // the axis: one step from the opening, the labels thinned by the same rule as the charts + const marks = []; for (let m = Math.ceil(o / step) * step; m <= c; m += step) marks.push(m); + const picks = xPicks(marks.map(fmt.clock), x1 - x0); + for (const i of picks) { const xx = tx(marks[i]); if (!narrow) svg('line', { x1: xx, x2: xx, y1: 0, y2: plotH, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); const lab = fmt.clock(marks[i]), tw = measure(s, lab, 12); text(g, lab, { x: Math.max(x0 - 2, Math.min(x1 + 6 - tw, xx - tw / 2)), y: plotH + 17, 'font-size': 12, fill: muted }); } + rows.forEach((r, k) => { + const y0 = k * pitch, cy = narrow ? y0 + pitch - 12 : y0 + pitch / 2; + if (narrow) { + const vwid = r.value ? measure(s, r.value, 13, 500) + 12 : 0; + text(g, fit(s, r.label, w - vwid, 14), { x: 0, y: y0 + 16, 'font-size': 14, fill: ink }); + if (r.value) text(g, r.value, { x: w, y: y0 + 16, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); + const second = [r.sub, r.note].filter(Boolean).join(' \u00b7 '); + if (second) text(g, fit(s, second, w, 12), { x: 0, y: y0 + 33, 'font-size': 12, fill: muted }); + } else rowLabel(g, s, el, r, { y0, pitch, lw }); + svg('line', { x1: x0, x2: x1, y1: cy, y2: cy, stroke: track, 'stroke-width': 2, 'stroke-linecap': 'round' }, g); + const cm = minutes(r.compare), am = minutes(r.at); + if (cm != null) titled(tick(g, tx(cm), cy), `${compareLabel || 'Comparison'}: ${fmt.clock(cm)}`, {}); + if (am != null) titled(dot(g, tx(am), cy), `${todayLabel}: ${fmt.clock(am)}`, {}); + if (narrow) return; + if (r.note) { if (r.value) text(g, r.value, { x: w, y: cy - 3, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); text(g, fit(s, r.note, vw - 8, 12), { x: w, y: r.value ? cy + 13 : cy + 4, 'text-anchor': 'end', 'font-size': 12, fill: muted }); } + else if (r.value) text(g, r.value, { x: w, y: cy + 5, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); + }); + s.setAttribute('aria-label', `${rows.length} rows between ${fmt.clock(o)} and ${fmt.clock(c)}`); + }; + draw(); watch(el, draw); settle(draw); + const items = []; + if (rows.some((r) => minutes(r.at) != null)) items.push({ label: todayLabel, w: 10, draw: (sw) => dot(sw, 5, 6) }); + if (compareLabel && rows.some((r) => minutes(r.compare) != null)) items.push({ label: compareLabel, w: 8, draw: (sw) => tick(sw, 4, 6) }); + return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) }; + } + function watch(el, draw) { if (!('ResizeObserver' in window)) return; let w = el.clientWidth; new ResizeObserver(() => { if (Math.abs(el.clientWidth - w) > 4) { w = el.clientWidth; draw(); } }).observe(el); } + window.Charts = { line, bars, diverging, ring, barList, plan, bullet, timeline, matrix, timeStrip, ticks, fmt }; +})(); diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 0000000..e91badc --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "declaration": false, + "noEmit": true, + "types": [ + "chrome" + ], + "resolveJsonModule": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/extension/vitest.config.mjs b/apps/extension/vitest.config.mjs similarity index 100% rename from packages/extension/vitest.config.mjs rename to apps/extension/vitest.config.mjs diff --git a/apps/site/build.mjs b/apps/site/build.mjs new file mode 100644 index 0000000..e0dd161 --- /dev/null +++ b/apps/site/build.mjs @@ -0,0 +1,40 @@ +import { build } from 'esbuild'; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const dist = resolve(here, 'dist'); +const ext = resolve(here, '../extension'); + +if (!existsSync(resolve(ext, 'dist/popup.html'))) throw new Error('Build the extension first: npm run build -w @meterx/extension'); + +rmSync(dist, { recursive: true, force: true }); +mkdirSync(dist, { recursive: true }); + +// Pages and images; partials are filled in below, not served. +cpSync(resolve(here, 'static'), dist, { recursive: true, filter: src => !src.includes('_partials') }); +cpSync(resolve(ext, 'static/fonts'), resolve(dist, 'fonts'), { recursive: true }); +cpSync(resolve(ext, 'static/styles/app.css'), resolve(dist, 'styles/app.css')); + +// The real extension pages, run in demo mode by the shim, which must load before each page's own script. +const app = resolve(dist, 'app'); +cpSync(resolve(ext, 'dist'), app, { recursive: true, filter: src => !/background\.js$|manifest\.json$/.test(src) }); +for (const page of ['popup.html', 'sidepanel.html', 'options.html']) { + const f = resolve(app, page); + writeFileSync(f, readFileSync(f, 'utf8').replace('\n readFileSync(resolve(here, 'static/_partials', `${name}.html`), 'utf8').trim(); +const header = partial('header'), footer = partial('footer'); +const walk = dir => readdirSync(dir).flatMap(n => { const p = join(dir, n); return statSync(p).isDirectory() ? (p === app ? [] : walk(p)) : [p]; }); +for (const f of walk(dist).filter(p => p.endsWith('.html'))) { + writeFileSync(f, readFileSync(f, 'utf8').replace('', header).replace('', footer)); +} + +const common = { absWorkingDir: here, bundle: true, target: 'es2022', minify: true }; +await build({ ...common, entryPoints: { home: 'src/home.ts', share: 'src/share.ts', get: 'src/get.ts', faq: 'src/faq.ts', contribute: 'src/contribute.ts', common: 'src/common.ts' }, format: 'esm', outdir: resolve(dist, 'js') }); +await build({ ...common, entryPoints: { 'demo-shim': 'src/demo-shim.ts' }, format: 'iife', outdir: resolve(dist, 'js') }); + +console.log('Site built to dist/'); diff --git a/apps/site/package.json b/apps/site/package.json new file mode 100644 index 0000000..05c5d5c --- /dev/null +++ b/apps/site/package.json @@ -0,0 +1,14 @@ +{ + "name": "@meterx/site", + "private": true, + "license": "AGPL-3.0", + "type": "module", + "scripts": { + "build": "node build.mjs", + "test": "vitest run", + "deploy": "npm run build -w @meterx/extension && node build.mjs && wrangler pages deploy dist --project-name meterx-site --branch main" + }, + "dependencies": { + "@meterx/shared": "*" + } +} diff --git a/apps/site/src/capacity.test.ts b/apps/site/src/capacity.test.ts new file mode 100644 index 0000000..99e090c --- /dev/null +++ b/apps/site/src/capacity.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { capacity, formatDuration } from './capacity'; + +describe('capacity', () => { + it('uses the extension thresholds and real arithmetic', () => { + const c = capacity(100)!; + expect(c.streams4k).toBe(4); + expect(c.streamsHd).toBe(20); + expect(c.gameSeconds).toBe(4000); + expect(c.filmSeconds).toBe(320); + }); + it('rejects nonsense', () => { + expect(capacity(0)).toBeNull(); + expect(capacity(NaN)).toBeNull(); + expect(capacity(-5)).toBeNull(); + }); +}); + +describe('formatDuration', () => { + it('reads naturally', () => { + expect(formatDuration(45)).toBe('45 seconds'); + expect(formatDuration(320)).toBe('5 minutes'); + expect(formatDuration(4000)).toBe('67 minutes'); + expect(formatDuration(6000)).toBe('1 hour 40 minutes'); + expect(formatDuration(1)).toBe('1 second'); + expect(formatDuration(7200)).toBe('2 hours'); + }); +}); diff --git a/apps/site/src/capacity.ts b/apps/site/src/capacity.ts new file mode 100644 index 0000000..917727d --- /dev/null +++ b/apps/site/src/capacity.ts @@ -0,0 +1,29 @@ +/** + * What a download speed buys, using the extension's own thresholds: 25 Mbps for a 4K stream, 5 Mbps for HD. + * @license AGPL-3.0 + */ + +export interface Capacity { streams4k: number; streamsHd: number; gameSeconds: number; filmSeconds: number } + +const MBIT_PER_GB = 8000; + +export function capacity(mbps: number): Capacity | null { + if (!(mbps > 0) || mbps > 100_000) return null; + return { + streams4k: Math.floor(mbps / 25), + streamsHd: Math.floor(mbps / 5), + gameSeconds: (50 * MBIT_PER_GB) / mbps, + filmSeconds: (4 * MBIT_PER_GB) / mbps, + }; +} + +const unit = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`; + +/** "45 seconds", "12 minutes", "1 hour 7 minutes". */ +export function formatDuration(s: number): string { + if (s < 90) return unit(Math.max(1, Math.round(s)), 'second'); + const m = Math.round(s / 60); + if (m < 90) return unit(m, 'minute'); + const h = Math.floor(m / 60), rest = m % 60; + return rest ? `${unit(h, 'hour')} ${unit(rest, 'minute')}` : unit(h, 'hour'); +} diff --git a/apps/site/src/common.ts b/apps/site/src/common.ts new file mode 100644 index 0000000..081ef3c --- /dev/null +++ b/apps/site/src/common.ts @@ -0,0 +1,101 @@ +/** + * Every page: install buttons for the visitor's browser, and section entrances. + * @license AGPL-3.0 + */ + +import { STORES, detectStore } from './stores'; + +/** Points every [data-install] link at the visitor's store, or at the install list when there is no live listing. */ +export function wireInstall(): void { + const id = detectStore(navigator.userAgent); + const store = id ? STORES[id] : null; + document.querySelectorAll('a[data-install]').forEach(a => { + if (store?.url) { + a.href = store.url; + a.textContent = a.dataset.install === 'short' ? `Add to ${store.name}` : `Add to ${store.name}, free`; + } + }); +} + +/** Parts of each section rise in once as the section enters. Content is visible without script or with reduced motion. */ +function entrances(): void { + if (matchMedia('(prefers-reduced-motion: reduce)').matches || !('IntersectionObserver' in window)) return; + const parts = document.querySelectorAll('[data-enter] > *'); + parts.forEach((el, i) => { el.classList.add('pre'); el.style.setProperty('--i', String(i % 6)); }); + const io = new IntersectionObserver(entries => { + for (const e of entries) if (e.isIntersecting) { e.target.querySelectorAll('.pre').forEach(el => el.classList.remove('pre')); io.unobserve(e.target); } + }, { rootMargin: '0px 0px -12% 0px' }); + document.querySelectorAll('[data-enter]').forEach(s => io.observe(s)); +} + +/** Marks the current page in the header and gives the header an edge once the page scrolls under it. */ +function chrome(): void { + const path = location.pathname; + document.querySelectorAll('.links a').forEach(a => { + if (path.startsWith(a.getAttribute('href')!)) a.setAttribute('aria-current', 'page'); + }); + const bar = document.querySelector('.site-bar'); + const menu = bar?.querySelector('.menu-btn'); + const setOpen = (open: boolean) => { bar?.classList.toggle('is-open', open); menu?.setAttribute('aria-expanded', String(open)); }; + menu?.addEventListener('click', () => setOpen(!bar!.classList.contains('is-open'))); + bar?.querySelectorAll('.links a').forEach(a => a.addEventListener('click', () => setOpen(false))); + addEventListener('keydown', e => { if (e.key === 'Escape') setOpen(false); }); + const onScroll = () => bar?.classList.toggle('is-scrolled', scrollY > 8); + addEventListener('scroll', onScroll, { passive: true }); + onScroll(); +} + +/** Frames marked data-fit take the height their page reports, so the frame never scrolls on its own. */ +function fitFrames(): void { + document.querySelectorAll('iframe.device').forEach(f => f.setAttribute('scrolling', 'no')); + // On phones the side panel frames switch to flow mode and take their full height too. + if (matchMedia('(max-width: 900px)').matches) { + document.querySelectorAll('iframe.device.panel').forEach(f => { + f.dataset.fit = ''; + f.setAttribute('scrolling', 'no'); + f.addEventListener('load', () => f.contentWindow?.postMessage({ mxDemo: 'flow' }, location.origin)); + }); + } + addEventListener('message', e => { + if (e.origin !== location.origin || e.data?.mxDemo !== 'height') return; + document.querySelectorAll('iframe[data-fit]').forEach(f => { + if (f.contentWindow === e.source) f.style.height = `${e.data.h}px`; + }); + }); +} + +/** Long documents get an on-page contents list beside them, built from their section headings. */ +function toc(): void { + const doc = document.querySelector('main.doc'), body = doc?.querySelector('.body'); + const heads = [...(body?.querySelectorAll(':scope > h2') ?? [])]; + if (!doc || !body || heads.length < 3) return; + const nav = document.createElement('nav'); + nav.className = 'toc'; + nav.setAttribute('aria-label', 'On this page'); + nav.innerHTML = '

      On this page

      '; + heads.forEach((h, i) => { + h.id ||= `s${i + 1}`; + const a = document.createElement('a'); + a.href = `#${h.id}`; + a.textContent = h.textContent; + nav.append(a); + }); + const content = document.createElement('div'); + content.className = 'content'; + content.append(...body.childNodes); + body.append(nav, content); + doc.classList.add('has-toc'); + const links = [...nav.querySelectorAll('a')]; + const io = new IntersectionObserver(es => es.forEach(e => { + if (e.isIntersecting) links.forEach(a => a.classList.toggle('is-here', a.hash === `#${e.target.id}`)); + }), { rootMargin: '-20% 0px -70% 0px' }); + heads.forEach(h => io.observe(h)); +} + +wireInstall(); +entrances(); +toc(); +chrome(); +fitFrames(); +const year = document.getElementById('year'); +if (year) year.textContent = String(new Date().getFullYear()); diff --git a/apps/site/src/contribute.ts b/apps/site/src/contribute.ts new file mode 100644 index 0000000..681dbba --- /dev/null +++ b/apps/site/src/contribute.ts @@ -0,0 +1,39 @@ +/** + * Contribute page: live repo stats, issues ready for newcomers, and everyone who has contributed. + * Without the API (offline, rate limited) the page keeps its plain links. + * @license AGPL-3.0 + */ + +import { REPO, gh, toContributors, toIssues } from './github'; + +const $ = (id: string) => document.getElementById(id)!; +const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!)); +const n = (v: number) => v.toLocaleString(); + +gh<{ stargazers_count: number; forks_count: number; open_issues_count: number }>(`repos/${REPO}`).then(r => { + $('stStars').textContent = n(r.stargazers_count); + $('stForks').textContent = n(r.forks_count); + $('stIssues').textContent = n(r.open_issues_count); + $('stats').classList.add('is-live'); +}).catch(() => { /* the stat cells keep their links */ }); + +Promise.all([ + gh(`repos/${REPO}/issues?state=open&labels=good%20first%20issue&per_page=8`), + gh(`repos/${REPO}/issues?state=open&labels=help%20wanted&per_page=8`), +]).then(([first, help]) => { + const seen = new Set(); + const all = [...toIssues(first), ...toIssues(help)].filter(i => !seen.has(i.number) && seen.add(i.number)); + if (!all.length) return; + $('issues').innerHTML = all.map(i => `
    • + #${i.number}${esc(i.title)} + ${i.labels.filter(l => l !== 'enhancement' && l !== 'documentation').map(l => `${esc(l)}`).join('')} +
    • `).join(''); +}).catch(() => { /* keeps the link to the issue list */ }); + +gh(`repos/${REPO}/contributors?per_page=60`).then(raw => { + const people = toContributors(raw); + if (!people.length) return; + $('people').innerHTML = people.map(p => `
    • + ${esc(p.login)}${n(p.contributions)} commits
    • `).join('') + + `
    • You?Start below
    • `; +}).catch(() => { /* keeps the link to the contributors graph */ }); diff --git a/apps/site/src/demo-shim.ts b/apps/site/src/demo-shim.ts new file mode 100644 index 0000000..ae92cfb --- /dev/null +++ b/apps/site/src/demo-shim.ts @@ -0,0 +1,124 @@ +/** + * Runs the real extension pages (popup, side panel, settings) on the website in demo mode. + * Loaded before the page script; stands in for the extension APIs with sample data in memory. + * The parent page can drive the sample test with postMessage({ mxDemo: 'drive', p }). + * @license AGPL-3.0 + */ + +import { VERSION, WORKER_URL, type BroadcastMessage, type HistoryEntry } from '@meterx/shared'; +import { SAMPLE_PLAN, sampleFrame, sampleHistory, sampleResult } from './sample'; + +type Area = 'local' | 'sync'; +type Changes = Record; +const data: Record> = { local: { history: sampleHistory() }, sync: { ispPlan: SAMPLE_PLAN } }; +const changeListeners: Array<(c: Changes, area: Area) => void> = []; +const messageListeners: Array<(m: BroadcastMessage) => void> = []; + +function area(name: Area) { + const store = data[name]; + return { + async get(keys?: string | string[] | Record | null) { + if (keys == null) return { ...store }; + if (typeof keys === 'string') return keys in store ? { [keys]: store[keys] } : {}; + if (Array.isArray(keys)) return Object.fromEntries(keys.filter(k => k in store).map(k => [k, store[k]])); + return Object.fromEntries(Object.entries(keys).map(([k, d]) => [k, k in store ? store[k] : d])); + }, + async set(items: Record) { + const changes: Changes = {}; + for (const [k, v] of Object.entries(items)) { changes[k] = { oldValue: store[k], newValue: v }; store[k] = v; } + changeListeners.forEach(f => f(changes, name)); + }, + async remove(keys: string | string[]) { + const changes: Changes = {}; + for (const k of ([] as string[]).concat(keys)) { changes[k] = { oldValue: store[k] }; delete store[k]; } + changeListeners.forEach(f => f(changes, name)); + }, + }; +} + +const broadcast = (m: BroadcastMessage) => messageListeners.forEach(f => f(m)); + +// --- The sample test, played on a timer (Run test) or driven by the parent page's scroll. +let running = false; +let progress = {}; +let timer: number | undefined; + +function show(p: number, save: boolean): void { + const f = sampleFrame(p); + if (f === null) { + if (running) { running = false; broadcast({ action: 'testError', error: 'Test cancelled', cancelled: true }); } + } else if (f === 'done') { + if (running) { + running = false; + const entry = sampleResult(); + if (save) void area('local').set({ history: [entry, ...(data.local.history as HistoryEntry[])].slice(0, 200) }); + broadcast({ action: 'testComplete', data: entry }); + } + } else { + running = true; + progress = f; + broadcast({ action: 'testProgress', data: f }); + } +} + +function play(): void { + clearInterval(timer); + const t0 = performance.now(), length = 7000; // the real test takes about 20 s; the demo plays it faster + timer = window.setInterval(() => { + const p = (performance.now() - t0) / length; + show(Math.min(p, 1), true); + if (p >= 1) clearInterval(timer); + }, 120); +} + +const chromeDemo = { + storage: { local: area('local'), sync: area('sync'), onChanged: { addListener: (f: (c: Changes, a: Area) => void) => changeListeners.push(f) } }, + runtime: { + async sendMessage(m: { action?: string }) { + if (m?.action === 'getState') return running ? { running, progress } : null; + if (m?.action === 'startTest') { play(); return { ok: true }; } + if (m?.action === 'cancelTest') { clearInterval(timer); show(0, false); return { ok: true }; } + return { ok: true }; + }, + onMessage: { addListener: (f: (m: BroadcastMessage) => void) => messageListeners.push(f) }, + getURL: (p: string) => p, + openOptionsPage: () => parent.postMessage({ mxDemo: 'openOptions' }, location.origin), + }, + permissions: { contains: async () => true, request: async () => true }, + sidePanel: { open: async () => parent.postMessage({ mxDemo: 'openSidePanel' }, location.origin) }, + windows: { getCurrent: async () => ({ id: 1 }) }, + tabs: { create: (o: { url?: string }) => { if (o?.url) window.open(o.url, '_blank', 'noopener'); } }, +}; +(window as unknown as { chrome: unknown }).chrome = chromeDemo; + +window.addEventListener('message', e => { + if (e.origin !== location.origin) return; + if (e.data?.mxDemo === 'play') { play(); return; } + if (e.data?.mxDemo === 'flow') { document.documentElement.classList.add('mx-flow'); return; } + if (e.data?.mxDemo !== 'drive' || typeof e.data.p !== 'number') return; + clearInterval(timer); + show(e.data.p, false); +}); + +// The demo answers the test server itself, so the pages show a connected state without touching the network. +const realFetch = window.fetch.bind(window); +window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (!url.startsWith(WORKER_URL)) return realFetch(input, init); + const path = new URL(url).pathname; + const body = path === '/health' ? JSON.stringify({ status: 'ok', server: 'cloudflare-worker', version: VERSION, edge: 'MAA', timestamp: Date.now() }) : 'Pong!'; + return new Promise(resolve => setTimeout(() => resolve(new Response(body, { status: 200, headers: { 'Content-Type': path === '/health' ? 'application/json' : 'text/plain' } })), 18)); +}; + +// On the site, pages sit inside a frame: tighter padding, and the frame takes the page's height so only the site scrolls. +document.addEventListener('DOMContentLoaded', () => { + const style = document.createElement('style'); + // At fractional browser zoom a page can round a pixel past its frame; the demo pages never scroll as a page. + style.textContent = 'html,body{overflow:hidden}.settings .wrap{max-width:none;padding:8px 20px 20px}.settings #closeBtn{visibility:hidden}' + // Flow mode (phones): the side panel lays out at full height instead of fitting the frame, so the frame grows and nothing scrolls inside it. + + 'html.mx-flow .panel .wrap{height:auto!important;display:block!important}html.mx-flow .panel main{grid-template-rows:none!important}html.mx-flow .results-body{overflow:visible!important}html.mx-flow #rows tr:nth-child(n+7){display:none}'; + document.head.append(style); + const report = () => parent.postMessage({ mxDemo: 'height', h: Math.ceil(document.documentElement.scrollHeight) }, location.origin); + new ResizeObserver(report).observe(document.body); + report(); +}); diff --git a/apps/site/src/faq.ts b/apps/site/src/faq.ts new file mode 100644 index 0000000..7630aef --- /dev/null +++ b/apps/site/src/faq.ts @@ -0,0 +1,23 @@ +/** + * Questions: on wide screens the open answer shows in a panel beside the list; on phones they stay a plain accordion. + * @license AGPL-3.0 + */ + +const list = document.getElementById('faq'), panel = document.getElementById('faqAnswer'); +const wide = matchMedia('(min-width: 900px)'); + +if (list && panel) { + const items = [...list.querySelectorAll('details')]; + const show = (d: HTMLDetailsElement) => { + items.forEach(x => x.classList.toggle('is-current', x === d)); + const more = d.dataset.more ? `Read more` : ''; + panel.querySelector('.answer')!.innerHTML = `

      ${d.querySelector('summary')!.textContent}

      ${d.querySelector('p')!.outerHTML}${more}`; + }; + items.forEach(d => d.querySelector('summary')!.addEventListener('click', e => { + if (!wide.matches) return; + e.preventDefault(); // on wide screens the answer goes to the panel instead of opening in place + show(d); + })); + document.body.classList.add('faq-js'); + show(items[0]); +} diff --git a/apps/site/src/get.ts b/apps/site/src/get.ts new file mode 100644 index 0000000..e109b98 --- /dev/null +++ b/apps/site/src/get.ts @@ -0,0 +1,55 @@ +/** + * Get MeterX: every store on the brand's dial, with the needle pointing at the visitor's own browser. + * /get/?go skips the page and goes straight to the visitor's store, for links in posts and bios. + * @license AGPL-3.0 + */ + +import { PLACES, STORES, detectStore } from './stores'; + +const id = detectStore(navigator.userAgent); +const mine = id ? STORES[id] : null; +if (new URLSearchParams(location.search).has('go') && mine?.url) location.replace(mine.url); + +const $ = (i: string) => document.getElementById(i)!; +const logo = (p: (typeof PLACES)[number]) => + ``; + +// The dial: one stop per place along the logo's three-quarter arc, live ones in colour. +const dial = $('dial'); +const from = 135, sweep = 270, step = sweep / (PLACES.length - 1); +PLACES.forEach((p, i) => { + const a = ((from + i * step) * Math.PI) / 180; + const el = document.createElement('a'); + el.className = `stop${p.url ? ' live' : ''}${p.id === id ? ' mine' : ''}`; + el.href = p.url || '#next'; + el.style.left = `${50 + 46 * Math.cos(a)}%`; + el.style.top = `${50 + 46 * Math.sin(a)}%`; + el.title = `${p.store}: ${p.url ? 'available' : p.note}`; + el.innerHTML = logo(p); + dial.append(el); +}); + +// The needle swings to the visitor's browser, or rests at the top when the browser is unknown. +const target = PLACES.findIndex(p => p.id === id); +const angle = target >= 0 ? from + target * step : 270; +requestAnimationFrame(() => $("needle").style.setProperty("--a", `${angle - 90}deg`)); + +if (mine) { + const main = $('getMain') as HTMLAnchorElement; + const place = PLACES.find(p => p.id === id)!; + if (mine.url) { + main.href = mine.url; + main.textContent = `Add to ${mine.name}, free`; + $('getLine').textContent = `You are on ${mine.name}. One click installs it from ${place.store}.`; + } else { + $('getLine').textContent = `You are on ${mine.name}. ${place.note}; Firefox and Edge have it today.`; + } +} + +const card = (p: (typeof PLACES)[number]) => `
    • + ${logo(p)} +

      ${p.store}

      ${p.browsers}

      ${p.url ? '' : `${p.note}`}
      + ${p.url ? `Install` : ''} +
    • `; +$('storesNow').innerHTML = PLACES.filter(p => p.url).map(card).join(''); +$('storesNext').innerHTML = PLACES.filter(p => !p.url).map(card).join(''); diff --git a/apps/site/src/github.test.ts b/apps/site/src/github.test.ts new file mode 100644 index 0000000..b1c80c1 --- /dev/null +++ b/apps/site/src/github.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { toContributors, toIssues } from './github'; + +describe('toIssues', () => { + it('keeps issues, drops pull requests and junk', () => { + const raw = [ + { number: 22, title: 'Reduced motion', html_url: 'u', labels: [{ name: 'good first issue' }] }, + { number: 9, title: 'A PR', html_url: 'p', pull_request: {}, labels: [] }, + null, + ]; + expect(toIssues(raw)).toEqual([{ number: 22, title: 'Reduced motion', url: 'u', labels: ['good first issue'] }]); + expect(toIssues({ message: 'rate limited' })).toEqual([]); + expect(toIssues([{ number: 1, title: 'Export \u2014 add HTML', html_url: 'u', labels: [] }])[0].title).toBe('Export, add HTML'); + }); +}); + +describe('toContributors', () => { + it('keeps people and drops bots', () => { + const raw = [{ login: 'ajithakdev', type: 'User', avatar_url: 'a?v=4', html_url: 'h', contributions: 120 }, { login: 'dependabot[bot]', type: 'Bot' }]; + expect(toContributors(raw)).toEqual([{ login: 'ajithakdev', avatar: 'a?v=4&s=96', url: 'h', contributions: 120 }]); + }); +}); diff --git a/apps/site/src/github.ts b/apps/site/src/github.ts new file mode 100644 index 0000000..ed87769 --- /dev/null +++ b/apps/site/src/github.ts @@ -0,0 +1,37 @@ +/** + * Reads public data about the MeterX repo from the GitHub API, cached for the visit. + * @license AGPL-3.0 + */ + +export const REPO = 'ajithakdev/meterx'; + +export interface Issue { number: number; title: string; url: string; labels: string[] } +export interface Contributor { login: string; avatar: string; url: string; contributions: number } + +/** The site never shows em or en dashes, even when an issue title has them. */ +const noDashes = (s: string) => s.replace(/\s*[\u2013\u2014]\s*/g, ', '); + +/** Open issues only: the issues endpoint also returns pull requests, which carry a pull_request key. */ +export function toIssues(raw: unknown): Issue[] { + if (!Array.isArray(raw)) return []; + return raw.filter(i => i && !i.pull_request && typeof i.number === 'number') + .map(i => ({ number: i.number, title: noDashes(String(i.title)), url: String(i.html_url), labels: (i.labels ?? []).map((l: { name: string }) => l.name) })); +} + +/** People only: bots and anonymous entries are left out. */ +export function toContributors(raw: unknown): Contributor[] { + if (!Array.isArray(raw)) return []; + return raw.filter(c => c && c.type === 'User' && typeof c.login === 'string') + .map(c => ({ login: c.login, avatar: `${c.avatar_url}&s=96`, url: c.html_url, contributions: c.contributions })); +} + +/** GET from the GitHub API, cached in sessionStorage so a visit costs one request per endpoint (the limit is 60 an hour). */ +export async function gh(path: string): Promise { + const key = `gh:${path}`; + try { const hit = sessionStorage.getItem(key); if (hit) return JSON.parse(hit) as T; } catch { /* storage blocked */ } + const res = await fetch(`https://api.github.com/${path}`, { headers: { Accept: 'application/vnd.github+json' } }); + if (!res.ok) throw new Error(`GitHub answered ${res.status}`); + const data = await res.json() as T; + try { sessionStorage.setItem(key, JSON.stringify(data)); } catch { /* storage blocked */ } + return data; +} diff --git a/apps/site/src/home.ts b/apps/site/src/home.ts new file mode 100644 index 0000000..109870b --- /dev/null +++ b/apps/site/src/home.ts @@ -0,0 +1,137 @@ +/** + * Home page: scroll drives the real popup through a sample test, the phase log follows it, + * and the closing question measures what a speed buys. + * @license AGPL-3.0 + */ + +import { fmtSpeed } from '@meterx/shared'; +import { capacity, formatDuration } from './capacity'; +import { quickTest } from './quick-test'; +import { PHASES, sampleFrame, sampleResult } from './sample'; + +const $ = (id: string) => document.getElementById(id) as T | null; + +// --- Hero: the act's scroll progress is the test's playhead. + +const ORDER = ['connect', 'latency', 'download', 'upload', 'done'] as const; + +function logState(p: number): void { + const f = sampleFrame(p); + const done = f === 'done', r = sampleResult(); + const current = f === null ? null : done ? 'done' : f.phase; + const reached = current ? ORDER.indexOf(current as typeof ORDER[number]) : -1; + const val: Record = { + connect: reached >= 0 ? 'Cloudflare MAA' : '', + latency: reached >= 1 ? `${(done ? r.ping! : (f as { ping?: number })?.ping ?? r.ping!).toFixed(0)} ms` : '', + download: reached >= 2 ? `${fmtSpeed(done || reached > 2 ? r.downloadSpeed : (f as { downloadSpeed?: number }).downloadSpeed)} Mbps` : '', + upload: reached >= 3 ? `${fmtSpeed(done ? r.uploadSpeed : (f as { uploadSpeed?: number }).uploadSpeed)} Mbps` : '', + done: done ? 'Excellent' : '', + }; + document.querySelectorAll('#phaseLog li').forEach((li, i) => { + li.dataset.state = i < reached || (done && i === reached) ? 'done' : i === reached ? 'active' : 'pending'; + li.querySelector('b')!.textContent = val[li.dataset.phase!]; + }); +} + +function wireHero(): void { + const act = $('hero'), frame = $('heroPopup'); + if (!act || !frame) return; + if (matchMedia('(max-width: 900px), (min-height: 1100px)').matches) { + // Phones and very tall windows do not pin the hero (see home.css), so the popup plays the sample test by itself when it comes into view. + const io = new IntersectionObserver(([e]) => { + if (!e.isIntersecting) return; + frame.contentWindow?.postMessage({ mxDemo: 'play' }, location.origin); + io.disconnect(); + }, { threshold: 0.5 }); + frame.addEventListener('load', () => io.observe(frame)); + return; + } + // The pinned stage is a full window tall but its content is shorter; pull the next section up into + // that empty space so the page does not show a blank band once the pin lets go. + const stage = act.querySelector('[data-sc-stage]'), content = act.querySelector('.split'), next = act.nextElementSibling as HTMLElement | null; + if (stage && content && next) { + const close = () => { + // The popup's caption can sit below the split's box when the popup is drawn smaller, so take the lowest of both. + const bottom = Math.max(...[content, ...content.querySelectorAll('.demo-note, .actions')].map(e => e.getBoundingClientRect().bottom)); + const empty = stage.clientHeight - (bottom - stage.getBoundingClientRect().top); + next.style.marginTop = `${-Math.max(0, Math.round(empty) - 64)}px`; + }; + new ResizeObserver(close).observe(stage); + addEventListener('load', close); + } + let ready = false, last = -1; + frame.addEventListener('load', () => { ready = true; last = -1; }); + const tick = () => { + const p = parseFloat(act.style.getPropertyValue('--sc-p')) || 0; + if (Math.abs(p - last) > 0.002) { + last = p; + logState(p); + // shortcut: the act's progress maps straight onto the test; past PHASES.done the result holds + if (ready) frame.contentWindow?.postMessage({ mxDemo: 'drive', p }, location.origin); + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + logState(0); +} + +// --- The closing question: type a speed, see what it buys; or measure this browser. + +let ruler: CanvasRenderingContext2D | null = null; +/** The input is part of the heading, so it is exactly as wide as its digits. */ +function fitInput(input: HTMLInputElement): void { + ruler ??= document.createElement('canvas').getContext('2d'); + if (!ruler) return; + const cs = getComputedStyle(input); + ruler.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + ruler.letterSpacing = cs.letterSpacing; + input.style.width = `${Math.ceil(ruler.measureText(input.value || '0').width) + 6}px`; +} + +function renderExplorer(input: HTMLInputElement): void { + fitInput(input); + const out = $('explain'); + if (!out) return; + const c = capacity(Number(input.value.replace(/[^\d.]/g, ''))); + out.innerHTML = c + ? [['4K streams at once', String(c.streams4k)], ['HD streams at once', String(c.streamsHd)], ['A 50 GB game download', formatDuration(c.gameSeconds)], ['A 4 GB film download', formatDuration(c.filmSeconds)]] + .map(([k, v]) => `
    • ${k}${v}
    • `).join('') + : '
    • Type a download speed in Mbps
    • '; +} + +function wireClose(): void { + const input = $('speedInput'), btn = $('qtRun'), note = $('qtNote'); + if (!input) return; + input.addEventListener('input', () => renderExplorer(input)); + document.fonts.ready.then(() => renderExplorer(input)); + renderExplorer(input); + btn?.addEventListener('click', async () => { + if (!note) return; + btn.disabled = true; + btn.textContent = 'Measuring…'; + note.textContent = 'Measuring ping, then download…'; + try { + const r = await quickTest(m => { note.innerHTML = `Download ${fmtSpeed(m)} Mbps so far…`; }); + note.innerHTML = `This browser: download ${fmtSpeed(r.mbps)} Mbps, ping ${r.ping.toFixed(0)} ms.`; + input.value = String(Math.round(r.mbps)); + renderExplorer(input); + } catch { + note.textContent = 'The test server could not be reached from here. Install MeterX to test from your toolbar.'; + } finally { + btn.disabled = false; + btn.textContent = 'Measure again'; + } + }); +} + +// Buttons inside the demo pages that would open other extension pages go to the matching site page instead. +window.addEventListener('message', e => { + if (e.origin !== location.origin) return; + if (e.data?.mxDemo === 'openOptions') location.href = '/features/#settings'; + if (e.data?.mxDemo === 'openSidePanel') location.href = '/features/#history'; +}); + +// The scroll engine is a classic script loaded before this module; starting it here keeps the page free of inline script (CSP). +(window as unknown as { ScrollCraft?: { mount(root: HTMLElement): void } }).ScrollCraft?.mount(document.body); +wireHero(); +wireClose(); diff --git a/apps/site/src/quick-test.ts b/apps/site/src/quick-test.ts new file mode 100644 index 0000000..fa3f23b --- /dev/null +++ b/apps/site/src/quick-test.ts @@ -0,0 +1,48 @@ +/** + * A short in-page check against the MeterX worker: a few pings and one download stream for about five seconds. + * Nothing runs until the visitor asks. + * @license AGPL-3.0 + */ + +import { WORKER_URL as WORKER } from '@meterx/shared'; + +async function pingMs(): Promise { + const t = performance.now(); + const r = await fetch(`${WORKER}/ping`, { cache: 'no-store' }); + await r.arrayBuffer(); + return performance.now() - t; +} + +export async function quickTest(onSpeed: (mbps: number) => void): Promise<{ mbps: number; ping: number }> { + await pingMs(); // warm the connection; the first request includes the TLS handshake + const pings: number[] = []; + for (let i = 0; i < 5; i++) pings.push(await pingMs()); + pings.sort((a, b) => a - b); + const ping = pings[2]; + + const ctrl = new AbortController(); + const stop = setTimeout(() => ctrl.abort(), 5500); + const res = await fetch(`${WORKER}/test-file/25MB.bin`, { cache: 'no-store', signal: ctrl.signal }); + const reader = res.body!.getReader(); + const t0 = performance.now(); + const RAMP = 600; // skip slow start so the figure reflects the steady rate + let bytes = 0, rampBytes = 0, mbps = 0, last = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + const t = performance.now() - t0; + if (t < RAMP) { rampBytes = bytes; continue; } + mbps = ((bytes - rampBytes) * 8) / ((t - RAMP) * 1000); + if (t - last > 200) { onSpeed(mbps); last = t; } + } + } catch (e) { + if (!(e instanceof DOMException && e.name === 'AbortError')) throw e; + } finally { + clearTimeout(stop); + } + // A fast line can finish inside the ramp window; then the whole transfer is the best estimate. + if (!mbps && bytes) mbps = (bytes * 8) / ((performance.now() - t0) * 1000); + return { mbps, ping }; +} diff --git a/apps/site/src/sample.test.ts b/apps/site/src/sample.test.ts new file mode 100644 index 0000000..863d9d3 --- /dev/null +++ b/apps/site/src/sample.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { PHASES, sampleFrame, sampleHistory, sampleResult } from './sample'; + +describe('sampleFrame', () => { + it('walks the real phase order and ends on done', () => { + expect(sampleFrame(0)).toBeNull(); + const phases = [0.05, 0.1, 0.3, 0.7].map(p => (sampleFrame(p) as { phase: string }).phase); + expect(phases).toEqual(['connect', 'latency', 'download', 'upload']); + expect(sampleFrame(PHASES.done)).toBe('done'); + }); + it('never shows a figure above the final result by more than the wobble', () => { + for (let p = 0.2; p < 0.9; p += 0.01) { + const f = sampleFrame(p) as { downloadSpeed?: number }; + if (f.downloadSpeed) expect(f.downloadSpeed).toBeLessThan(sampleResult().downloadSpeed! * 1.04); + } + }); +}); + +describe('sampleHistory', () => { + it('is stable, newest first, with consistent grades', () => { + const h = sampleHistory(1e12); + expect(h).toHaveLength(30); + expect(h[0].timestamp).toBeGreaterThan(h[1].timestamp); + expect(sampleHistory(1e12)).toEqual(h); + }); +}); diff --git a/apps/site/src/sample.ts b/apps/site/src/sample.ts new file mode 100644 index 0000000..df97641 --- /dev/null +++ b/apps/site/src/sample.ts @@ -0,0 +1,61 @@ +/** + * Sample data for the demo pages: a week of results on a 300/100 plan and one test played frame by frame. + * Everything here is labelled as sample data wherever it is shown. + * @license AGPL-3.0 + */ + +import { gradeBufferbloat, type HistoryEntry, type IspPlan, type TestProgress } from '@meterx/shared'; + +export const SAMPLE_PLAN: IspPlan = { downloadMbps: 300, uploadMbps: 100, providerName: 'Sample ISP' }; + +/** Thirty results, newest first, one every 5.6 hours, with one slow evening. Deterministic. */ +export function sampleHistory(now = Date.now()): HistoryEntry[] { + let seed = 11; + const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + return Array.from({ length: 30 }, (_, i) => { + const ping = 18 + rnd() * 8; + const delta = 30 + rnd() * 20; + return { + timestamp: now - (i + 1) * 5.6 * 3600e3, + downloadSpeed: 262 + rnd() * 40 - (i === 9 ? 120 : 0), + uploadSpeed: 88 + rnd() * 10, + ping, jitter: 1.5 + rnd() * 2.5, packetLoss: 0, + bufferbloatDelta: delta, bufferbloatGrade: gradeBufferbloat(delta), + loadedPingDown: ping + delta, loadedPingUp: ping + delta * 0.6, + ipv4Ok: true, ipv6Ok: true, edge: 'MAA', isp: 'Sample ISP', + }; + }); +} + +const FINAL = { downloadSpeed: 284.6, uploadSpeed: 92.1, ping: 19, jitter: 2.1, packetLoss: 0, loadedPingDown: 61, loadedPingUp: 46 }; + +/** The result the sample test ends on. */ +export function sampleResult(now = Date.now()): HistoryEntry { + const delta = Math.max(FINAL.loadedPingDown, FINAL.loadedPingUp) - FINAL.ping; + return { ...FINAL, timestamp: now, bufferbloatDelta: delta, bufferbloatGrade: gradeBufferbloat(delta), ipv4Ok: true, ipv6Ok: true, edge: 'MAA', isp: 'Sample ISP' }; +} + +const clamp = (v: number) => Math.min(1, Math.max(0, v)); +const rise = (t: number) => 1 - Math.exp(-5 * t); +const wobble = (t: number) => 1 + 0.03 * Math.sin(t * 37); + +/** Phase boundaries as shares of the whole test, matching the real timings (about 1 s, 2 s, 9 s, 9 s). */ +export const PHASES = { start: 0.04, latency: 0.08, download: 0.2, upload: 0.56, done: 0.9 } as const; + +/** + * The sample test at position p (0 to 1): null before it starts, 'done' once it has finished, + * otherwise the progress message the real service worker would send at that moment. + */ +export function sampleFrame(p: number): TestProgress | null | 'done' { + if (p < PHASES.start) return null; + if (p >= PHASES.done) return 'done'; + if (p < PHASES.latency) return { phase: 'connect', status: 'Connecting' }; + const lt = clamp((p - PHASES.latency) / (PHASES.download - PHASES.latency)); + const ping = FINAL.ping + 9 * (1 - lt), jitter = FINAL.jitter + 1.5 * (1 - lt); + if (p < PHASES.download) return { phase: 'latency', status: 'Measuring ping', ping, jitter, packetLoss: 0 }; + const dt = clamp((p - PHASES.download) / (PHASES.upload - PHASES.download)); + const down = FINAL.downloadSpeed * rise(dt) * wobble(dt); + if (p < PHASES.upload) return { phase: 'download', status: 'Measuring download', ping, jitter, packetLoss: 0, downloadSpeed: down }; + const ut = clamp((p - PHASES.upload) / (PHASES.done - PHASES.upload)); + return { phase: 'upload', status: 'Measuring upload', ping, jitter, packetLoss: 0, downloadSpeed: FINAL.downloadSpeed, uploadSpeed: FINAL.uploadSpeed * rise(ut) * wobble(ut) }; +} diff --git a/apps/site/src/share.ts b/apps/site/src/share.ts new file mode 100644 index 0000000..38c9ecf --- /dev/null +++ b/apps/site/src/share.ts @@ -0,0 +1,37 @@ +/** + * Share viewer: the result lives in the link's hash, so it never reaches a server. + * @license AGPL-3.0 + */ + +import { parseShareUrl, qualityOf } from '@meterx/shared'; + +const $ = (id: string) => document.getElementById(id); +const fmt = (v: number | undefined, d: number) => (v === undefined ? null : v.toFixed(d)); +const speed = (v: number | undefined) => (v === undefined ? null : v >= 100 ? v.toFixed(0) : v.toFixed(1)); + +function set(id: string, v: string | null): void { + const el = $(id); + if (!el) return; + if (v === null) { el.textContent = '-'; el.classList.add('dash'); } else { el.textContent = v; el.classList.remove('dash'); } +} + +const r = parseShareUrl(location.href); +if (r) { + $('empty')!.hidden = true; + $('result')!.hidden = false; + set('down', speed(r.downloadSpeed)); + set('up', speed(r.uploadSpeed)); + set('ping', fmt(r.ping, 0)); + set('jitter', fmt(r.jitter, 1)); + set('loss', fmt(r.packetLoss, 1)); + const when = new Date(r.timestamp); + $('when')!.textContent = `${when.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}, ${when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`; + const where = [r.isp, r.edge ? `Cloudflare ${r.edge}` : ''].filter(Boolean).join(' · '); + $('where')!.textContent = where; + const q = qualityOf(r); + const pill = $('quality')!; + if (q) { pill.textContent = q.label; pill.className = `pill q-${q.cls}`; } else pill.hidden = true; + const grade = $('grade')!; + if (r.bufferbloatGrade) grade.textContent = `Load grade ${r.bufferbloatGrade}`; else grade.hidden = true; + document.title = `${speed(r.downloadSpeed) ?? '-'} Mbps on MeterX`; +} diff --git a/apps/site/src/stores.test.ts b/apps/site/src/stores.test.ts new file mode 100644 index 0000000..880ea66 --- /dev/null +++ b/apps/site/src/stores.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { detectStore } from './stores'; + +describe('detectStore', () => { + it('tells the browsers apart, Edge and Opera before Chrome', () => { + expect(detectStore('Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0')).toBe('firefox'); + expect(detectStore('Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/130.0 Safari/537.36 Edg/130.0')).toBe('edge'); + expect(detectStore('Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/130.0 Safari/537.36 OPR/114.0')).toBe('opera'); + expect(detectStore('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/130.0 Safari/537.36')).toBe('chrome'); + expect(detectStore('Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 Version/18.0 Mobile Safari/604.1')).toBeNull(); + }); +}); diff --git a/apps/site/src/stores.ts b/apps/site/src/stores.ts new file mode 100644 index 0000000..362da7f --- /dev/null +++ b/apps/site/src/stores.ts @@ -0,0 +1,35 @@ +/** + * Where MeterX is listed and where it is going. An empty url means the listing is not live yet. + * @license AGPL-3.0 + */ + +export type StoreId = 'firefox' | 'edge' | 'opera' | 'chrome'; + +export const STORES: Record = { + firefox: { name: 'Firefox', url: 'https://addons.mozilla.org/firefox/addon/meterx-by-ajithakdev/' }, + edge: { name: 'Edge', url: 'https://microsoftedge.microsoft.com/addons/detail/glkpolbkpachomgmmhdkmjnghejbhlfc' }, + opera: { name: 'Opera', url: '' }, // shortcut: fill in from the Opera Add-ons listing once it is public + chrome: { name: 'Chrome', url: '' }, // not listed yet +}; + +/** Every place on the Get page, in dial order. `browsers` are the ones that install from that store. */ +export interface Place { id: string; store: string; browsers: string; logo: string; colour: string; url: string; note: string } +export const PLACES: Place[] = [ + { id: 'firefox', store: 'Firefox Add-ons', browsers: 'Firefox on Windows, macOS and Linux', logo: 'firefoxbrowser', colour: '#FF7139', url: STORES.firefox.url, note: 'Live' }, + { id: 'edge', store: 'Microsoft Edge Add-ons', browsers: 'Microsoft Edge', logo: 'microsoftedge', colour: '#0C8DD7', url: STORES.edge.url, note: 'Live' }, + { id: 'opera', store: 'Opera Add-ons', browsers: 'Opera and Opera GX', logo: 'opera', colour: '#FF1B2D', url: STORES.opera.url, note: 'Listing link coming soon' }, + { id: 'chrome', store: 'Chrome Web Store', browsers: 'Chrome, plus Brave, Vivaldi and Arc', logo: 'googlechrome', colour: '#4285F4', url: STORES.chrome.url, note: 'Planned for the next release' }, + { id: 'brave', store: 'Brave', browsers: 'Installs from the Chrome Web Store', logo: 'brave', colour: '#FB542B', url: '', note: 'With the Chrome listing' }, + { id: 'vivaldi', store: 'Vivaldi', browsers: 'Installs from the Chrome Web Store', logo: 'vivaldi', colour: '#EF3939', url: '', note: 'With the Chrome listing' }, + { id: 'android', store: 'Firefox for Android', browsers: 'Firefox on Android phones and tablets', logo: 'firefoxbrowser', colour: '#FF7139', url: '', note: 'Planned' }, + { id: 'safari', store: 'Safari', browsers: 'Safari on macOS and iOS', logo: 'safari', colour: '#006CFF', url: '', note: 'Later: needs an Apple developer account' }, +]; + +/** The visitor's browser, from the user agent. Order matters: Edge and Opera also say "Chrome". */ +export function detectStore(ua: string): StoreId | null { + if (/Firefox\//.test(ua)) return 'firefox'; + if (/Edg\//.test(ua)) return 'edge'; + if (/OPR\//.test(ua)) return 'opera'; + if (/Chrome\//.test(ua)) return 'chrome'; + return null; +} diff --git a/apps/site/static/404.html b/apps/site/static/404.html new file mode 100644 index 0000000..1cff7f4 --- /dev/null +++ b/apps/site/static/404.html @@ -0,0 +1,29 @@ + + + + + +Page not found · MeterX + + + + + + + + + + + + + + + + +
      +

      This page does not exist

      The link may be old or mistyped.

      + +
      + + + diff --git a/apps/site/static/_headers b/apps/site/static/_headers new file mode 100644 index 0000000..646050e --- /dev/null +++ b/apps/site/static/_headers @@ -0,0 +1,11 @@ +/* + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=() + Content-Security-Policy: default-src 'self'; connect-src 'self' https://meterx-speedtest.meterx-ajithakdev.workers.dev https://api.github.com; img-src 'self' data: https://avatars.githubusercontent.com; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; frame-ancestors 'self'; base-uri 'none'; form-action 'none' + Strict-Transport-Security: max-age=31536000 + +/fonts/* + Cache-Control: public, max-age=31536000, immutable +/img/* + Cache-Control: public, max-age=86400 diff --git a/apps/site/static/_partials/footer.html b/apps/site/static/_partials/footer.html new file mode 100644 index 0000000..791d33b --- /dev/null +++ b/apps/site/static/_partials/footer.html @@ -0,0 +1,16 @@ + + diff --git a/apps/site/static/_partials/header.html b/apps/site/static/_partials/header.html new file mode 100644 index 0000000..c5aaad5 --- /dev/null +++ b/apps/site/static/_partials/header.html @@ -0,0 +1,15 @@ + +
      + +
      diff --git a/apps/site/static/about/index.html b/apps/site/static/about/index.html new file mode 100644 index 0000000..5b9122b --- /dev/null +++ b/apps/site/static/about/index.html @@ -0,0 +1,40 @@ + + + + + +About MeterX: how it measures + + + + + + + + + + + + + + + + + + + + +
      +

      About MeterX

      One person built MeterX to answer a plain question honestly: is the connection I pay for the connection I get?

      +
      +

      How it measures

      +

      A twenty second test in four phases, with latency measured under load. The details are on How it works.

      +

      Open source

      +

      The extension, the worker and the self-hosted server are open source under AGPL-3.0 at github.com/ajithakdev/meterx. Bug reports and ideas go in the issues there.

      +

      Who made it

      +

      Ajith Kumar K, a backend engineer. More of his work is at ajithakdev.com.

      +
      +
      + + + diff --git a/apps/site/static/contribute/index.html b/apps/site/static/contribute/index.html new file mode 100644 index 0000000..0897434 --- /dev/null +++ b/apps/site/static/contribute/index.html @@ -0,0 +1,87 @@ + + + + + +Contribute to MeterX + + + + + + + + + + + + + + + + + + + +
      +
      +

      Build MeterX with us

      +

      MeterX is open source and made in the open. Fixes, ideas, translations, docs and design are all welcome, and small first steps are marked for you.

      +
      + +
      +
      +
      Stars
      See
      +
      Forks
      See
      +
      Open issues
      See
      +
      License
      AGPL-3.0
      +
      +
      + +
      +
      +
      +

      Good places to start

      +

      Issues marked good first issue are small and well described. Help wanted means the owner would love a hand.

      +
      + See every open one +
      + +
      + +
      +

      From zero to a pull request

      +
        +
      1. Fork and clone

        Fork ajithakdev/meterx, then git clone your fork and run npm install.

      2. +
      3. Build and load

        npm run build, then load apps/extension/dist/ as an unpacked extension in Chrome, Edge or Firefox.

      4. +
      5. Change and check

        Make the change on a branch. npm test and npm run lint must pass.

      6. +
      7. Open a pull request

        Say what changed and why, and link the issue. Small pull requests get reviewed fastest.

      8. +
      +

      The full guide, with the repo layout and every command, is in CONTRIBUTING.md.

      +
      + +
      +

      The people who build it

      Everyone with a merged change, straight from GitHub.

      + +
      + +
      +

      Not a coder?

      +
        +
      • Report a bugSomething looks or measures wrong? Open an issue with your browser and what you saw.
      • +
      • Suggest an ideaMissing a feature? Describe the problem it solves in a new issue.
      • +
      • TranslateMeterX is English only today. Translations are tracked under the i18n label.
      • +
      • Spread the wordShare meterx.ajithakdev.com/get, or leave a review on your browser's store.
      • +
      +
      +
      + + + + diff --git a/apps/site/static/faq/index.html b/apps/site/static/faq/index.html new file mode 100644 index 0000000..0e02979 --- /dev/null +++ b/apps/site/static/faq/index.html @@ -0,0 +1,55 @@ + + + + + +MeterX questions and answers + + + + + + + + + + + + + + + + + + + +
      +
      +

      Questions, answered

      +

      Short answers about MeterX. Anything missing? Ask in the issue tracker.

      +
      +
      +
      +
      Is it free?

      Yes, with no paid tier, no account and no ads. The code is open source under AGPL-3.0.

      +
      Which browsers does it run in?

      Firefox, Microsoft Edge and Opera today, and Chrome soon. It also runs in other browsers built on Chromium. Get it for your browser.

      +
      What leaves my browser?

      Only the test traffic: bytes downloaded from and uploaded to the test server, and the pings. Results are stored in your browser and nowhere else. The privacy page has the details.

      +
      Why is my result different from other speed tests?

      Each test uses its own servers, number of streams and length. MeterX tests against the nearest Cloudflare location with several streams for a fixed time and trims the slow start. How it measures.

      +
      What is bufferbloat?

      When a line is busy, routers can queue too much data, so every request waits in line. Speed looks fine but calls and games lag. MeterX measures ping while the line is full and grades the rise from A to F.

      +
      Can I use my own test server?

      Yes. The repository includes a small server you can run on your own machine or network, and the settings page lets you point MeterX at it.

      +
      How do I share a result?

      Press Share in the popup. The link carries the result inside it, after the # sign, so it never reaches a server, and this site shows it to whoever opens it.

      +
      Where do I report a bug?

      In the issue tracker on GitHub.

      +
      + +
      +
      + + + + diff --git a/apps/site/static/features/index.html b/apps/site/static/features/index.html new file mode 100644 index 0000000..0f4643d --- /dev/null +++ b/apps/site/static/features/index.html @@ -0,0 +1,84 @@ + + + + + +MeterX features: history, plan check, reports, privacy + + + + + + + + + + + + + + + + + + + +
      +
      +

      Everything MeterX does

      +

      These are the real extension pages running on this site with sample data. Click around: nothing here touches your own results.

      +
      + +
      +
      +
      The side panel, with sample data.
      +
      +

      History, trend and your plan

      +

      The side panel keeps up to 200 results, charts them, and compares the averages with the speeds you pay for.

      +
        +
      • RangesThe last 24 hours, 7 days, 30 days or everything.
      • +
      • Against your planWhat share of the advertised download and upload actually arrives.
      • +
      • Run a test from hereThe panel switches to live figures while a test runs, then back to your history.
      • +
      • ExportTry it: the PDF, CSV and JSON buttons download real files made from the sample data.
      • +
      +
      +
      +
      + +
      +
      +
      +

      Reports you can hand to your provider

      +

      A PDF with averages, ranges, a trend chart and every result. Each page carries a report ID that changes if any figure is edited.

      +
        +
      • CSVOne row per test, units in every column name, local time next to UTC. Opens straight in Excel.
      • +
      • JSONThe same fields plus a summary, with a versioned format for scripts.
      • +
      +
      +
      First page of a MeterX connection report with averages, latency under load, a plan comparison and a speed chart
      Page one of a report, made from sample data.
      +
      +
      + +
      +
      +

      Settings, schedule and your own server

      +

      Choose the test server, enter your plan, and run tests on a timer with a notice when quality drops to Poor.

      +
      + +

      The settings page, with sample settings. Saving here changes nothing on your device.

      +
      + +
      +

      Private by design

      +
        +
      • No accountInstall it and press the icon. There is nothing to sign up for.
      • +
      • Results stay in your browserUp to 200 results in the extension's own storage, and nowhere else.
      • +
      • No logs on the serverThe test server sends and receives test bytes and keeps nothing.
      • +
      • Open sourceEvery line is public under AGPL-3.0. Read the code.
      • +
      +

      Get MeterX, free

      +
      +
      + + + diff --git a/apps/site/static/get/index.html b/apps/site/static/get/index.html new file mode 100644 index 0000000..e2c6d0c --- /dev/null +++ b/apps/site/static/get/index.html @@ -0,0 +1,63 @@ + + + + + +Get MeterX for your browser + + + + + + + + + + + + + + + + + + + + + +
      +
      +
      +
      + + +
      +
      + +
      +
      +

      Get MeterX

      +

      Free for every major browser. Pick yours.

      + Choose your browser +
      + +
      +

      Available now

      +
        +
        + +
        +
        + +
        +

        Coming next

        Where MeterX goes after this release. Every listing stays free.

        +
          +
          +
          + + + + diff --git a/apps/site/static/how-it-works/index.html b/apps/site/static/how-it-works/index.html new file mode 100644 index 0000000..f4673ca --- /dev/null +++ b/apps/site/static/how-it-works/index.html @@ -0,0 +1,103 @@ + + + + + +How MeterX measures your connection + + + + + + + + + + + + + + + + + + + +
          +
          +

          How MeterX measures

          +

          A full test takes about twenty seconds and runs in four phases. Each one answers a different question about your line.

          +
          + +
          +

          Where the twenty seconds go

          Each bar shows the phase's share of the whole test.

          +
            +
          • Under a second

            Connect

            MeterX opens a connection to the nearest Cloudflare location, or to your own server.

          • +
          • 2 seconds

            Ping and jitter

            A run of small requests measures the delay and how much it wobbles from one request to the next. Requests that never return count as loss.

          • +
          • About 9 seconds

            Download

            Four parallel streams fill your line for a fixed time. The slow start and the last moments are trimmed, so the figure is the steady speed.

          • +
          • About 9 seconds

            Upload

            Three streams do the same upward while pings keep running, to catch the delay that builds under load.

          • +
          +
          + +
          +
          +
          +

          Latency under load

          A line can look fast and still stutter on a call while something downloads. That is bufferbloat: routers queue too much data, so every request waits in line.

          +

          MeterX keeps pinging during the download and upload and grades the worst rise above the ping at rest.

          + + + + + + + + + + +
          How the load grade is set
          GradeRise in ping under loadWhat you notice
          A5 ms or lessNothing, even on calls during big downloads
          B30 ms or lessAlmost nothing
          C60 ms or lessSmall hiccups in games and calls when the line is busy
          D200 ms or lessCalls stutter while someone downloads
          FMore than 200 msEverything lags when the line is busy
          +
          +
          +
          + + Ping during a sample test: about 19 ms at rest, rising to 61 ms while downloading and 46 ms while uploading + + + 0255075 + + + RestDownloadingUploading + +42 ms under load + +
          Ping in ms during a sample test. The rise of 42 ms gives grade C.
          +
          +
          + + Ping during a sample test on a line with good queue management: about 19 ms at rest and 23 ms under load + + + 0255075 + + + RestDownloadingUploading + +4 ms under load + +
          The same test on a line with good queue management: ping barely moves, grade A.
          +
          +
          +
          +
          + +
          +

          Where it tests

          +
            +
          • The nearest Cloudflare locationBy default, a small Cloudflare Worker answers from the location closest to you. It sends and receives test bytes and keeps nothing.
          • +
          • Or your own serverRun the self-hosted server from the repository on your machine or network, and point MeterX at it in settings.
          • +
          • Why results differ from other testsEach test uses its own servers, number of streams and length. MeterX uses several streams for a fixed time, which is close to how your browser loads pages and files.
          • +
          • What a quality label meansExcellent needs 50 Mbps down, 20 up and under 30 ms ping. Good needs 20, 10 and under 60 ms. Fair needs 5, 2 and under 150 ms.
          • +
          +

          Get MeterX, free

          +
          +
          + + + diff --git a/apps/site/static/img/apple-touch-icon.png b/apps/site/static/img/apple-touch-icon.png new file mode 100644 index 0000000..f1db1b9 Binary files /dev/null and b/apps/site/static/img/apple-touch-icon.png differ diff --git a/apps/site/static/img/glyph-on-dark.svg b/apps/site/static/img/glyph-on-dark.svg new file mode 100644 index 0000000..bd6ac81 --- /dev/null +++ b/apps/site/static/img/glyph-on-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/site/static/img/glyph-on-light.svg b/apps/site/static/img/glyph-on-light.svg new file mode 100644 index 0000000..dbab0d3 --- /dev/null +++ b/apps/site/static/img/glyph-on-light.svg @@ -0,0 +1 @@ + diff --git a/apps/site/static/img/icon-32.png b/apps/site/static/img/icon-32.png new file mode 100644 index 0000000..3446f1c Binary files /dev/null and b/apps/site/static/img/icon-32.png differ diff --git a/apps/site/static/img/mark.svg b/apps/site/static/img/mark.svg new file mode 100644 index 0000000..e22dfeb --- /dev/null +++ b/apps/site/static/img/mark.svg @@ -0,0 +1 @@ + diff --git a/apps/site/static/img/maskable-512.png b/apps/site/static/img/maskable-512.png new file mode 100644 index 0000000..27d0908 Binary files /dev/null and b/apps/site/static/img/maskable-512.png differ diff --git a/apps/site/static/img/og.png b/apps/site/static/img/og.png new file mode 100644 index 0000000..51fb54f Binary files /dev/null and b/apps/site/static/img/og.png differ diff --git a/apps/site/static/img/shot-report.png b/apps/site/static/img/shot-report.png new file mode 100644 index 0000000..ed6fefb Binary files /dev/null and b/apps/site/static/img/shot-report.png differ diff --git a/apps/site/static/img/stores/brave.svg b/apps/site/static/img/stores/brave.svg new file mode 100644 index 0000000..fa798a4 --- /dev/null +++ b/apps/site/static/img/stores/brave.svg @@ -0,0 +1 @@ +Brave \ No newline at end of file diff --git a/apps/site/static/img/stores/firefoxbrowser.svg b/apps/site/static/img/stores/firefoxbrowser.svg new file mode 100644 index 0000000..3cbc89c --- /dev/null +++ b/apps/site/static/img/stores/firefoxbrowser.svg @@ -0,0 +1 @@ +Firefox Browser \ No newline at end of file diff --git a/apps/site/static/img/stores/googlechrome.svg b/apps/site/static/img/stores/googlechrome.svg new file mode 100644 index 0000000..919ac2a --- /dev/null +++ b/apps/site/static/img/stores/googlechrome.svg @@ -0,0 +1 @@ +Google Chrome \ No newline at end of file diff --git a/apps/site/static/img/stores/microsoftedge.svg b/apps/site/static/img/stores/microsoftedge.svg new file mode 100644 index 0000000..2b0cd03 --- /dev/null +++ b/apps/site/static/img/stores/microsoftedge.svg @@ -0,0 +1 @@ +Microsoft Edge \ No newline at end of file diff --git a/apps/site/static/img/stores/opera.svg b/apps/site/static/img/stores/opera.svg new file mode 100644 index 0000000..d1194ee --- /dev/null +++ b/apps/site/static/img/stores/opera.svg @@ -0,0 +1 @@ +Opera \ No newline at end of file diff --git a/apps/site/static/img/stores/safari.svg b/apps/site/static/img/stores/safari.svg new file mode 100644 index 0000000..5d15438 --- /dev/null +++ b/apps/site/static/img/stores/safari.svg @@ -0,0 +1 @@ +Safari \ No newline at end of file diff --git a/apps/site/static/img/stores/vivaldi.svg b/apps/site/static/img/stores/vivaldi.svg new file mode 100644 index 0000000..167e8ad --- /dev/null +++ b/apps/site/static/img/stores/vivaldi.svg @@ -0,0 +1 @@ +Vivaldi \ No newline at end of file diff --git a/apps/site/static/index.html b/apps/site/static/index.html new file mode 100644 index 0000000..11ad5bd --- /dev/null +++ b/apps/site/static/index.html @@ -0,0 +1,97 @@ + + + + + +MeterX: internet speed test in your browser toolbar + + + + + + + + + + + + + + + + + + + + + + + +
          + +
          +
          +
          +
          +

          Your internet,
          honestly measured.

          +

          MeterX is a free, open-source browser extension that tests download, upload, ping, jitter and bufferbloat from your toolbar. No account, nothing tracked.

          +
            +
          1. Connect
          2. +
          3. Ping and jitter
          4. +
          5. Download
          6. +
          7. Upload
          8. +
          9. Result
          10. +
          + +
          +
          + +
          The real MeterX popup, running a sample test.
          +
          +
          +
          +
          + + +
          +
          +
          + +
          The real side panel with sample data. Try the tabs, Run test and Export.
          +
          +
          +

          Every test, kept on your device

          +

          The side panel keeps up to 200 results with a trend chart, so a slow evening stands out from a slow line.

          +
            +
          • Against your planEnter the speeds you pay for and see what share of them arrives.
          • +
          • Reports you can hand overA PDF with averages and a trend, or CSV and JSON with units in every column.
          • +
          • Tests on a timerEvery 30 minutes, 2 hours or once a day, with a notice when quality drops.
          • +
          • Share a resultOne link carries the whole result inside it, so it never passes through a server.
          • +
          +

          Everything MeterX does

          +
          +
          +
          + +
          +
          +

          Mbps do?

          +
            +

            Counts use the extension's own rules: 25 Mbps for each 4K stream and 5 Mbps for HD.

            +
            + Get MeterX, free + +
            +

            A five second check from this page fills in your own speed. It uses up to 25 MB.

            +
            +
            + +
            + + + + + diff --git a/apps/site/static/privacy/index.html b/apps/site/static/privacy/index.html new file mode 100644 index 0000000..8129f88 --- /dev/null +++ b/apps/site/static/privacy/index.html @@ -0,0 +1,63 @@ + + + + + +MeterX privacy policy + + + + + + + + + + + + + + + + + + + + +
            +

            Privacy policy

            MeterX collects no personal data. Your results stay in your browser.

            Last updated 24 October 2026. Applies to MeterX 2027.0.0 and later.

            +
            +

            What the extension stores

            +
              +
            • Your test results (download, upload, ping, jitter, packet loss, latency under load, the Cloudflare location and the network name the server reports), up to 200 of them, in your browser's local extension storage.
            • +
            • Your settings: the test server address, your ISP plan if you enter one, the schedule for automatic tests and whether to notify you. These are in your browser's synced extension storage, so they follow your browser account if you use sync.
            • +
            +

            Nothing is sent to MeterX or anyone else. Uninstalling the extension, or clearing its storage, deletes all of it.

            +

            What a test sends

            +

            A test exchanges data with the test server: it downloads test bytes, uploads test bytes that the server throws away, and sends small ping requests. As with any web request, the server sees your IP address. The default server is a Cloudflare Worker that:

            +
              +
            • keeps no request logs and no database, and stores nothing about you;
            • +
            • returns your IP address, its type (IPv4 or IPv6), the network name, city, country and Cloudflare location, so the extension can show them. These come from Cloudflare's own lookup of your IP and are not kept by the worker.
            • +
            +

            Cloudflare runs the network the worker is on. Its handling of traffic is covered by Cloudflare's privacy policy. If you set a custom server, your tests go to that server instead, and its owner's policy applies.

            +

            Permissions and why

            +
              +
            • Storage: to keep your results and settings.
            • +
            • Alarms: to run tests on the schedule you choose. Off by default.
            • +
            • Notifications: to tell you when a scheduled test finds that quality has dropped, if you turn it on.
            • +
            • Side panel: to show your history beside the page.
            • +
            • Access to the test server's address: to run tests. Access to a custom server is asked for only when you add one.
            • +
            +

            Share links and reports

            +

            A share link carries the result inside the link itself, after the # sign, which browsers never send to a server. This site reads it in your browser to show the result. PDF, CSV and JSON reports are made on your device and saved where you choose.

            +

            This website

            +

            This site uses no cookies, no analytics and no trackers. The quick test on the home page runs only when you press its button and talks to the same test server. The Contribute page asks GitHub's public API for the repository's issues and contributors from your browser, so GitHub sees that request as it would any visit to github.com.

            +

            Children

            +

            MeterX is not aimed at children and collects no personal data from anyone.

            +

            Changes and contact

            +

            Changes to this policy are listed in the repository's history and the date above is updated. Questions go to the issue tracker or ajithkumarks2579@gmail.com.

            +
            +
            + + + diff --git a/apps/site/static/r/index.html b/apps/site/static/r/index.html new file mode 100644 index 0000000..3de39ef --- /dev/null +++ b/apps/site/static/r/index.html @@ -0,0 +1,51 @@ + + + + + +A MeterX speed test result + + + + + + + + + + + + + + + + +
            + +
            +

            No result in this link

            This page shows results shared from MeterX. The link you opened has no result in it, or it was cut short.

            + +
            +
            + + + + diff --git a/apps/site/static/robots.txt b/apps/site/static/robots.txt new file mode 100644 index 0000000..3e99f62 --- /dev/null +++ b/apps/site/static/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Allow: / +Disallow: /r/ +Disallow: /app/ +Sitemap: https://meterx.ajithakdev.com/sitemap.xml diff --git a/apps/site/static/sc/scrollcraft.css b/apps/site/static/sc/scrollcraft.css new file mode 100644 index 0000000..62ee4f8 --- /dev/null +++ b/apps/site/static/sc/scrollcraft.css @@ -0,0 +1,432 @@ +/* ============================================================================ + scrollcraft.css: the taste floor + ---------------------------------------------------------------------------- + Two layers, and the split matters: + + TOKENS what you override per brand. Colour roles, the type ramp, the + spacing scale, elevation, motion. A brand is ~12 values. + DEVICES what the engine drives. Do not restyle these to taste; they are + the mechanism. Style your own markup instead. + + Nothing here draws a "component". There are no card, badge, or pill classes, + because a stylesheet that ships those is how every page built on it ends up + with the same shapes. You get a floor and a vocabulary; the composition is + yours. + ========================================================================== */ + +/* ---------------------------------------------------------------- tokens -- */ +:root { + /* --- colour roles. Override these six and the page is rebranded. -------- */ + --sc-canvas: #08090b; /* the page ground. Drift interpolates this. */ + --sc-surface: #101217; /* anything raised off the ground */ + --sc-ink: #f4f2ef; /* primary text */ + --sc-ink-soft: #9a9ba1; /* secondary text. Tinted, never flat gray. */ + --sc-accent: #d8ff3e; /* ONE accent. It owns a region, not confetti. */ + --sc-accent-ink: #08090b; /* text that sits on the accent */ + + --sc-hairline: color-mix(in oklab, var(--sc-ink) 12%, transparent); + --sc-hairline-strong: color-mix(in oklab, var(--sc-ink) 22%, transparent); + + /* --- type. Two families max. Display carries voice, text carries prose. - */ + --sc-font-display: "Instrument Sans", "Geist", system-ui, sans-serif; + --sc-font-text: "Geist", system-ui, sans-serif; + --sc-font-mono: "Geist Mono", ui-monospace, monospace; + + /* Fluid ramp. Tracking tightens as size grows because a face set at 6rem + with 0 tracking reads loose; this is optical correction, not decoration. */ + --sc-t-xs: clamp(0.75rem, 0.72rem + 0.15vw, 0.82rem); + --sc-t-sm: clamp(0.875rem, 0.84rem + 0.18vw, 0.95rem); + --sc-t-base: clamp(1rem, 0.96rem + 0.22vw, 1.125rem); + --sc-t-lg: clamp(1.2rem, 1.1rem + 0.5vw, 1.5rem); + --sc-t-xl: clamp(1.6rem, 1.35rem + 1.2vw, 2.25rem); + --sc-t-2xl: clamp(2.1rem, 1.6rem + 2.4vw, 3.4rem); + --sc-t-3xl: clamp(2.8rem, 1.9rem + 4.2vw, 5rem); + --sc-t-4xl: clamp(3.4rem, 1.9rem + 6.6vw, 7.5rem); + + --sc-track-tight: -0.035em; + --sc-track-snug: -0.02em; + --sc-track-normal: -0.005em; + --sc-track-wide: 0.08em; + + --sc-leading-none: 0.94; + --sc-leading-tight: 1.06; + --sc-leading-body: 1.62; + + --sc-measure: 62ch; + + /* --- space. 4px base: the useful middle steps an 8-only scale misses. --- */ + --sc-1: 0.25rem; --sc-2: 0.5rem; --sc-3: 0.75rem; --sc-4: 1rem; + --sc-5: 1.5rem; --sc-6: 2rem; --sc-7: 3rem; --sc-8: 4rem; + --sc-9: 6rem; --sc-10: 8rem; --sc-11: 12rem; + + /* Section rhythm is fluid so a phone doesn't inherit desktop air. */ + --sc-section: clamp(4.5rem, 9vw, 11rem); + --sc-gutter: clamp(1.25rem, 5vw, 5.5rem); + --sc-maxw: 82rem; + + /* --- radius. Pick one scale for the page and hold it. ------------------- */ + --sc-r-sm: 6px; --sc-r-md: 12px; --sc-r-lg: 20px; --sc-r-pill: 999px; + + /* --- elevation. Every shadow has an offset AND a soft blur, and is tinted + to the canvas hue. A zero-offset coloured halo is decoration, not + depth, so there isn't one here. ---------------------------------- */ + --sc-shadow-color: 220 40% 2%; + --sc-e1: 0 1px 2px hsl(var(--sc-shadow-color) / 0.28), + 0 2px 6px -1px hsl(var(--sc-shadow-color) / 0.20); + --sc-e2: 0 2px 4px hsl(var(--sc-shadow-color) / 0.24), + 0 8px 18px -4px hsl(var(--sc-shadow-color) / 0.30); + --sc-e3: 0 4px 8px hsl(var(--sc-shadow-color) / 0.22), + 0 18px 40px -8px hsl(var(--sc-shadow-color) / 0.38), + 0 48px 90px -24px hsl(var(--sc-shadow-color) / 0.30); + + /* Edge light: a 1px top highlight sells a raised surface better than any + amount of blur, because real raised things catch light on their lip. */ + --sc-edge: inset 0 1px 0 color-mix(in oklab, var(--sc-ink) 10%, transparent); + + /* --- motion. Built-in CSS easings are too weak for UI. ------------------ */ + --sc-ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --sc-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + --sc-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); + --sc-d-fast: 160ms; --sc-d-base: 240ms; --sc-d-slow: 420ms; + + --sc-z-stage: 1; --sc-z-copy: 20; --sc-z-chrome: 60; +} + +/* --------------------------------------------------------------- reset -- */ +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; } +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } + +body { + margin: 0; + background: var(--sc-canvas); + color: var(--sc-ink); + font-family: var(--sc-font-text); + font-size: var(--sc-t-base); + line-height: var(--sc-leading-body); + letter-spacing: var(--sc-track-normal); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + font-synthesis-weight: none; + overflow-x: clip; +} +img, video, canvas, svg { display: block; max-width: 100%; } +button, input, select, textarea { font: inherit; color: inherit; } + +/* ------------------------------------------------- the browser surfaces -- + The parts you did not draw still carry the design. Selection, caret, focus + ring, scrollbar and tabular numerals all ship with defaults that belong to no + design system. Theming them is the cheapest signal that a page was built + rather than assembled, and it is the step that gets skipped most reliably. */ +::selection { background: var(--sc-accent); color: var(--sc-accent-ink); } +:root { caret-color: var(--sc-accent); accent-color: var(--sc-accent); } +:focus-visible { + outline: 2px solid var(--sc-accent); + outline-offset: 3px; + border-radius: var(--sc-r-sm); +} +:where(a) { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 0.22em; } +@supports (scrollbar-color: auto) { + html { scrollbar-color: color-mix(in oklab, var(--sc-ink) 26%, transparent) transparent; scrollbar-width: thin; } +} +:where(table, .sc-nums, [data-sc-count]) { font-variant-numeric: tabular-nums; } + +/* ----------------------------------------------------------- typography -- */ +.sc-display { + font-family: var(--sc-font-display); + font-weight: 500; + line-height: var(--sc-leading-none); + letter-spacing: var(--sc-track-tight); + text-wrap: balance; + margin: 0; +} +.sc-display--xl { font-size: var(--sc-t-4xl); } +.sc-display--lg { font-size: var(--sc-t-3xl); } +.sc-display--md { font-size: var(--sc-t-2xl); letter-spacing: var(--sc-track-snug); } +.sc-lede { + font-size: var(--sc-t-lg); + line-height: 1.44; + letter-spacing: var(--sc-track-snug); + color: var(--sc-ink); + max-width: 46ch; + text-wrap: pretty; + margin: 0; +} +.sc-body { max-width: var(--sc-measure); color: var(--sc-ink-soft); text-wrap: pretty; margin: 0; } +.sc-label { + font-family: var(--sc-font-mono); + font-size: var(--sc-t-xs); + letter-spacing: var(--sc-track-wide); + text-transform: uppercase; + color: var(--sc-ink-soft); +} + +/* ---------------------------------------------------------------- layout -- */ +.sc-wrap { width: 100%; max-width: var(--sc-maxw); margin-inline: auto; padding-inline: var(--sc-gutter); } +.sc-section { padding-block: var(--sc-section); } +/* More space above a heading than below it: the gap belongs to the boundary, + not to the pair. */ +.sc-stack > * + * { margin-top: var(--sc-4); } +.sc-stack > :is(h1,h2,h3) { margin-top: var(--sc-7); } +.sc-stack > :is(h1,h2,h3):first-child { margin-top: 0; } +.sc-rule { height: 1px; border: 0; background: var(--sc-hairline); margin: 0; } + +/* =============================================================== DEVICES == + Driven by the engine. Restyle your own markup, not these. */ + +.sc-act--pinned { position: relative; } +.sc-stage { + position: sticky; top: 0; + height: 100vh; height: 100svh; + overflow: clip; + z-index: var(--sc-z-stage); +} + +/* scrub / sequence media */ +.sc-stage :is(video[data-sc-scrub], canvas[data-sc-sequence], .sc-stage__poster) { + position: absolute; inset: 0; + width: 100%; height: 100%; + object-fit: cover; +} +video[data-sc-scrub] { opacity: 0; transition: opacity 380ms var(--sc-ease-out); } +.sc-has-clip video[data-sc-scrub] { opacity: 1; } +/* The poster is a live frame-holder, not a placeholder: it stays up until a + real video frame has painted, which is what stops the blank-stage flash. */ +.sc-stage__poster { z-index: 0; transition: opacity 380ms var(--sc-ease-out); } +.sc-has-clip .sc-stage__poster { opacity: 0; } + +/* A scrim only where text sits. A full-frame darkening layer flattens the + image everywhere to fix contrast in one corner. */ +.sc-scrim { + position: absolute; inset: 0; pointer-events: none; z-index: 1; + background: linear-gradient( + var(--sc-scrim-angle, 180deg), + color-mix(in oklab, var(--sc-canvas) var(--sc-scrim-a, 78%), transparent) 0%, + color-mix(in oklab, var(--sc-canvas) 30%, transparent) 42%, + transparent 68%); +} +/* Point the scrim at the copy. A scrim gradient that darkens the top while the + copy sits at the bottom fixes contrast nowhere and flattens the image + everywhere, which is the most common way text-over-video goes wrong. */ +.sc-scrim--bottom { --sc-scrim-angle: 0deg; } +.sc-scrim--left { --sc-scrim-angle: 90deg; } +.sc-scrim--right { --sc-scrim-angle: 270deg; } + +/* Corner scrims, for copy anchored to a corner. An edge gradient has to darken + a whole band across the frame to cover one corner; a corner gradient puts the + density where the text is and leaves the rest of the image alone. Pair with + .sc-copy--lead / .sc-copy--trail. */ +.sc-scrim--lead { + background: linear-gradient(to top right, + color-mix(in oklab, var(--sc-canvas) 94%, transparent) 0%, + color-mix(in oklab, var(--sc-canvas) 72%, transparent) 30%, + color-mix(in oklab, var(--sc-canvas) 30%, transparent) 52%, + transparent 72%); +} +.sc-scrim--trail { + background: linear-gradient(to top left, + color-mix(in oklab, var(--sc-canvas) 94%, transparent) 0%, + color-mix(in oklab, var(--sc-canvas) 72%, transparent) 30%, + color-mix(in oklab, var(--sc-canvas) 30%, transparent) 52%, + transparent 72%); +} +/* A band across the bottom, for copy that spans the full width of the frame. + That is what both corner anchors become below 860px, and it is also the right + shape whenever the copy block is wider than a corner. */ +.sc-scrim--band { + background: linear-gradient(to top, + color-mix(in oklab, var(--sc-canvas) 94%, transparent) 0%, + color-mix(in oklab, var(--sc-canvas) 74%, transparent) 22%, + color-mix(in oklab, var(--sc-canvas) 30%, transparent) 42%, + transparent 58%); +} +.sc-scrim--vignette { + background: radial-gradient(120% 90% at 50% 45%, transparent 40%, + color-mix(in oklab, var(--sc-canvas) 70%, transparent) 100%); +} + +/* copy over a stage. + max-width is in rem, never ch. A `ch` on this container resolves against the + CONTAINER's font-size (body size), not the display size of the heading inside + it, so `max-width: 20ch` here silently produces a ~180px column and wraps a + hero headline to six lines. Set a ch measure on the text element itself. */ +.sc-copy { + position: absolute; z-index: var(--sc-z-copy); + inset-inline: var(--sc-gutter); + max-width: min(46rem, 76vw); + will-change: opacity, transform; +} +.sc-copy--lead { left: var(--sc-gutter); bottom: clamp(3rem, 12vh, 9rem); } +/* inset-inline FIRST. It is the shorthand for left+right, so declaring it after + `left: 50%` resets left to auto and the block drifts off the left edge. */ +.sc-copy--center { inset-inline: auto; left: 50%; top: 50%; translate: -50% -50%; text-align: center; } +.sc-copy--trail { inset-inline: auto; right: var(--sc-gutter); bottom: clamp(3rem, 12vh, 9rem); text-align: right; } + +/* cue defaults: the engine writes opacity/transform inline, these are the + pre-paint state so nothing flashes before the first read() */ +[data-sc-cue] { opacity: 0; will-change: opacity, transform; } +.sc-ready [data-sc-cue] { transition: none; } + +/* kinetic split */ +.sc-split { display: inline-block; overflow: hidden; vertical-align: top; } +/* Descenders live below the baseline; a line mask clipped to the line box eats + the tails off g, y, p, j. The padding buys them room back. */ +.sc-split--line { display: block; padding-bottom: 0.14em; margin-bottom: -0.14em; } +.sc-split__i { display: inline-block; will-change: transform, opacity; } +.sc-is-split { opacity: 1 !important; } + +/* horizontal rail */ +[data-sc-pan] { display: flex; will-change: transform; } + +/* reveal + parallax */ +[data-sc-reveal] { will-change: clip-path; } +[data-sc-parallax] { will-change: transform; } + +/* flow reveal (fires once) */ +[data-sc-in], [data-sc-stagger] > * { + opacity: 0; + transform: translate3d(0, 14px, 0); + transition: opacity 620ms var(--sc-ease-out), transform 620ms var(--sc-ease-out); +} +[data-sc-in].sc-in, [data-sc-stagger] > .sc-in { opacity: 1; transform: none; } + +/* pointer devices */ +[data-sc-tilt] { transform-style: preserve-3d; will-change: transform; } +[data-sc-magnet] { will-change: transform; } +/* isolation always; position only if nothing more specific already set it. + Written with :where() (zero specificity) on purpose: a plain `position: + relative` here has the same weight as `.sc-stage` and, being later in the + file, silently wins. A stage carrying both attributes then stops being + sticky and the whole act scrolls away instead of pinning. */ +[data-sc-spotlight] { isolation: isolate; } +:where([data-sc-spotlight]) { position: relative; } +[data-sc-spotlight]::after { + content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 2; + background: radial-gradient( + 28rem 28rem at calc(var(--sc-mx, 0.5) * 100%) calc(var(--sc-my, 0.5) * 100%), + color-mix(in oklab, var(--sc-accent) 16%, transparent), transparent 70%); + opacity: 0; transition: opacity var(--sc-d-slow) var(--sc-ease-out); +} +[data-sc-spotlight]:hover::after { opacity: 1; } + +/* =========================================================== WORLDFLIGHT == + One fixed stage for the whole page. The only thing in document flow is the + spacer, and it is empty. If you find yourself adding a section here, you want + act mode instead: the moment a real block scrolls past the fixed stage, the + page has a seam again and the whole point is gone. */ + +[data-sc-mode="worldflight"] { position: relative; } + +.sc-world { + position: fixed; inset: 0; + overflow: clip; + z-index: var(--sc-z-stage); + background: var(--sc-canvas); +} + +/* Every leg is mounted for the life of the page and stacked in the same box. + The engine owns opacity, visibility and z-index here; do not set them. */ +.sc-world__seg { + position: absolute; inset: 0; + opacity: 0; + will-change: opacity; +} +.sc-world__seg > :is(video, img, canvas) { + position: absolute; inset: 0; + width: 100%; height: 100%; + object-fit: cover; +} +/* The poster is the frame-holder AND the first camera move: the engine pushes + it in slowly until a real decoded frame is available to replace it. */ +.sc-world__poster { + z-index: 0; + transform-origin: 50% 50%; + transition: opacity 420ms var(--sc-ease-out); +} +.sc-world__seg > video { z-index: 1; } +.sc-world__seg.sc-has-clip .sc-world__poster { opacity: 0; } +/* The act stylesheet lights a clip through its ancestor act. A worldflight leg + has no act, so match the class the engine also writes on the clip itself. */ +video[data-sc-scrub].sc-has-clip { opacity: 1; } + +/* The copy layer is fixed too, and inert by default: the engine hands + pointer-events back to a block only while it is actually legible. */ +.sc-world__copy { + position: fixed; inset: 0; + z-index: var(--sc-z-copy); + pointer-events: none; +} +.sc-world__scrim { position: absolute; inset: 0; pointer-events: none; z-index: 0; } +[data-sc-copy] { + opacity: 0; + pointer-events: none; + will-change: opacity, transform; +} +.sc-ready [data-sc-copy] { transition: none; } + +/* The track. Empty on purpose. */ +.sc-world__spacer { pointer-events: none; } + +/* scroll progress */ +[data-sc-progress] { + position: fixed; inset: 0 0 auto 0; height: 2px; z-index: var(--sc-z-chrome); + background: var(--sc-accent); transform-origin: 0 50%; transform: scaleX(0); + will-change: transform; +} + +/* ----------------------------------------------------------- atmosphere -- + Depth is not only shadow. Grain keeps a flat dark ground from banding, and + it is the difference between "a dark page" and "a lit room". */ +.sc-grain { position: fixed; inset: 0; pointer-events: none; z-index: 3; opacity: 0.045; } +.sc-grain::before { + content: ""; position: absolute; inset: -50%; + background-image: url("data:image/svg+xml;utf8,"); +} + +/* -------------------------------------------------------- reduced motion -- */ +@media (prefers-reduced-motion: reduce) { + /* Fewer and gentler, not zero. Opacity still carries the reveal so the page + is comprehensible; every position change is dropped. */ + [data-sc-in], [data-sc-stagger] > * { transform: none; transition-duration: 220ms; } + [data-sc-parallax], [data-sc-pan], .sc-split__i { transform: none !important; } + /* A wipe is a position change too. Show reveal content settled. */ + [data-sc-reveal] { clip-path: none !important; } + /* A rail's transform IS its navigation, not decoration. Zeroing it the way + parallax and kinetic type are zeroed parks the act on its first screenful + and makes everything past the fold unreachable: a device degrading into + missing content. Hand the travel back to the reader as an ordinary scroll + region instead, so the same items are all still gettable without motion. + A build that would rather re-lay the rail out as a grid can still do that; + this is the floor, not the ceiling. */ + [data-sc-act="pan"] .sc-stage, + [data-sc-act="pan"] [data-sc-stage] { + overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; + scroll-snap-type: x proximity; + } + [data-sc-pan] > * { scroll-snap-align: center; } + [data-sc-spotlight]::after { display: none; } + /* A worldflight keeps its whole story here. No clip is ever fetched, so the + posters are the film, and they cross-dissolve through exactly the same + seams at exactly the same scroll positions. The push-in and the copy drift + are the only things that go. */ + .sc-world__poster, [data-sc-copy] { transform: none !important; } +} + +/* --------------------------------------------------------------- mobile -- */ +@media (max-width: 860px) { + .sc-copy { inset-inline: var(--sc-gutter); max-width: none; } + .sc-copy--trail { text-align: left; } + .sc-stage { height: 100svh; } + /* .sc-copy--trail re-anchors to the left and spans the full width here, so a + bottom-RIGHT corner gradient darkens the one corner the copy just left and + guarantees a contrast failure over any bright clip. Point the density at + the copy: the same band .sc-scrim--band paints. */ + .sc-scrim--trail { + background: linear-gradient(to top, + color-mix(in oklab, var(--sc-canvas) 94%, transparent) 0%, + color-mix(in oklab, var(--sc-canvas) 74%, transparent) 22%, + color-mix(in oklab, var(--sc-canvas) 30%, transparent) 42%, + transparent 58%); + } +} diff --git a/apps/site/static/sc/scrollcraft.js b/apps/site/static/sc/scrollcraft.js new file mode 100644 index 0000000..439bc30 --- /dev/null +++ b/apps/site/static/sc/scrollcraft.js @@ -0,0 +1,1283 @@ +/* ============================================================================ + scrollcraft: a scroll-driven interaction runtime + ---------------------------------------------------------------------------- + Vanilla JS. Zero dependencies. Zero DOM generation. + + The engine does NOT build your page. You author real, semantic HTML and mark + it up with data-sc-* attributes; the engine reads them and drives them from a + single scroll value on a single rAF loop. That is deliberate: a runtime that + generates its own DOM from a config object makes every site it touches look + identical, which is the failure mode this replaces. + + --------------------------------------------------------------------------- + ACTS: the unit of scroll time + --------------------------------------------------------------------------- +
            ...
            + + data-sc-act scrub | pin | pan | flow (default: flow) + data-sc-clip-map="travel" opts a pinned act OUT of full-life clip mapping. + By DEFAULT a scrub clip is mapped across the stage's entire + on-screen life, not across its pinned travel, because a pinned + stage is visible for a viewport BEFORE the pin begins (sliding + up into view) and a viewport AFTER it ends (sliding off the + top). Mapped to pinned travel, the clip sits on its first + frame through the whole entry and its last frame through the + whole exit: the reader watches a still photograph while the + page moves. Pair it with data-sc-dwell, which moves quickly at + the edges and settles in the middle, so the fast motion lands + on the two slides and the settle lands inside the pin where + the copy is. data-sc-runout is accepted and ignored; it named + the old opt-in for the exit half of this. + data-sc-span viewport-heights of scroll this act owns. Pinned devices only + (scrub/pin/pan). Default 1.5. The engine sets the outer + height and sticks the first .sc-stage / [data-sc-stage] child. + + Every act exposes a normalized progress p (0..1): + pinned p = (y - top) / (height - vh) + flow p = (y + vh - top) / (height + vh) + and publishes it as --sc-p on the act element, so CSS can read it too. + + --------------------------------------------------------------------------- + DEVICES: what p drives + --------------------------------------------------------------------------- + data-sc-scrub on