diff --git a/.env b/.env new file mode 100644 index 0000000..f7d1d47 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +# Default env vars for the Vite build. Checked in. +# Trainees who want to override locally should create `.env.local` (gitignored). + +# Canonical "Acme Widgets — Catalog" Google Sheet, published to web as CSV. +# A fresh clone runs against this URL out of the box. +VITE_WIDGETS_SHEET_URL=https://docs.google.com/spreadsheets/d/e/2PACX-1vRDcRnvmze_Pj2tdivNrUuJRJtTT1Hugiv2OUiRZYYEz1n5WHcFdBP45FucjUGwfRV0JwYijSIxm4gQ/pub?gid=0&single=true&output=csv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3fc3ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + env: + # Hardcoded; bump in lockstep with @playwright/test in package.json. + # Cache is invalidated on version change. + PLAYWRIGHT_VERSION: 1.59.1 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bun run typecheck + + - name: Build + run: bun run build + + - name: Vitest + run: bun run test + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ env.PLAYWRIGHT_VERSION }} + + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: Playwright + run: bun run test:e2e + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload Playwright test-results on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + path: test-results/ + retention-days: 7 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..87a7bf0 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,51 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build with Pages base path + env: + VITE_BASE: /acme-widgets/ + run: bun run build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7925542 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ + +# Beads / Dolt files (added by bd init) +.dolt/ +*.db +.beads-credential-key + +# Manual backups of dolt db for evolving versions of workshop +.backups + +# Node / Bun +node_modules/ + +# Foreign package-manager lockfiles (this project uses bun.lock) +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build output +dist/ +.vite/ +*.tsbuildinfo + +# Local env overrides (Vite convention; `.env` itself is committed) +*.local + +# Test artifacts +playwright-report/ +test-results/ +.playwright-mcp/ + +# OS +.DS_Store diff --git a/README.md b/README.md index 36b06a7..05e43e4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,92 @@ # Acme Widgets -This repo is used to create an example website for Software Factory Intensive training. +Example website for Software Factory Intensive training. Live at +****. + +## Quick Start + +### Prerequisites + +- **[Bun](https://bun.sh) `>=1.2`** — runtime + package manager + script runner. + Vite (the dev server / bundler) is installed for you when you run `bun install`, + so you don't need to install it globally. + +#### Install Bun + +See the official install guide: . + +```bash +# macOS / Linux / WSL +curl -fsSL https://bun.sh/install | bash + +# macOS via Homebrew +brew install oven-sh/bun/bun + +# Windows (PowerShell) +powershell -c "irm bun.sh/install.ps1 | iex" +``` + +Verify: + +```bash +bun --version # should print 1.2.x or newer +``` + +#### About Vite + +You don't install Vite globally for this project — it ships as a dev dependency +(see `package.json`) and is run via the `bun run` scripts below. If you want to +read up on it: . + +### Clone and run + +```bash +git clone git@github.com:bryanhirsch/acme-widgets-private.git +cd acme-widgets-private +bun install # installs Vite and all other deps +bun run dev # http://localhost:5173 +``` + +Other useful scripts: + +```bash +bun run typecheck # tsc --noEmit +bun run build # production build to dist/ (also writes 404.html) +bun run preview # serve the built output on http://localhost:4173 +bun run test # Vitest, one shot +bun run test:watch # Vitest, watch mode +bun run test:e2e # Playwright (chromium); spins up `bun run preview` +``` + +## Stack + +TypeScript + React 19 + Vite 6 + Tailwind v4 + React Router v7, +hosted on GitHub Pages, CI on GitHub Actions. See +[`docs/current/STACK.md`](docs/current/STACK.md) for as-built versions +and conventions, and +[`docs/decision-records/0001.ADR.TECH_STACK.md`](docs/decision-records/0001.ADR.TECH_STACK.md) +for the decision record. + +## Repo layout + +- [`docs/current/`](docs/current) — what is true on `main` right now. +- [`docs/decision-records/`](docs/decision-records) — frozen ADRs. +- [`docs/future/`](docs/future) — proposed changes drafted as ADRs. +- `src/` — application source. `pages/` contains the three routes; + `components/` contains shared chrome and the 15 widget presets; + `lib/widgets.ts` is the CSV parser + cache. +- `e2e/` — Playwright specs. +- `public/data/widgets.csv` — last-resort fallback for the catalog if the + Google Sheet can't be reached. Editing this file does **not** change + the live site; see below. + +## Editing the catalog + +Widget data (prices, stock counts, descriptions) lives in a Google Sheet, +not in this repo. Producers and employees edit the sheet directly; changes +appear on the live site within a few minutes — no PR, no deploy. + +See [`docs/current/EDITING_THE_CATALOG.md`](docs/current/EDITING_THE_CATALOG.md) +for the sheet location and editing rules. The architecture choice is +captured in +[`docs/decision-records/0002.ADR.DYNAMIC_WIDGET_CATALOG.md`](docs/decision-records/0002.ADR.DYNAMIC_WIDGET_CATALOG.md). diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..f885144 --- /dev/null +++ b/bun.lock @@ -0,0 +1,543 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "acme-widgets", + "dependencies": { + "@heroicons/react": "^2.2.0", + "papaparse": "^5.5.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.14.2", + }, + "devDependencies": { + "@playwright/test": "^1.59.1", + "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/papaparse": "^5.5.2", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.0", + "tailwindcss": "^4.2.4", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^4.1.5", + }, + }, + }, + "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], + + "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], + + "@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@types/papaparse": ["@types/papaparse@5.5.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], + + "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="], + + "@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], + + "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.345", "", {}, "sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg=="], + + "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], + + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsdom": ["jsdom@29.1.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="], + + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + + "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-router": ["react-router@7.14.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "tldts": ["tldts@7.0.29", "", { "dependencies": { "tldts-core": "^7.0.29" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-JIXCerhudr/N6OWLwLF1HVsTTUo7ry6qHa5eWZEkiMuxsIiAACL55tGLfqfHfoH7QaMQUW8fngD7u7TxWexYQg=="], + + "tldts-core": ["tldts-core@7.0.29", "", {}, "sha512-W99NuU7b1DcG3uJ3v9k9VztCH3WialNbBkBft5wCs8V8mexu0XQqaZEYb9l9RNNzK8+3EJ9PKWB0/RUtTQ/o+Q=="], + + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + } +} diff --git a/docs/current/DOCUMENTATION.md b/docs/current/DOCUMENTATION.md new file mode 100644 index 0000000..fb90728 --- /dev/null +++ b/docs/current/DOCUMENTATION.md @@ -0,0 +1,68 @@ +# Documentation Standard + +In a mature software factory, agents read and write the code; humans read and +write plain English. Agents do code review; humans review functionality and +the plain-English descriptions in `docs/`. This document defines how those +plain-English documents are organized in this repository. + +## Directory structure + +``` +docs/ + current/ # Authoritative docs for current state of code on the default branch (main) + future/ # Proposed changes, drafted as ADRs awaiting approval + roadmap → future # Symlink for anyone looking for "the roadmap" + decision-records/ # ADRs in flight or landed, moved here on a branch where the ADR is being implemented + human-review-logs/ # Per-PR checklists for human functional review +``` + +## Lifecycle of an architecture decision + +1. **Draft.** A proposed change is written as an ADR in `docs/future/`, + numbered sequentially (e.g. `0001.ADR.TECH_STACK.md`). +2. **Approve & branch.** After approval if/when the work gets prioritized + the ADR is moved from `docs/future/` + to `docs/decision-records/` on the branch created to implement it. +3. **Evolve during implementation.** If the architecture evolves before + merge, update the ADR in place in `docs/decision-records/` so the PR + reflects what was actually built. +4. **Merge.** After merge, the ADR in `docs/decision-records/` is a frozen + historical record — a decision made at a point in time, in a particular + context. It does not change again. New decisions get new ADRs. +5. **Reflect current state.** The same PR updates `docs/current/` to describe + the new state of the codebase. + +## What goes where + +| Directory | Stable? | Purpose | +|--------------------------|---------|-----------------------------------------------------------| +| `docs/current/` | Living | What is true on the default branch right now. | +| `docs/future/` | Living | What we are considering. | +| `docs/decision-records/` | Frozen upon merge to main | ADRs get moved here as part of implementation. | +| `docs/human-review-logs/`| Append | One file per PR that warrants human functional review. | + +## When to update `docs/current/` + +Any pull request that makes a meaningful change to technical architecture, +user experience, or information architecture must include corresponding +updates to `docs/current/`. If the docs and the code disagree, that is a bug +in either the code or the docs — never an acceptable steady state. + +## Human review logs + +Some changes deserve a human walkthrough before merge. For those, add a file +at `docs/human-review-logs/--.md` containing a checklist of +suggested functional reviews. Where a review item corresponds to an +automated test, reference the test so the reviewer has context. The goal is +to let a human verify, as efficiently as possible, that the change does what +it was intended to do. + +A review log is appropriate when: +- The change is user-visible and an automated test cannot fully cover the + intent (e.g. copy, layout, motion). +- The change crosses subsystem boundaries in a way a single PR diff is hard + to evaluate. +- The change is a milestone the team wants captured for posterity. + +Skip the review log for routine refactors, dependency bumps, and other +changes whose intent is fully captured by tests and the diff itself. diff --git a/docs/current/EDITING_THE_CATALOG.md b/docs/current/EDITING_THE_CATALOG.md new file mode 100644 index 0000000..1f53e48 --- /dev/null +++ b/docs/current/EDITING_THE_CATALOG.md @@ -0,0 +1,93 @@ +# Editing the Catalog + +Widget data on the live site comes from a Google Sheet. Editing the +sheet updates the live site within a few minutes — no PR, no deploy. + +## Where the sheet lives + +**Sheet:** "Acme Widgets — Catalog." + +If you need edit access, ask Bryan. Editor access is intentionally +limited to the same group who can merge code to `main`, because changes +to the sheet go live with no review. + +> ⚠ The published version of this sheet is publicly readable on the +> internet. Put **only catalog data** in it — no internal notes, no +> private prices, no draft copy, no customer info. + +## Editing rules + +The sheet has one row per widget. Edit cells in place, add new rows at +the bottom, or delete rows you no longer want on the site. + +| Column | What goes here | +| ----------------- | ------------------------------------------------------------------ | +| `type` | The widget's display name. Required. | +| `description` | One-sentence description shown on the catalog card. | +| `number_in_stock` | A whole number. The card shows "N in stock" (or "Only 9 left"). | +| `price` | A number in dollars. Decimals OK (`19.99`). No `$` sign. | +| `icon` | Preset key for the card's visual (e.g. `brass-widget.svg`). Required. See "How `icon` picks a visual" below. | +| `categories` | Tags separated by `;` (e.g. `everyday;artisanal;gift`). | + +**Do not rename the column headers.** The site reads columns by name; +renaming `price` to `Price ($)` will make the catalog page show an +error to visitors instead of silently breaking every price. If you +need a label change, ask an engineer. + +A row missing `type` or `icon` is skipped on the site (other rows +still appear) — useful if you want to draft a row in place before +filling it out. + +## How `icon` picks a visual + +Each widget card has a "hero" panel — the colored background, label, +and cog at the top of the card. The `icon` cell decides which one a +row gets. The match is by filename: + +- `icon` is `brass-widget.svg` → the site looks for a preset file at + [`src/components/presets/brass-widget.tsx`](../../src/components/presets) + and renders that. +- `icon` is anything the site doesn't recognize → the card falls back + to the generic "Standard Issue" preset. The row still appears; only + the visual is generic. + +The full list of presets a producer can pick from is whatever lives in +[`src/components/presets/`](../../src/components/presets). The +filenames in that directory (minus the `.tsx` extension, plus `.svg`) +are the valid `icon` values. Files starting with `_` (like +`_standard.tsx`) are internal helpers and are not pickable from the +sheet. + +> **Adding a new widget design is a code change, not a sheet change.** +> A producer with a new visual idea works with an engineer to add a +> file under `src/components/presets/`. Once that file is merged, the +> producer can reference its filename in the sheet's `icon` column and +> the new design appears on the site. The decision to organize presets +> this way is captured in +> [`../decision-records/0003.ADR.SELF_REGISTERING_PRESETS.md`](../decision-records/0003.ADR.SELF_REGISTERING_PRESETS.md). + +## How fast does an edit go live? + +Google's published-CSV URL is edge-cached. In practice, edits show up +on the live site within ~5 minutes. There is no manual "publish" step +after the initial setup. + +If the sheet itself becomes unreachable, the site falls back to a +snapshot bundled with the code (`public/data/widgets.csv`) so visitors +still see something. The snapshot may be out of date — that's expected. + +## When something looks wrong + +- **Prices are all $0 or fields look empty across all widgets:** a + required column was probably renamed in the header. Restore the + original column name. +- **The catalog page shows "The Catalog is temporarily unavailable":** + the sheet is unreachable AND the bundled fallback also failed. Ping + an engineer. +- **The site shows old data after I edited the sheet:** wait ~5 + minutes for Google's edge cache, then hard-refresh. + +## Why a Google Sheet and not a CMS? + +Captured in +[`../decision-records/0002.ADR.DYNAMIC_WIDGET_CATALOG.md`](../decision-records/0002.ADR.DYNAMIC_WIDGET_CATALOG.md). diff --git a/docs/current/GITHUB_ACTIONS.md b/docs/current/GITHUB_ACTIONS.md new file mode 100644 index 0000000..f805627 --- /dev/null +++ b/docs/current/GITHUB_ACTIONS.md @@ -0,0 +1,38 @@ +# GitHub Actions on forks + +The repo ships with two workflows under `.github/workflows/`: + +- **`ci.yml`** — typecheck, build, Vitest, Playwright. Runs on PRs and pushes to `main`. +- **`deploy.yml`** — builds and publishes the site to GitHub Pages. See [GITHUB_PAGES.md](./GITHUB_PAGES.md). + +Actions are free for public repos. Keep the fork public. + +## One-time setup per fork + +### 1. Enable Actions on the fork + +Forks have Actions **disabled by default**. Open the **Actions** tab and click "I understand my workflows, go ahead and enable them." + +### 2. Allow the actions used by the workflows + +Settings → Actions → General → Actions permissions. The simplest option is "Allow all actions and reusable workflows." If you prefer a stricter allowlist, the workflows use: + +- `actions/checkout` +- `actions/cache` +- `actions/upload-artifact` +- `actions/upload-pages-artifact` +- `actions/deploy-pages` +- `oven-sh/setup-bun` + +### 3. Workflow permissions + +Settings → Actions → General → Workflow permissions. The shipped workflows declare the permissions they need (`contents: read`, plus `pages: write` and `id-token: write` for `deploy.yml`), so the default "Read repository contents and packages permissions" is fine. If a run fails with "Resource not accessible by integration," switch to "Read and write permissions." + +## Verifying CI runs + +Push a commit to `main` or open a PR against `main`. The Actions tab should show a green "CI" run. The Playwright report and `test-results/` artifacts upload only on failure (7-day retention). + +## Related + +- [GITHUB_PAGES.md](./GITHUB_PAGES.md) — Pages-specific setup (the deploy workflow) +- [HOSTING.md](./HOSTING.md) — overall remote / Dolt / Pages architecture diff --git a/docs/current/GITHUB_PAGES.md b/docs/current/GITHUB_PAGES.md new file mode 100644 index 0000000..c4d98ba --- /dev/null +++ b/docs/current/GITHUB_PAGES.md @@ -0,0 +1,43 @@ +# Hosting acme-widgets on GitHub Pages (forks) + +This project ships with a Pages deploy workflow (`.github/workflows/deploy.yml`) preconfigured for the default repo name `acme-widgets`. On a fork, you only need to flip two switches in the GitHub UI. + +Pages is free only for **public** repos — keep the fork public. + +## Prerequisite + +Actions must be enabled on the fork — see [GITHUB_ACTIONS.md](./GITHUB_ACTIONS.md). + +## One-time setup + +1. **Set Pages source to "GitHub Actions".** Settings → Pages → Build and deployment → Source: **GitHub Actions**. Do not pick "Deploy from a branch." +2. **Trigger a deploy.** Push to `main`, or run the workflow manually: Actions tab → "Deploy to GitHub Pages" → "Run workflow". + +## Your site URL + +``` +https://.github.io/acme-widgets/ +``` + +(Same form on the canonical repo: .) + +The URL also appears in Settings → Pages and on the workflow run summary once the deploy succeeds. + +## Already wired up (no action needed) + +- **SPA routing.** `package.json`'s `postbuild` copies `dist/index.html` to `dist/404.html` so React Router handles deep-link refreshes. +- **Base path.** `VITE_BASE: /acme-widgets/` is set in `deploy.yml` so assets resolve under `/acme-widgets/...`. +- **Pages permissions** (`pages: write`, `id-token: write`) and **concurrency** (`pages` group) are set in `deploy.yml`. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| Workflow doesn't run on the fork | Actions disabled | See [GITHUB_ACTIONS.md](./GITHUB_ACTIONS.md) | +| `actions/deploy-pages` fails with "Pages site not found" | Source not set to "GitHub Actions" | Step 1 | +| Deep-link refresh returns 404 | `dist/404.html` missing | Re-run the workflow; verify `postbuild` ran | + +## Related + +- [GITHUB_ACTIONS.md](./GITHUB_ACTIONS.md) — enabling and configuring Actions on a fork +- [HOSTING.md](./HOSTING.md) — overall remote / Dolt / Pages architecture diff --git a/docs/current/HOSTING.md b/docs/current/HOSTING.md new file mode 100644 index 0000000..b0aad24 --- /dev/null +++ b/docs/current/HOSTING.md @@ -0,0 +1,58 @@ +# Hosting & Repo Architecture + +The acme-widgets training project is one local working tree synced to **two GitHub remotes** plus **one Dolt remote**. The Git source — all branches — is fully shared with the public remote; only the Dolt issue-tracker database stays private. + +## Remotes + +| Name | URL | Carries | Why | +| -------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `origin` | `git@github.com:bryanhirsch/acme-widgets-private.git` | All Git branches + `.beads/` config + Dolt branches (issue data) | Private repo holds the Dolt issue data we don't want public | +| `public` | `git@github.com:bryanhirsch/acme-widgets.git` | All Git branches (no Dolt branches) | Public repo unlocks **free** GitHub Pages hosting + GitHub Actions CI | +| Dolt | `git+ssh://git@github.com/bryanhirsch/acme-widgets-private.git` (via `bd dolt remote`) | Dolt issue data on orphan branches inside the private repo | Issue tracker (beads) data is sensitive; never goes to the public repo | + +## Branches + +| Branch | Purpose | +| ---------------- | ------------------------------------------------------------------------------------ | +| `main` | Production branch — powers GitHub Pages; workshop participants merge their finished work here | +| `development` | Main development branch — where the demo website for the workshop gets built before merging to `main` | +| `dev/start-here` | Workshop starting point for participants | +| `dev/` | Targeted starting points for individual modules and lessons (WIP scaffolds) | + +Every Git branch above is pushed to **both** remotes. Only the Dolt orphan branches (`build1.0.0/nyc1`, `workshop1.0.0/nyc1`, etc.) stay on the private remote; they're managed by `bd dolt push` / `bd dolt pull`. + +The Pages deploy workflow (`.github/workflows/deploy.yml`) triggers on push to `main`, so a deploy fires when finished work lands there. + +## Pushing + +```bash +git push origin # private remote (also receives Dolt branches via bd dolt push) +git push public # public remote (Pages + CI run from here) +bd dolt push # push Dolt issue data to the private remote +``` + +For convenience, configure a single `both` remote whose push URLs cover origin and public, so `git push both ` updates both repos in one shot. + +## What ends up where + +- **Public remote (`public`):** every Git branch (`main`, `development`, `dev/start-here`, `dev/`, …). No Dolt branches. The `.beads/` config files are present in tree but contain no issue content — only project bootstrap metadata. +- **Private remote (`origin`):** every Git branch **plus** the Dolt branches. Source of truth for both code and issue tracking. +- **GitHub Pages:** deploys from `public/main`. Free tier requires a public repo. +- **GitHub Actions CI:** runs on the **public** remote. Free tier for public repos. + +## Working with this setup + +- For **issue tracking** (`bd ready`, `bd close`, etc.): operate normally; `bd dolt push` syncs to the private Dolt remote. +- For **shipping code**: push the relevant branch to both remotes (or to a configured `both` remote). The deploy/CI workflows run on the public remote. +- For **fresh clones**: clone the **private** repo first (`git@github.com:bryanhirsch/acme-widgets-private.git`) to get the beads bootstrap config, run `bd bootstrap` to clone the Dolt issue data, then add the `public` remote so you can push code there too. + +## Why not one repo? + +- A purely **public** repo would expose the Dolt issue database (issues, decisions, planning notes) to anyone. +- A purely **private** repo would require a paid GitHub plan for Pages and Actions on private repos. +- Splitting Git history (public, all branches) from issue-tracker data (private, in Dolt branches on the private remote) keeps everything free while keeping the issue tracker private. + +## Hosting the public repo (or a fork) + +- [GITHUB_ACTIONS.md](./GITHUB_ACTIONS.md) — enabling and configuring Actions on a fork. +- [GITHUB_PAGES.md](./GITHUB_PAGES.md) — Pages setup, base-path config, and SPA routing. diff --git a/docs/current/INFORMATION_ARCHITECTURE.md b/docs/current/INFORMATION_ARCHITECTURE.md new file mode 100644 index 0000000..8c47ccf --- /dev/null +++ b/docs/current/INFORMATION_ARCHITECTURE.md @@ -0,0 +1,27 @@ +# Information Architecture + +The Acme Widgets site is intentionally small. There are three top-level routes, +no auth, and no nested navigation. Anyone landing on the home page should be +able to find a widget or learn how to order one within a single click. + +## Route map + +| Path | Purpose | +|------------|--------------------------------------------------------------------------------------------------| +| `/` | Home page advertising Acme Widgets — widgets for all purposes and occasions. | +| `/widgets` | Listing of every widget for sale, sourced live from a Google Sheet (see [EDITING_THE_CATALOG](EDITING_THE_CATALOG.md)). | +| `/orders` | Placeholder explaining that online orders are coming soon; meanwhile, email `sales@acmewidgets.com`. | + +## Navigation + +A single top-level navigation surfaces all three routes from every page. There +is no breadcrumb, no sidebar, and no per-widget detail page. If a future +feature needs deeper nesting (e.g. `/widgets/:id`), capture the change in a +new ADR before adding it. + +## Routing implementation + +See [`0001.ADR.TECH_STACK.md`](../decision-records/0001.ADR.TECH_STACK.md) +for the choice of React Router v7 (data router). Routes are declared in a +single `routes.tsx` module so trainees can see the entire information +architecture in one file. diff --git a/docs/current/PERSONAS.md b/docs/current/PERSONAS.md new file mode 100644 index 0000000..0667cf3 --- /dev/null +++ b/docs/current/PERSONAS.md @@ -0,0 +1,41 @@ +# Personas + +Acme Widgets serves three personas. None of them have accounts in the current +system — the site is anonymous and read-only — but the personas still drive +how the catalog is organized and how copy is written. + +## Customers + +Everyone needs widgets. Customers range from kids buying a first widget to +elderly buyers replacing a long-loved one, and they shop both for personal +use and for business. The catalog must be browseable without a tutorial and +should communicate purpose and occasion clearly. + +**What customers do on the site:** browse `/widgets`, decide they want one, +follow the instructions on `/orders` to email sales. + +## Producers + +The people who make widgets and sell them through Acme Widgets. This includes +handcrafted artisanal makers, manufacturers of high-precision widgets, and +specialty producers for particular occasions and purposes. + +**What producers do on the site:** producers do not interact with the live +site directly. They submit catalog entries by editing `public/data/widgets.csv` +in a pull request. An employee reviews and merges. + +## Employees + +People who work for Acme Widgets, buying widgets from producers and selling to +customers. + +**What employees do on the site:** employees own the catalog. They review +producer PRs against `widgets.csv`, merge them, and respond to orders sent to +`sales@acmewidgets.com`. + +## Why this matters for the codebase + +Because there is no auth and no per-persona UI, persona differences show up in +**copy and catalog structure**, not in conditional rendering. When adding new +content, ask which persona it serves and whether the existing IA +(`/`, `/widgets`, `/orders`) is the right home for it. diff --git a/docs/current/STACK.md b/docs/current/STACK.md new file mode 100644 index 0000000..208c339 --- /dev/null +++ b/docs/current/STACK.md @@ -0,0 +1,122 @@ +# As-Built Stack + +This file is the ground truth for what the default branch actually ships. +It is the companion to [`../decision-records/0001.ADR.TECH_STACK.md`](../decision-records/0001.ADR.TECH_STACK.md): +the ADR captures the decision and its rationale; this file captures the +versions and conventions that won during implementation. + +If the code and this file disagree, that is a bug in the file. + +## Pinned versions + +| Layer | Choice | Notes | +| ---------------- | --------------------------------- | ---------------------------------------------------------- | +| Runtime / pm | Bun `>=1.2`, currently `1.3.11` | Pinned in CI to `1.3.11` via `oven-sh/setup-bun@v2`. | +| Bundler | Vite `^6` | `@vitejs/plugin-react`. | +| Language | TypeScript `^5.7` | Strict + `noUncheckedIndexedAccess`. | +| UI | React `^19` | | +| Routing | React Router `^7` (data router) | `createBrowserRouter` + ``. | +| Styling | **Tailwind CSS v4** (CSS-first) | `@tailwindcss/vite` plugin; no `tailwind.config.js`. | +| Icons | `@heroicons/react/24/{solid,outline}` | | +| CSV parsing | `papaparse` | Used for RFC 4180 quote handling. | +| Unit / component | Vitest `^4`, jsdom, testing-library | Config in `vite.config.ts`, setup in `src/test/setup.ts`. | +| E2E | Playwright `^1.59`, chromium-only | Config at repo root. webServer wraps `bun run preview`. | + +The `engines.bun` floor lives in `package.json`. The CI bun version is +pinned in `.github/workflows/ci.yml` (and `deploy.yml`) — bump both at +the same time when you upgrade the runtime. + +## Tailwind variant — v4, not v3 + +Tailwind v4 was chosen during implementation of aw-l0r.2 over v3, per +the bead's own NOTES which named v4 as the recommended default. The +practical implications: + +- **No `tailwind.config.js` and no `postcss.config.js`.** Both files + intentionally do not exist. v4 reads its theme from a `@theme` block + in CSS. +- **Single source of truth for design tokens** is `src/index.css`. The + brass / paper / jewel-tone palette and the Fraunces + Inter font + stacks live in that one `@theme` block. Reach into that block to add + or change tokens; do not introduce a JS config. +- **Plugin pipeline** is the `@tailwindcss/vite` plugin registered in + `vite.config.ts`. There is no PostCSS config in the repo. +- **`@apply` still works** inside the `@layer base` block in + `src/index.css`; we use it for the small set of base styles + (`html { @apply bg-paper text-ink … }`). + +If you ever switch back to v3, write a new ADR. Don't silently mix the +two — every preset in `src/components/widgetPresets.tsx` assumes v4 +arbitrary-value syntax (`text-[clamp(40px,6vw,72px)]`, +`bg-brass-600`, etc.). + +## Catalog data source + +The widget catalog is fetched live from a Google Sheet, not bundled +into the build. This is the as-built side of +[`../decision-records/0002.ADR.DYNAMIC_WIDGET_CATALOG.md`](../decision-records/0002.ADR.DYNAMIC_WIDGET_CATALOG.md). + +- **Live source:** the published-CSV URL in `VITE_WIDGETS_SHEET_URL` + (defined in `.env`, typed in `src/vite-env.d.ts`). A fresh clone runs + against the canonical Acme Widgets sheet with no setup. Override + locally by creating `.env.local` (gitignored) with a different value. +- **Fallback:** `public/data/widgets.csv`. Used only when the live fetch + fails (network error or non-OK HTTP). When the fallback fires, the + browser console gets a `console.warn`. The fallback CSV is a snapshot + and may drift from the live sheet — that's accepted for the demo. +- **Schema policy:** a row missing `type` or `icon` is skipped with a + warning (recoverable — the rest of the catalog renders). A header + with a renamed required column (`type`, `description`, + `number_in_stock`, `price`, `icon`, `categories`) **throws**. The + `/widgets` route renders `WidgetsRouteError` in that case rather than + silently corrupting prices. +- **Producer workflow:** see + [EDITING_THE_CATALOG](EDITING_THE_CATALOG.md). Producers edit the + sheet; engineers do not ship PRs to update prices or stock counts. + +## `BASE_URL` convention + +The site has to work in two places: at `/` in dev and at +`/acme-widgets/` on GitHub Pages (the public remote — see +[`HOSTING.md`](HOSTING.md)). One vite build serves both, controlled +by `import.meta.env.BASE_URL`: + +- **Vite config:** `base: process.env.VITE_BASE ?? '/'` in + `vite.config.ts`. CI sets `VITE_BASE=/acme-widgets/` for the deploy + job; local dev/preview leaves it unset. +- **Router basename:** `createBrowserRouter(routes, { basename: import.meta.env.BASE_URL })` + in `src/routes.tsx`. **Do not** double-prefix in `` calls — + pass app-relative paths only (e.g. `to="/widgets"`). +- **Asset fetches:** prefix with `import.meta.env.BASE_URL` for any + asset under `public/`. The fallback widgets CSV in `src/lib/widgets.ts` + uses this pattern (`${import.meta.env.BASE_URL}data/widgets.csv`). + `BASE_URL` already has a trailing slash; do not double up. The live + catalog URL (`VITE_WIDGETS_SHEET_URL`) is absolute and does **not** + use `BASE_URL`. + +If you add a new fetch of a `public/` asset, follow the same pattern. + +## GitHub Pages SPA fallback (`404.html`) + +GitHub Pages serves `404.html` for any path it can't find on disk. +For an SPA, that means a hard refresh on `/widgets` would 404 unless +we hand it the SPA shell. The fix is one line in `package.json`: + +```json +"postbuild": "cp -f dist/index.html dist/404.html" +``` + +Bun runs lifecycle scripts, so `bun run build` produces both files +without any extra wiring. **Do not strip this.** If you change the +build pipeline, make sure `dist/404.html` continues to exist in +production. + +## Test runner split + +- **Vitest** (`bun run test`) — pure functions, hooks, components in + isolation. Excludes the `e2e/` directory. +- **Playwright** (`bun run test:e2e`) — flows that depend on routing, + real network fetches (yes, the live `widgets.csv`), or browser APIs. + +See [`TESTING.md`](TESTING.md) for the policy on when to reach for +each. diff --git a/docs/current/TESTING.md b/docs/current/TESTING.md new file mode 100644 index 0000000..18066fe --- /dev/null +++ b/docs/current/TESTING.md @@ -0,0 +1,47 @@ +# Testing + +Acme Widgets uses two test runners, each scoped to a different layer of the +application. Both run on every PR via GitHub Actions and must be green before +merge. + +## Unit & component tests — Vitest + +Vitest runs unit tests for pure functions and component tests for React +components in isolation. It uses the same Vite transform pipeline as the app +itself, so tests see the code exactly as the bundler sees it. + +- **Location:** co-located with source as `*.test.ts` / `*.test.tsx`. +- **Run locally:** `bun run test` +- **Watch mode:** `bun run test:watch` + +Use Vitest when: +- The unit under test is a pure function, hook, or component. +- The test does not need a real browser DOM, network, or routing. + +## End-to-end tests — Playwright + +Playwright drives a real headless browser against a built copy of the site. +E2E tests cover the user-visible flows defined in +[`INFORMATION_ARCHITECTURE.md`](INFORMATION_ARCHITECTURE.md): landing on `/`, +browsing `/widgets`, reading `/orders`. + +- **Location:** `e2e/` at the repo root. +- **Run locally:** `bun run test:e2e` +- **Headed (debug):** `bun run test:e2e:headed` + +Use Playwright when: +- The behavior depends on routing, real network fetches, or browser APIs. +- The test verifies a user-visible flow end to end. + +## Choosing between the two + +Default to Vitest. Reach for Playwright only when the value of the test +depends on real browser behavior. Every Playwright test costs CI time, so +each one should justify itself with a flow a unit test cannot cover. + +## What we do not test + +- Visual regression. Out of scope for the training example. +- Performance / load. The site is static and the catalog is small. +- Accessibility audits beyond what Playwright + axe-core can assert in a + regular E2E run. diff --git a/docs/decision-records/0001.ADR.TECH_STACK.md b/docs/decision-records/0001.ADR.TECH_STACK.md new file mode 100644 index 0000000..ef84410 --- /dev/null +++ b/docs/decision-records/0001.ADR.TECH_STACK.md @@ -0,0 +1,125 @@ +# ADR 0001: Tech Stack & Hosting for Acme Widgets + +- **Status:** Accepted +- **Date:** 2026-04-29 +- **Deciders:** Bryan + +## Context + +Acme Widgets is the example website used in the Software Factories Intensive +training. It exists to give trainees a small, realistic codebase to practice +factory workflows on. That purpose drives three constraints: + +1. **Fast to set up.** A trainee should be able to clone, install, and run + locally in minutes, on a clean laptop, without account provisioning. +2. **Free to operate.** No paid services on the critical path. The repo should + stay green on a personal GitHub account at zero cost. +3. **Recognizable to working engineers.** The stack should reflect choices a + real product team might make in 2026, so lessons transfer. + +The site itself is small: a marketing home page, a static widget catalog, and +a placeholder orders page. There is no auth, no database, no payments — those +are intentionally out of scope so the example stays focused on factory +mechanics rather than product complexity. + +## Decision + +### Application stack + +- **Language:** TypeScript +- **UI framework:** React +- **Runtime / package manager:** Bun +- **Bundler:** Vite (with Bun as the package manager and script runner) +- **Routing:** React Router (data router, v7) +- **Styling:** Tailwind CSS + +### Data + +> **Superseded in part by [ADR 0002](./0002.ADR.DYNAMIC_WIDGET_CATALOG.md):** +> the widget catalog moved from a static CSV to a Google Sheet published as +> CSV, fetched live by the browser. The bullet below describes the original +> decision and is preserved as historical context. + +- **Widget catalog:** static CSV checked into the repo at + `public/data/widgets.csv`, fetched at runtime by the catalog page and parsed + client-side. Producers and employees update the catalog by editing the CSV + and opening a PR. +- **No backend, no database.** Orders are handled out-of-band by emailing + `sales@acmewidgets.com`. + +### Hosting & CI + +- **Hosting:** GitHub Pages (free for public repos), serving the static build + output. +- **CI:** GitHub Actions (free tier for public repos) for build, test, and + deploy-to-Pages on merge to the default branch. + +### Testing + +- **Unit / component tests:** Vitest (Jest-compatible API, runs natively under + Bun/Vite without the Node-compat overhead Jest requires). +- **End-to-end tests:** Playwright, headless in CI. + +> Note: the rough notes proposed Jest. Vitest is preferred because it shares +> Vite's transform pipeline and works with Bun without extra configuration. +> If a future ADR moves the project off Vite, revisit this choice. + +## Consequences + +**Positive** + +- Zero hosting cost; trainees can fork and deploy on their own GitHub account. +- Bun + Vite gives sub-second cold starts on local dev, keeping the inner loop + tight during exercises. +- Static CSV catalog means no database, no migrations, no seeding scripts — + exercises can focus on factory workflow rather than infrastructure. +- TypeScript + React + Tailwind matches what most working engineers use today, + so trainees recognize patterns immediately. + +**Negative / accepted trade-offs** + +- GitHub Pages serves static assets only. Any future feature requiring a + server (real orders, search, auth) will require a new ADR and likely a + hosting change. +- CSV-as-database does not scale past a few hundred rows or any concurrent + writers. That is acceptable for the training scope. +- Bun is younger than Node; some npm packages have rough edges under it. + Mitigated by keeping dependencies few and mainstream. + +## Alternatives considered + +- **Next.js on Vercel.** Rejected: requires a Vercel account, and SSR/ISR + features add concepts the training doesn't need. +- **Plain HTML + a static site generator (Astro, 11ty).** Rejected: trainees + expect React; SSGs add a meta-framework concept that distracts from factory + exercises. +- **Firebase Hosting + Firestore.** Rejected: introduces account setup, + billing risk, and a real database — all out of scope for the example. +- **Jest for unit tests.** Rejected in favor of Vitest, see Testing above. + +### Default branch + +`main`. Acme Widgets has no need for parallel major-version branches and +follows the GitHub default. + +## Open questions + +- ~~Should the CSV be parsed at build time (typed import) or fetched at + runtime (smaller bundle, easier to hot-swap)?~~ **Resolved during + implementation:** runtime fetch via `papaparse`, with the parsed list + cached in module scope so the home featured trio and the catalog page + share one network request. See `src/lib/widgets.ts`. + +## Related + +- As-built stack (versions, conventions): see + [`../current/STACK.md`](../current/STACK.md). +- Hosting & repo architecture (two GitHub remotes + one Dolt remote): see + [`../current/HOSTING.md`](../current/HOSTING.md). +- Information architecture (routes, page purposes): see + [`../current/INFORMATION_ARCHITECTURE.md`](../current/INFORMATION_ARCHITECTURE.md). +- Personas (customers, producers, employees): see + [`../current/PERSONAS.md`](../current/PERSONAS.md). +- Testing strategy: see [`../current/TESTING.md`](../current/TESTING.md). +- Documentation convention: see + [`../current/DOCUMENTATION.md`](../current/DOCUMENTATION.md). diff --git a/docs/decision-records/0002.ADR.DYNAMIC_WIDGET_CATALOG.md b/docs/decision-records/0002.ADR.DYNAMIC_WIDGET_CATALOG.md new file mode 100644 index 0000000..3ec7ace --- /dev/null +++ b/docs/decision-records/0002.ADR.DYNAMIC_WIDGET_CATALOG.md @@ -0,0 +1,178 @@ +# ADR 0002: Dynamic Widget Catalog Backed by Google Sheets + +- **Status:** Accepted +- **Date:** 2026-05-01 +- **Deciders:** Bryan +- **Supersedes (in part):** [ADR 0001 — "Data" section](./0001.ADR.TECH_STACK.md#data) + +## Context + +ADR 0001 chose a static CSV at `public/data/widgets.csv` as the widget +catalog. Producers and employees edit the catalog by opening a PR against +that file. That worked while the catalog was small and edits were rare, +but two friction points have surfaced: + +1. **Edits require a deploy.** A widget price change or stock correction + waits on a PR review, a CI build, and a Pages deploy. That is the right + shape for code, not for data the business expects to update casually. +2. **Non-engineer producers cannot self-serve.** Editing CSV in GitHub is + a poor experience for the producer/employee personas described in + [`PERSONAS.md`](../current/PERSONAS.md), and we have no good way to + teach trainees factory workflows around catalog updates without + inventing one. + +We want catalog edits to be live (visible to site visitors within minutes, +not on the next deploy) and to happen in a tool a non-engineer already +knows. We do not want to walk back any of ADR 0001's hard constraints: + +- Fast trainee setup, no account provisioning required to run locally. +- Zero hosting cost; GitHub Pages stays as the host. +- No backend, no database, no paid services on the critical path. + +## Decision + +### Source of truth + +The widget catalog moves to a single Google Sheet, "Acme Widgets — Catalog," +with one row per widget and a header row whose column names match the +existing CSV (`type`, `description`, `number_in_stock`, `price`, `icon`, +`categories`). Producers and employees edit the sheet directly; engineers +no longer ship PRs to update prices or stock counts. + +### Delivery to the browser + +The sheet is **published to web as CSV** via Google Sheets' +`File → Share → Publish to web → CSV` feature. The published URL is a +stable, public, CORS-permissive endpoint of the form: + +``` +https://docs.google.com/spreadsheets/d/e/{publish-id}/pub?output=csv +``` + +The site fetches that URL at runtime from the browser, parses it with the +existing `papaparse` pipeline in `src/lib/widgets.ts`, and renders the +catalog. No API key, no OAuth, no backend, and no GCP project are +required. Google's edge serves the CSV; we treat it as a CDN we don't pay +for. + +### Configuration + +The published-CSV URL is provided to the build via a Vite env var +(`VITE_WIDGETS_SHEET_URL`) and read in `src/lib/widgets.ts`. The default +value points at the canonical Acme Widgets sheet so a fresh clone runs +out of the box without any setup. Trainees who want to point the example +at their own sheet override the env var locally. + +### Fallback + +`public/data/widgets.csv` stays in the repo as a **last-resort fallback** +only. If the Sheets fetch fails (network error, sheet unpublished, +Google outage), `fetchWidgets()` retries against the local CSV and logs +a warning to the browser console. The fallback is whatever is checked +into git at the time — Acme Widgets is a demonstration site, not a +production storefront, so the fallback existing matters more than it +being current. There is no scheduled refresh; if the snapshot drifts +from the live sheet, that is acceptable for the demo. + +### Schema validation + +Bad data shows up in two shapes, and they are handled asymmetrically: + +- **Bad row** — a single widget is missing a required field (`type` or + `icon`). The current behavior is preserved: skip the row and log a + warning to the browser console. The rest of the catalog renders. +- **Bad header** — a column has been renamed in the sheet (e.g. + `price` → `Price ($)`), which would silently corrupt that column for + every widget. This fails loud: `parseWidgetsCsv` throws, and the + catalog page renders a visible error state instead of garbage. + +Validation runs at runtime in the browser. There is no scheduled CI +check against the live sheet — it would reintroduce the same kind of +scheduled ops work we declined for the fallback refresh. + +### Local development + +`bun dev` hits the live sheet by default, matching production behavior. +If the live fetch fails, the same fallback path applies and the warning +appears in the browser console — trainees working offline will see the +catalog from the local CSV with a clear signal in the console that +they're not on the live data. + +### Caching and freshness + +Google's published-CSV endpoint is edge-cached; in practice edits appear +within ~5 minutes. The browser-side module cache in `widgets.ts` is kept +as-is (one fetch per page load shared across views), so within a single +session a visitor sees a consistent snapshot. + +## Consequences + +**Positive** + +- Producers edit prices and stock in a familiar spreadsheet UI; changes + reach visitors in minutes without a PR or a deploy. +- The web app stays a static bundle on GitHub Pages. Hosting cost stays + zero. No backend is introduced. +- The change is small and localized: a single fetch URL flips, the parse + pipeline is unchanged, and the existing tests for `parseWidgetsCsv` + remain valid. +- Trainees see a recognizable real-world pattern (Sheets-as-CMS) without + the overhead of a real headless CMS. + +**Negative / accepted trade-offs** + +- The catalog is now publicly readable via the published-CSV URL. That + is acceptable — the site already publishes the same data on `/widgets` + — but it means the sheet must contain only catalog data, never internal + notes. +- Anyone with edit access to the sheet can change live site content with + no review. Mitigated by limiting sheet editors to the same people who + could already merge PRs to `main`. +- A Google Sheets outage degrades the site. Mitigated by the local-CSV + fallback. +- The "publish to web" URL format is undocumented as a stable contract. + It has been stable for the better part of a decade, so we accept the + risk; if Google changes the format, ADR 0003 revisits the choice. + +## Alternatives considered + +- **Google Sheets API with an API key.** Rejected: requires a GCP + project, billing-enabled or not, and an API key in env. Conflicts with + ADR 0001's "fast to set up, no account provisioning" constraint. +- **Build-time pull from the sheet via GitHub Actions, commit the CSV.** + Rejected: edits still wait on a CI run, which defeats the "live" goal. + Also rejected as a *fallback refresh* mechanism — Acme Widgets is a + demonstration site, and a static fallback that drifts from the live + sheet is acceptable for the demo. +- **Headless CMS (Sanity, Contentful, Strapi).** Rejected: each + introduces an account, a free tier with a billing risk, and a + modeling concept (schemas, references) that is overkill for a flat + list of widgets and noisy for the training scope. +- **Airtable.** Rejected: similar shape to Sheets but with a more + restrictive free tier and an editor experience trainees are less + likely to recognize. +- **Cloudflare Worker (or similar) proxying the Sheets API.** Rejected: + introduces a backend service on the critical path, which ADR 0001 + rules out. +- **Firebase / Firestore.** Rejected for the same reasons given in ADR + 0001 — account setup, billing risk, real database out of scope. + +## Open questions + +- **Test strategy.** Current tests parse a fixture CSV string. The + fetch path needs a test against a stubbed Sheets response and a test + for the fallback when the fetch fails. Out of scope for this ADR; + capture in tasks under the implementation plan. + +## Related + +- ADR 0001 — Tech Stack & Hosting: + [`./0001.ADR.TECH_STACK.md`](./0001.ADR.TECH_STACK.md). + This ADR amends the "Data" section of 0001. +- Information architecture: + [`../current/INFORMATION_ARCHITECTURE.md`](../current/INFORMATION_ARCHITECTURE.md). +- Personas (producers/employees): + [`../current/PERSONAS.md`](../current/PERSONAS.md). +- Hosting model: + [`../current/HOSTING.md`](../current/HOSTING.md). +- Implementation plan: tracked in beads epic `aw-dvn`. diff --git a/docs/decision-records/0003.ADR.SELF_REGISTERING_PRESETS.md b/docs/decision-records/0003.ADR.SELF_REGISTERING_PRESETS.md new file mode 100644 index 0000000..c0c626d --- /dev/null +++ b/docs/decision-records/0003.ADR.SELF_REGISTERING_PRESETS.md @@ -0,0 +1,228 @@ +# ADR 0003: Self-Registering Widget Presets + +- **Status:** Accepted +- **Date:** 2026-05-26 +- **Deciders:** Bryan +- **Related:** [ADR 0002 — Dynamic Widget Catalog](./0002.ADR.DYNAMIC_WIDGET_CATALOG.md) + +## Context + +A "widget" on the site is two things stitched together at render time: + +1. **Catalog data** — a row in the Google Sheet (or its CSV fallback at + `public/data/widgets.csv`): `type`, `description`, `number_in_stock`, + `price`, `icon`, `categories`. +2. **Visual preset** — a small React component (a "hero" panel: gradient, + label, cog icon) that gives the card its character. Today, all fifteen + presets live in `src/components/widgetPresets.tsx` and are looked up + via a single `widgetPresets: Record` object keyed by + the `icon` filename (e.g. `brass-widget.svg` → `Brass`). + +The two halves connect through one column. `WidgetCard` does: + +```ts +const preset = widgetPresets[widget.icon] ?? standardPreset; +``` + +`widget.icon` is the value of the `icon` cell in the sheet. If the sheet +says `brass-widget.svg`, the registry's `'brass-widget.svg'` entry is +used. If the sheet says something the registry doesn't know about, the +card falls back to `Standard`. The `.svg` suffix is historical — there +is no real `brass-widget.svg` file on disk (only +`public/icons/placeholder.svg` exists). The string is just a registry +key shaped like a filename. + +That contract works fine at 15 widgets. It does not scale to the +workflow the rest of the system is designed for. + +ADR 0002 deliberately makes adding catalog **data** a no-code, +no-PR operation: producers append rows to the sheet and the live site +picks them up within minutes. But adding a **new visual style** still +means editing `widgetPresets.tsx` — a single file with one append-only +registry object at the bottom. Two consequences: + +1. **The registry is a serialization point.** N agents (or humans) each + adding a preset all edit the same object literal in the same file. + Every parallel branch conflicts on the registry block. The work is + embarrassingly parallel in nature but artificially serial in + practice. +2. **Visual work and catalog work drift.** A new row can be added to + the sheet immediately; the matching visual lands days later in a + PR. The fallback to `Standard` makes this safe but invisible — + nothing on the site or in the repo tells you that + `meteor-widget.svg` is referenced by the sheet but has no preset + defined. + +## Decision + +### Per-preset files + +Move each preset to its own file under `src/components/presets/`, +one file per preset, named after the registry key it owns: + +``` +src/components/presets/ + brass-widget.tsx + steel-widget.tsx + pocket-widget.tsx + ... + _standard.tsx # the fallback; leading underscore keeps it + # out of the auto-discovery glob + _shared.tsx # CogEl, cogClass, shared types +``` + +Each preset module has a single default export of type `Preset`. No +registry edit is required to add one — dropping a new file into +`src/components/presets/` is the entire change. + +### Auto-discovery via `import.meta.glob` + +The registry is built at module-load time by Vite's +[`import.meta.glob`](https://vitejs.dev/guide/features.html#glob-import), +which expands at build into a static map of `path → module`. Roughly: + +```ts +// src/components/widgetPresets.ts +import type { Preset } from './presets/_shared'; +import standardPreset from './presets/_standard'; + +const modules = import.meta.glob<{ default: Preset }>( + './presets/[a-z]*.tsx', + { eager: true }, +); + +export const widgetPresets: Record = Object.fromEntries( + Object.entries(modules).map(([path, mod]) => { + const key = path.replace(/^\.\/presets\//, '').replace(/\.tsx$/, ''); + return [`${key}.svg`, mod.default]; + }), +); + +export { standardPreset }; +``` + +`WidgetCard` is unchanged: `widgetPresets[widget.icon] ?? standardPreset` +still works because the generated registry has the same shape. + +### How sheet data matches a preset + +The contract between the catalog and the visual layer is **one column: +`icon`**. The mapping is: + +| Sheet cell (`icon`) | Preset file path | +| --------------------- | ----------------------------------------- | +| `brass-widget.svg` | `src/components/presets/brass-widget.tsx` | +| `steel-widget.svg` | `src/components/presets/steel-widget.tsx` | +| `meteor-widget.svg` | *(no file)* → falls back to `_standard` | +| *(empty)* | row is skipped by `parseWidgetsCsv` | + +The rule: + +1. Strip the `.svg` suffix from the `icon` cell. +2. Look for `src/components/presets/.tsx`. +3. If present, its default export is the preset. +4. If absent, render `_standard.tsx`. + +Producers editing the sheet do not need to know this mapping exists — +they pick from a documented list of available `icon` values, the same +way they do today. Engineers adding a new visual style add a file at +the path that matches the `icon` value the sheet will use. + +### Documenting available icons + +Today the list of valid `icon` values is implicit — you discover them +by reading `widgetPresets.tsx`. With per-file presets, the list of +valid values is the contents of `src/components/presets/` (minus the +leading-underscore files). Generate a short reference table in +`docs/current/EDITING_THE_CATALOG.md` from that directory at build time, +or maintain it by hand — either way, the source of truth is the +filesystem, not a registry object. + +### Naming and validation + +- **Filenames are lowercase, hyphenated**, matching the `icon` column + convention (`brass-widget.tsx`, not `BrassWidget.tsx`). +- **Files starting with `_`** (`_standard.tsx`, `_shared.tsx`) are + excluded from the glob. The leading-underscore convention is what + makes the discovery rule unambiguous. +- **A unit test** asserts that every preset file's default export + satisfies the `Preset` type and that the generated registry has no + duplicate keys. This catches a malformed preset at CI time rather + than at render time. +- **No runtime check** that every `icon` value in the sheet has a + matching file. That would re-introduce the kind of build-time + coupling between catalog data and code that ADR 0002 explicitly + rejected. The `_standard` fallback remains the contract for + unknown icons. + +## Consequences + +**Positive** + +- Adding a new visual preset is a single new file. N engineers or + agents can add N presets on N branches with zero merge conflicts on + shared registry code. +- The pairing between an `icon` cell value and a preset is now visible + on the filesystem — `ls src/components/presets/` is the list of + supported icons. +- `widgetPresets.tsx` shrinks from ~350 lines of mixed registry-and- + styling to a ~15-line generator. Visual code lives next to the + preset it belongs to. +- The `WidgetCard` consumer is unchanged; the public API of + `widgetPresets` and `standardPreset` is preserved, so this is a + pure refactor at the call site. + +**Negative / accepted trade-offs** + +- `import.meta.glob` is a Vite-specific feature. We are already + committed to Vite by ADR 0001, so this does not add a new lock-in, + but it does deepen the existing one. A move off Vite would need to + replace the glob with an explicit `index.ts` re-export. +- The mapping rule (strip `.svg`, look up filename) is one indirection + more than the current explicit object literal. The trade-off is + fewer touch points per change in exchange for one documented rule. +- Eager glob loading bundles every preset into the initial chunk, the + same as today. If the preset count grows large enough that this + matters (it does not at 15 or even 100), switching to a lazy glob + and `React.lazy` is a follow-up — out of scope here. + +## Alternatives considered + +- **Status quo.** Rejected: the registry-as-bottleneck problem is the + whole reason this ADR exists. +- **One file per preset, manual `index.ts` re-export.** Splits the + per-file work cleanly but reintroduces a single shared file + (`index.ts`) that every new preset has to edit. Solves half the + problem. +- **Runtime registration via side-effectful `register()` calls inside + each preset module.** Works, but depends on every module being + imported for its side effect, which is fragile under tree-shaking + and requires a barrel file anyway. The `import.meta.glob` approach + gets the same effect declaratively and statically. +- **Move presets into the catalog data itself** (e.g. a `preset_json` + column in the sheet describing the hero panel). Rejected: presets + are React components with Tailwind classes and Heroicons imports, + not data. Embedding them in the sheet would either restrict them to + a tiny declarative subset or invite arbitrary JSX-in-a-cell, both + worse than the current split. + +## Open questions + +- **Naming the fallback.** `_standard.tsx` keeps the fallback out of + the glob and signals "not user-pickable." If we ever want + `standard-widget.svg` in the sheet to render the same visual, + re-export `_standard` from a regular `standard-widget.tsx`. +- **Generated icon reference in `EDITING_THE_CATALOG.md`.** Worth a + short script in `package.json` (`bun run docs:icons`) or kept + manual? Defer until the preset count grows past ~30. + +## Related + +- ADR 0001 — Tech Stack & Hosting: + [`./0001.ADR.TECH_STACK.md`](./0001.ADR.TECH_STACK.md). +- ADR 0002 — Dynamic Widget Catalog: + [`./0002.ADR.DYNAMIC_WIDGET_CATALOG.md`](./0002.ADR.DYNAMIC_WIDGET_CATALOG.md). +- Current registry: `src/components/widgetPresets.tsx`. +- Consumer: `src/components/WidgetCard.tsx`. +- Catalog editing guide: + [`../current/EDITING_THE_CATALOG.md`](../current/EDITING_THE_CATALOG.md). diff --git a/docs/human-review-logs/aw-dvn--dynamic-catalog.md b/docs/human-review-logs/aw-dvn--dynamic-catalog.md new file mode 100644 index 0000000..5251289 --- /dev/null +++ b/docs/human-review-logs/aw-dvn--dynamic-catalog.md @@ -0,0 +1,93 @@ +# Human review — aw-dvn: dynamic widget catalog (Google Sheets) + +**Bead:** [`aw-dvn`](../../README.md) (epic) — see also `aw-dvn.1` through `aw-dvn.11`. +**ADR:** [`0002.ADR.DYNAMIC_WIDGET_CATALOG.md`](../decision-records/0002.ADR.DYNAMIC_WIDGET_CATALOG.md). +**Branch:** `aw-dvn/dynamic-catalog`. + +The diff is small per file but spans data, routing, UI, config, and docs. +Most of the runtime behavior is exercised by unit tests (referenced inline +below) — this log focuses on what the tests *cannot* verify: copy quality, +end-to-end UX of each failure mode, and the human-only setup items. + +## Editorial — copy and writing + +The new producer-facing doc and the new error UI are user-visible to +non-engineers. Tests assert they exist; they cannot tell us whether the +writing is clear, accurate, or appropriately scoped. + +- [ ] [`docs/current/EDITING_THE_CATALOG.md`](../current/EDITING_THE_CATALOG.md) + reads cleanly to a non-engineer producer/employee. The column table is + accurate. The "do not rename headers" warning is unmistakable. The + "what to do when something looks wrong" section matches the actual + failure modes. +- [ ] `WidgetsRouteError` copy in + [`src/pages/Widgets.tsx`](../../src/pages/Widgets.tsx) is the right + tone for the brand and points users somewhere useful. Both link + destinations (Home, How to order) make sense as fallbacks for a + catalog outage. +- [ ] [`README.md`](../../README.md) "Editing the catalog" section is + accurate. The reframing of `widgets.csv` as "fallback only, editing it + does not change the live site" lands clearly. +- [ ] [`docs/current/STACK.md`](../current/STACK.md) "Catalog data source" + section accurately describes what shipped (not what was originally + planned). + +## Failure-mode walkthrough + +The fetch path has six discrete branches. Each is unit-tested in isolation, +but the *user-facing experience* of each is worth a one-time manual pass. +Test names are referenced so you don't redo what's already covered — this +is about confirming the test contract feels right when seen in a real +browser, not about catching regressions. + +- [ ] **Live sheet, 200, valid CSV** — visit `/widgets` against the live + site (or `bun run dev`). All 15 widgets render. No `console.warn` + from the fetch path. *Covered by:* `fetches the sheet, parses it, + and caches the result` in `src/lib/widgets.test.ts`. +- [ ] **Sheet network down → fallback succeeds.** In `.env.local`, set + `VITE_WIDGETS_SHEET_URL=https://invalid.example.invalid/`. Restart + `bun run dev`. `/widgets` should render the bundled snapshot, and the + browser console should show the "Sheet catalog unreachable; falling + back to bundled widgets.csv" warning. *Covered by:* `falls back to + the bundled CSV with a console.warn on a network error`. +- [ ] **Sheet 404 → fallback succeeds.** Set + `VITE_WIDGETS_SHEET_URL=http://localhost:5173/no-such-file.csv`. + Same expected outcome. *Covered by:* `falls back when the sheet + returns a non-OK HTTP response`. +- [ ] **Sheet OK, malformed CSV → fail loud.** Temporarily rename the + `price` column header in the live sheet (e.g. to `Price ($)`). + `/widgets` should show the `WidgetsRouteError` page within ~5 minutes + (Google's edge cache window) — *not* the bundled snapshot, *not* + every row showing $0. **Restore the header immediately after.** + *Covered by:* `does NOT fall back when the sheet returns 200 but + the CSV is malformed` and `throws when a required column is missing + or renamed in the header`. +- [ ] **Both sources fail.** With the bad `.env.local` URL above, also + delete or rename `public/data/widgets.csv` locally. `/widgets` + should render `WidgetsRouteError`. (Restore the file when done.) +- [ ] **Cached → no re-fetch within a session.** Open `/widgets`, then + navigate Home → Widgets again. The sheet should not be fetched a + second time within the same page session. *Covered by:* the cache + assertion in `fetches the sheet, parses it, and caches the result`. + +## Setup / governance items + +These cannot be verified from the diff and should be confirmed by someone +with access to the canonical Google account. + +- [ ] The URL in `.env`'s `VITE_WIDGETS_SHEET_URL` points at the canonical + "Acme Widgets — Catalog" sheet — not a personal scratch sheet, not a + copy. +- [ ] Editor access on that sheet is restricted to people who already have + merge rights on `main`, per ADR 0002. Anyone outside that group is + "viewer" only. +- [ ] The sheet contains catalog data only — no internal notes, no draft + copy, no customer info. (The published-CSV URL is publicly readable.) + +## Out of scope for this review + +- Whether the *architecture* is right: that's ADR 0002, which was + reviewed separately before this branch was cut. +- Visual design of `WidgetCard`, the category chips, or the catalog page + layout: unchanged in this PR. +- Changes to the test runner setup or CI: none. diff --git a/docs/roadmap b/docs/roadmap new file mode 120000 index 0000000..5a1521c --- /dev/null +++ b/docs/roadmap @@ -0,0 +1 @@ +future \ No newline at end of file diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts new file mode 100644 index 0000000..ab7e604 --- /dev/null +++ b/e2e/home.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test('home page renders the hero h1 and links to the catalog', async ({ + page, +}) => { + await page.goto('/'); + + await expect( + page.getByRole('heading', { level: 1, name: /widgets for all purposes/i }), + ).toBeVisible(); + + // Primary CTA links to /widgets. Use the link role + name; the wordmark + // logo also links to /, but it doesn't match /catalog/i. + await expect( + page.getByRole('link', { name: /catalog/i }), + ).toHaveAttribute('href', '/widgets'); +}); diff --git a/e2e/orders.spec.ts b/e2e/orders.spec.ts new file mode 100644 index 0000000..776e386 --- /dev/null +++ b/e2e/orders.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test('orders page renders an h1 and the sales mailto link', async ({ page }) => { + await page.goto('/orders'); + + // Asserting an h1 exists so removing it in source produces a useful + // failure ("expected to be visible" + the matched query). + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + + // The literal email string must be visible somewhere in the DOM. + await expect(page.getByText('sales@acmewidgets.com')).toBeVisible(); + + // The mailto link points exactly at the bare address (no query params). + await expect( + page.getByRole('link', { name: /sales@acmewidgets\.com/ }), + ).toHaveAttribute('href', 'mailto:sales@acmewidgets.com'); +}); diff --git a/e2e/widgets.spec.ts b/e2e/widgets.spec.ts new file mode 100644 index 0000000..b0b4d78 --- /dev/null +++ b/e2e/widgets.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; + +test('widgets page renders cards from the live CSV with quoted-comma intact', async ({ + page, +}) => { + await page.goto('/widgets'); + + // Wait for at least one card; we look up a stable name string from the + // CSV rather than a row index so reordering doesn't break the spec. + const classicBrass = page.getByRole('article', { name: 'Classic Brass Widget' }); + await expect(classicBrass).toBeVisible(); + + // The CSV row's description has an embedded comma — verify it survived + // the parse intact (would truncate if a naive split-on-comma were used). + await expect( + classicBrass.getByText( + 'A timeless hand-polished brass widget, suitable for everyday use and treasured heirloom display.', + ), + ).toBeVisible(); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..5b90c10 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Acme Widgets + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..b29f668 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "acme-widgets", + "private": true, + "version": "0.0.0", + "type": "module", + "engines": { + "bun": ">=1.2" + }, + "scripts": { + "dev": "bun --bun vite", + "build": "bun --bun tsc -b && bun --bun vite build", + "postbuild": "cp -f dist/index.html dist/404.html", + "preview": "bun --bun vite preview", + "typecheck": "bun --bun tsc --noEmit", + "test": "bun --bun vitest run", + "test:watch": "bun --bun vitest --watch", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed" + }, + "dependencies": { + "@heroicons/react": "^2.2.0", + "papaparse": "^5.5.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.14.2" + }, + "devDependencies": { + "@playwright/test": "^1.59.1", + "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/papaparse": "^5.5.2", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.0", + "tailwindcss": "^4.2.4", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^4.1.5" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..267670c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI + ? [['github'], ['html', { open: 'never' }]] + : 'list', + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:4173', + }, + webServer: { + command: 'bun run preview', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [{ name: 'chromium', use: devices['Desktop Chrome'] }], +}); diff --git a/public/data/widgets.csv b/public/data/widgets.csv new file mode 100644 index 0000000..8965793 --- /dev/null +++ b/public/data/widgets.csv @@ -0,0 +1,16 @@ +type,description,number_in_stock,price,icon,categories +Classic Brass Widget,"A timeless hand-polished brass widget, suitable for everyday use and treasured heirloom display.",42,19.99,brass-widget.svg,everyday;artisanal;gift +Precision Steel Widget,"Machined to 0.001mm tolerance for industrial assemblies and high-precision applications.",128,89.50,steel-widget.svg,industrial;precision;business +Pocket Widget Mini,"A travel-sized widget that fits in any pocket. Perfect first widget for kids and curious newcomers.",260,4.99,pocket-widget.svg,everyday;kids;starter +Birthday Sparkle Widget,"Festive widget with confetti finish, designed to make any birthday celebration sparkle.",75,12.50,sparkle-widget.svg,occasion;birthday;gift +Wedding Heirloom Widget,"Hand-engraved silver-plated widget presented in a velvet box, a keepsake for the happy couple.",18,149.00,heirloom-widget.svg,occasion;wedding;artisanal;gift +Holiday Evergreen Widget,"Seasonal widget finished in evergreen lacquer with gold accents for the winter holidays.",94,24.99,evergreen-widget.svg,occasion;holiday;gift +Heavy-Duty Industrial Widget,"Reinforced cast-iron widget rated for continuous operation in demanding factory environments.",36,219.00,industrial-widget.svg,industrial;business;heavy-duty +Artisan Walnut Widget,"Hand-turned walnut widget from a small Vermont workshop. Each piece is unique.",22,64.00,walnut-widget.svg,artisanal;gift;everyday +Replacement Standard Widget,"Direct drop-in replacement for the most common widget assembly. Keep one in the drawer.",410,7.99,standard-widget.svg,everyday;replacement;starter +Anniversary Gold Widget,"24k gold-leafed widget mounted on a polished marble base. Marks the milestones that matter.",9,389.00,gold-widget.svg,occasion;anniversary;artisanal;gift +Lab-Grade Calibration Widget,"NIST-traceable calibration widget for laboratory and metrology use, shipped with certificate.",54,142.75,lab-widget.svg,precision;business;laboratory +Kids' Rainbow Widget,"Brightly colored, BPA-free widget sized for small hands. A great first widget.",185,6.50,rainbow-widget.svg,kids;starter;gift +Graduation Commemorative Widget,"Engravable widget with mortarboard motif, a thoughtful gift for the new graduate.",47,34.99,graduation-widget.svg,occasion;graduation;gift +Workshop Bulk-Pack Widget,"Box of 25 general-purpose widgets at a working price for shops, makers, and tinkerers.",62,79.00,bulk-widget.svg,business;everyday;bulk +Vintage-Style Copper Widget,"Antiqued copper widget styled after the original 1952 Acme catalog design.",28,42.00,copper-widget.svg,artisanal;vintage;everyday diff --git a/public/icons/placeholder.svg b/public/icons/placeholder.svg new file mode 100644 index 0000000..279cca4 --- /dev/null +++ b/public/icons/placeholder.svg @@ -0,0 +1,4 @@ + diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..2e8b735 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,30 @@ +import { Link } from 'react-router'; + +const links = [ + { to: '/orders', label: 'Contact Us' }, + { to: '/pricing-page', label: 'Pricing' }, + { to: '/feedback-form', label: 'Feedback' }, +]; + +export default function Footer() { + return ( +
+
+ +

+ © {new Date().getFullYear()} Acme Widgets +

+
+
+ ); +} diff --git a/src/components/TopNav.test.tsx b/src/components/TopNav.test.tsx new file mode 100644 index 0000000..32adac8 --- /dev/null +++ b/src/components/TopNav.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createMemoryRouter, Outlet, RouterProvider } from 'react-router'; +import { describe, expect, it } from 'vitest'; +import TopNav from './TopNav'; + +function renderTopNavAtPath(initialPath: string) { + const router = createMemoryRouter( + [ + { + path: '/', + element: ( + <> + + + + ), + children: [ + { index: true, element:
Home
}, + { + path: 'widgets', + element:
Widgets
, + }, + { + path: 'orders', + element:
Orders
, + }, + ], + }, + ], + { initialEntries: [initialPath] }, + ); + return render(); +} + +describe('TopNav', () => { + it('renders three primary nav links', () => { + renderTopNavAtPath('/'); + const nav = screen.getByRole('navigation', { name: /primary/i }); + expect(within(nav).getByRole('link', { name: 'Home' })).toBeInTheDocument(); + expect(within(nav).getByRole('link', { name: 'Widgets' })).toBeInTheDocument(); + expect(within(nav).getByRole('link', { name: 'Orders' })).toBeInTheDocument(); + }); + + it('marks the active link with aria-current="page"', () => { + renderTopNavAtPath('/widgets'); + const nav = screen.getByRole('navigation', { name: /primary/i }); + expect(within(nav).getByRole('link', { name: 'Widgets' })).toHaveAttribute( + 'aria-current', + 'page', + ); + expect(within(nav).getByRole('link', { name: 'Home' })).not.toHaveAttribute( + 'aria-current', + ); + }); + + it('navigates to /widgets when the Widgets link is clicked', async () => { + const user = userEvent.setup(); + renderTopNavAtPath('/'); + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + + const nav = screen.getByRole('navigation', { name: /primary/i }); + await user.click(within(nav).getByRole('link', { name: 'Widgets' })); + + expect(await screen.findByTestId('widgets-page')).toBeInTheDocument(); + }); +}); diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx new file mode 100644 index 0000000..a099c3d --- /dev/null +++ b/src/components/TopNav.tsx @@ -0,0 +1,44 @@ +import { Cog6ToothIcon } from '@heroicons/react/24/solid'; +import { Link, NavLink } from 'react-router'; + +const links = [ + { to: '/', label: 'Home', end: true }, + { to: '/widgets', label: 'Widgets' }, + { to: '/orders', label: 'Orders' }, +]; + +const linkClass = ({ isActive }: { isActive: boolean }) => + [ + 'font-sans font-medium text-[13px] sm:text-[15px] transition-colors', + isActive + ? 'text-ink border-b-2 border-brass-600' + : 'text-ink-soft hover:text-ink', + ].join(' '); + +export default function TopNav() { + return ( +
+
+ +
+
+ ); +} diff --git a/src/components/WidgetCard.tsx b/src/components/WidgetCard.tsx new file mode 100644 index 0000000..6124879 --- /dev/null +++ b/src/components/WidgetCard.tsx @@ -0,0 +1,56 @@ +import type { Widget } from '../lib/widgets'; +import { standardPreset, widgetPresets } from './widgetPresets'; + +const currency = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +function stockMeta(n: number): { dot: string; label: string } { + if (n <= 0) return { dot: 'bg-burgundy', label: 'Backorder' }; + if (n < 10) return { dot: 'bg-burgundy', label: `Only ${n} left` }; + if (n < 50) return { dot: 'bg-brass-600', label: `${n} in stock` }; + return { dot: 'bg-evergreen', label: `${n} in stock` }; +} + +export default function WidgetCard({ widget }: { widget: Widget }) { + const preset = widgetPresets[widget.icon] ?? standardPreset; + const stock = stockMeta(widget.numberInStock); + const visibleCategories = widget.categories.slice(0, 2); + + return ( +
+ {preset.hero} +
+
+

+ {widget.type} +

+ + {currency.format(widget.price)} + +
+

+ {widget.description} +

+
+ + + {visibleCategories.length > 0 && ( + + {visibleCategories.join(' · ')} + + )} +
+
+
+ ); +} diff --git a/src/components/WidgetCardSkeleton.tsx b/src/components/WidgetCardSkeleton.tsx new file mode 100644 index 0000000..f3dc8ac --- /dev/null +++ b/src/components/WidgetCardSkeleton.tsx @@ -0,0 +1,15 @@ +export default function WidgetCardSkeleton() { + return ( +