From aacb7e4d64ccf6c772e70b48b7bf263288415671 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 22 Oct 2026 09:19:09 +0530 Subject: [PATCH 01/22] feat: scaffold local practice with enforced checks --- .github/workflows/ci.yml | 26 + .gitignore | 13 + .oxlintrc.json | 51 + AGENTS.md | 2 +- README.md | 26 +- lefthook.yml | 6 + package.json | 44 + pages/index.tsx | 5 + playwright.config.ts | 16 + pnpm-lock.yaml | 4532 ++++++++++++++++++++ pnpm-workspace.yaml | 24 + scripts/oxlint-boundaries.mjs | 35 + src/features/practice/browser/practice.tsx | 15 + tests/browser/fixtures.ts | 1 + tests/browser/practice.spec.ts | 17 + tests/fence.test.mjs | 43 + tsconfig.json | 46 + vite.config.ts | 10 + void.config.ts | 8 + 19 files changed, 4918 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .oxlintrc.json create mode 100644 lefthook.yml create mode 100644 package.json create mode 100644 pages/index.tsx create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/oxlint-boundaries.mjs create mode 100644 src/features/practice/browser/practice.tsx create mode 100644 tests/browser/fixtures.ts create mode 100644 tests/browser/practice.spec.ts create mode 100644 tests/fence.test.mjs create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 void.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bcf310f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI +on: + push: + pull_request: +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.21.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm check + - run: pnpm test + - run: pnpm build + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: browser-results + path: test-results/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73a5f36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules +dist +.void +.wrangler +.void-wrangler.jsonc +.env +.env.* +!.env.example +*.tsbuildinfo +.DS_Store +playwright-report/ +test-results/ +.fence-fixtures-*/ diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..529e3ba --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,51 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "typescript", + "react", + "jsx-a11y" + ], + "jsPlugins": [ + "./scripts/oxlint-boundaries.mjs" + ], + "categories": { + "correctness": "error" + }, + "rules": { + "strokes-boundaries/no-platform-in-domain": "error", + "typescript/no-explicit-any": "error", + "typescript/no-non-null-assertion": "error", + "typescript/consistent-type-imports": "error", + "jsx-a11y/prefer-tag-over-role": "off" + }, + "overrides": [ + { + "files": [ + "src/**/domain/**/*.{ts,tsx}", + "pages/**/*.{ts,tsx}", + ".fence-fixtures/domain/*.ts" + ], + "rules": { + "no-restricted-globals": [ + "error", + "window", + "document", + "navigator", + "localStorage", + "sessionStorage", + "fetch", + "PointerEvent", + "ResizeObserver", + "require" + ] + } + } + ], + "ignorePatterns": [ + "node_modules", + ".void", + "dist", + "test-results", + "playwright-report" + ] +} diff --git a/AGENTS.md b/AGENTS.md index ee417c7..c22cdcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ The checked-in lesson is authoritative for the teaching sequence. React state ow ## Enforcement -The folder currently contains documentation only. The rat-stack fence is required but is not installed or verified yet. Establish exact dependency pins, strict type checking with Effect diagnostics, import-boundary lint, tests, commit hooks, and CI with the scaffold. Derive commands from its package scripts. +The Void/React scaffold uses exact dependency pins, strict type checking with Effect diagnostics, Oxlint import boundaries, browser tests, commit hooks, and CI. Run `pnpm check`, `pnpm test`, and `pnpm build`; derive commands from the package scripts and preserve the fence. Read installed Void guidance at `node_modules/void/skills/void/SKILL.md` and matching `node_modules/void/docs/` before framework changes. Read `node_modules/effect/AGENTS.md` and pinned source before Effect changes. Keep provider implementations outside domain code. Framework routes adapt shared contracts when server actions are introduced. Keep browser APIs in browser modules. Report violations of these patterns explicitly and fix their cause; preserve checks rather than bypassing them. diff --git a/README.md b/README.md index cfe3f26..b8313c7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A small Hindi handwriting practice app for mobile browsers. -Status: investigation and implementation plan. No app scaffold, dependencies, automated fence, or deployment exists yet. +Status: local Void/React practice shell and automated fence implemented. Tracing, stroke checking, calibration, content review, and device verification are still pending; no deployment exists. Use React and Void. Use rat-stack as the engineering reference: Effect for external work and typed failures, and compiler, lint, tests, and hooks for enforcement. The owner's explicit choices replace Foldkit with React and Alchemy with Void deployment. @@ -103,3 +103,27 @@ Start with local development and no cloud data resources. Keep rat-stack's check ## Intentionally deferred Accounts, email, cloud persistence, spaced repetition, whole-word recognition, OCR, model training, and polished stroke morphing. No Skritter code or character dataset is part of the proposed app. + +## Local development and verification + +Use Node 24.21.0 or newer and the package-manager version pinned in the manifest. + +```sh +pnpm install --frozen-lockfile +pnpm exec playwright install chromium +pnpm dev +``` + +The app is the actual Void 1.2.0 React Static Pages starter, without database or deployment setup. Void's page entry renders the browser practice module. Lesson content and pure geometry belong under the practice domain; browser drawing belongs under its browser folder. Provider adapters and server routes cannot be imported into either domain or browser code. There is no server mutation or cloud state. + +The generated `dev`, `build`, `preview`, and `postinstall` commands are retained. The enforcement scripts are: + +- `pnpm check`: Void type generation, strict TypeScript with patched Effect diagnostics, and Oxlint including import boundaries. +- `pnpm test`: intentional compiler/import failure probes followed by the complete-page Playwright checks. +- `pnpm build`: Void's client and Worker build, without publishing. + +`prepare` patches the pinned TypeScript 7.0.2 compiler using compatible `@effect/tsgo` 0.56.0 and installs Lefthook. Every commit runs check and test; CI installs from the frozen lockfile, installs Chromium, then runs check, test, and build. Do not bypass hooks. The fence regression test temporarily introduces invalid types, a floating Effect, and forbidden imports, requires each to fail, and removes the probes afterward. + +Oxlint's `jsx-a11y/prefer-tag-over-role` is disabled because it incorrectly demands that the accessible SVG writing pad become an HTML image; the pad needs SVG geometry and an explicit image role. Other accessibility correctness rules remain enabled. The initial shell is deliberately unstyled and provides no tracing or grading yet. + +A browser smoke check at phone and desktop viewport sizes proves that the local shell loads and the pad stays square. This is emulation, not physical-device validation. No deployment or real-phone verification has been performed. diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..5618258 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,6 @@ +pre-commit: + commands: + check: + run: pnpm check + test: + run: pnpm test diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba95b00 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "strokes", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "postinstall": "void prepare", + "check": "pnpm typecheck && pnpm lint", + "typecheck": "void prepare && tsc --noEmit", + "lint": "oxlint --deny-warnings", + "test": "pnpm test:fence && pnpm test:browser", + "test:browser": "playwright test", + "prepare": "effect-tsgo patch && lefthook install", + "test:fence": "node --test tests/fence.test.mjs" + }, + "dependencies": { + "effect": "4.0.5", + "hono": "4.13.13", + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { + "@effect/tsgo": "0.56.0", + "@playwright/test": "1.64.1", + "@types/node": "26.6.5", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@void/react": "1.2.0", + "fast-check": "4.10.2", + "lefthook": "2.2.2", + "oxc-transform-react": "0.152.0", + "oxlint": "1.89.0", + "typescript": "7.0.2", + "vite": "8.3.4", + "vitest": "5.0.5", + "void": "1.2.0" + }, + "packageManager": "pnpm@12.5.1", + "engines": { + "node": ">=24.21.0" + } +} diff --git a/pages/index.tsx b/pages/index.tsx new file mode 100644 index 0000000..cc0196b --- /dev/null +++ b/pages/index.tsx @@ -0,0 +1,5 @@ +import { Practice } from "../src/features/practice/browser/practice"; + +export default function HomePage() { + return ; +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c91c70d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/browser", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: 0, + use: { baseURL: "http://127.0.0.1:4173", trace: "retain-on-failure" }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "pnpm dev --host 127.0.0.1 --port 4173 --strictPort", + url: "http://127.0.0.1:4173", + reuseExistingServer: false, + timeout: 60_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..3cc864b --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4532 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.5.1 + version: 12.5.1 + +packages: + + '@pnpm/exe.android-arm64@12.5.1': + resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.5.1': + resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.5.1': + resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.5.1': + resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.5.1': + resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.5.1': + resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.5.1': + resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.5.1': + resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.5.1': + resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.5.1': + resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.5.1': + resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.5.1': + resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.5.1': + resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.5.1': + resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + cpu: [x64] + os: [win32] + + pnpm@12.5.1: + resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.5.1': + optional: true + + '@pnpm/exe.android-x64@12.5.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.5.1': + optional: true + + '@pnpm/exe.darwin-x64@12.5.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.5.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.5.1': + optional: true + + '@pnpm/exe.linux-arm64@12.5.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.5.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.5.1': + optional: true + + '@pnpm/exe.linux-s390x@12.5.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.5.1': + optional: true + + '@pnpm/exe.linux-x64@12.5.1': + optional: true + + '@pnpm/exe.win32-arm64@12.5.1': + optional: true + + '@pnpm/exe.win32-x64@12.5.1': + optional: true + + pnpm@12.5.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.5.1 + '@pnpm/exe.android-x64': 12.5.1 + '@pnpm/exe.darwin-arm64': 12.5.1 + '@pnpm/exe.darwin-x64': 12.5.1 + '@pnpm/exe.freebsd-x64': 12.5.1 + '@pnpm/exe.linux-arm64': 12.5.1 + '@pnpm/exe.linux-arm64-musl': 12.5.1 + '@pnpm/exe.linux-ppc64': 12.5.1 + '@pnpm/exe.linux-riscv64': 12.5.1 + '@pnpm/exe.linux-s390x': 12.5.1 + '@pnpm/exe.linux-x64': 12.5.1 + '@pnpm/exe.linux-x64-musl': 12.5.1 + '@pnpm/exe.win32-arm64': 12.5.1 + '@pnpm/exe.win32-x64': 12.5.1 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + effect: + specifier: 4.0.5 + version: 4.0.5 + hono: + specifier: 4.13.13 + version: 4.13.13 + react: + specifier: 19.3.1 + version: 19.3.1 + react-dom: + specifier: 19.3.1 + version: 19.3.1(react@19.3.1) + devDependencies: + '@effect/tsgo': + specifier: 0.56.0 + version: 0.56.0 + '@playwright/test': + specifier: 1.64.1 + version: 1.64.1 + '@types/node': + specifier: 26.6.5 + version: 26.6.5 + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@void/react': + specifier: 1.2.0 + version: 1.2.0(oxc-transform-react@0.152.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(void@1.2.0(@types/node@26.6.5)(arktype@2.2.9)(kysely@0.29.7)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(valibot@1.5.0(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(wrangler@4.151.3(@cloudflare/workers-types@5.20261021.1)(@types/node@26.6.5))(zod@4.6.7)) + fast-check: + specifier: 4.10.2 + version: 4.10.2 + lefthook: + specifier: 2.2.2 + version: 2.2.2 + oxc-transform-react: + specifier: 0.152.0 + version: 0.152.0 + oxlint: + specifier: 1.89.0 + version: 1.89.0 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.3.4 + version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + vitest: + specifier: 5.0.5 + version: 5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + void: + specifier: 1.2.0 + version: 1.2.0(@types/node@26.6.5)(arktype@2.2.9)(kysely@0.29.7)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(valibot@1.5.0(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(wrangler@4.151.3(@cloudflare/workers-types@5.20261021.1)(@types/node@26.6.5))(zod@4.6.7) + +packages: + + '@ark/schema@0.56.8': + resolution: {integrity: sha512-R05TOe85oQBGFFw2zKMuZpqkXibLKuC/aJt6VwFn5Cjc4x3NkWfhvGRpRDycNkj3ddcHnOPtIsmjVowJO9Yxdw==} + + '@ark/util@0.56.8': + resolution: {integrity: sha512-XLiQyGfpJcws1zCtp2Cva8s1oheCdFn1Rt6JSyC4NnkDrpwCJnlaiawuuOc1Z97vMH+jEzW5vyyf0xOju5wUQA==} + + '@better-auth/core@1.7.12': + resolution: {integrity: sha512-SUStBGHJ13KnSO6+7U/1DSlfuOtln4jtOrFZqzQtxZVHZIrAoMjwHNGitlF+s7hvKbuC3Z14xjnqfLLqIqGfxw==} + peerDependencies: + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/api': ^1.9.0 + better-call: 1.4.4 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.7.12': + resolution: {integrity: sha512-Qzoyuf05aK01Pc0k3EXv8Xj3XCT2m2dCWc5HoB7x+xX6N3Pm2eQsca+UIArsJcbuNEWWP3L51oIebnBoHJ5DTw==} + peerDependencies: + '@better-auth/core': ^1.7.12 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.7.12': + resolution: {integrity: sha512-1PaFXktLv4n3DmrGbPaCj5SsvcT40NnWw7NLua5/J1+8/A051N5ZzqOwJifXEIQX5k6NdwvmymwDQxKytduEAQ==} + peerDependencies: + '@better-auth/core': ^1.7.12 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.7.12': + resolution: {integrity: sha512-glaDwXzDptnddHqRuu7fj47FAssUcOzLNgdlTGJoEPrspkX69x2vnXwgAzNuApjZI21WLtPH/rQ6CN+DQGybPA==} + peerDependencies: + '@better-auth/core': ^1.7.12 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + + '@better-auth/mongo-adapter@1.7.12': + resolution: {integrity: sha512-HKV4sgYSw/Gdt9AuHlESMkiKL72SWdGweDy1Wsyxoh06BpG7aNu4aSJ7marNpMdKZteyRbxWWqQkA9ediA8qFw==} + peerDependencies: + '@better-auth/core': ^1.7.12 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.7.12': + resolution: {integrity: sha512-FZRX6ORbwhDmeYE1Tl0IPtUn8v3BlVFgbJxDnm/DCNHxuFRf+0t4HfddUgYeYehHFR8aSDsdYX44fEFWM9877w==} + peerDependencies: + '@better-auth/core': ^1.7.12 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.7.12': + resolution: {integrity: sha512-DaDkKNnI99vKGGbWq9KJg2uYeaOjsK9nIBw9T2m2KG/RkxpvQRjYkZ00++up2bZQkR5pB1nukGbplza3UJG/Xg==} + peerDependencies: + '@better-auth/core': ^1.7.12 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + + '@better-fetch/fetch@1.3.2': + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} + + '@cloudflare/build-output-utils@0.9.3': + resolution: {integrity: sha512-0MgvIq6UxbRWLwB2QJU7qjLqpPkfygOebN3UiENhdp+z6zpRKrNnwkms2ynadmxCOh3sL2v+SI0/pNXxgXdteA==} + + '@cloudflare/codemods@0.5.1': + resolution: {integrity: sha512-8pAR4f6XhcA+194Oov82bDirSC/k8g0IXoIl+1ej3d7PUS3CPvlM2E4uQSzusiUbSS+4vTLP2RZA16vWuKIPAQ==} + hasBin: true + + '@cloudflare/config@0.27.0': + resolution: {integrity: sha512-wNnL0z4lj5Gm2fze5qXTOvRPbUOi4jlMmwPk7ArCUakI5oVxgjfl3Rhnfd/Mmzo3g86x/ofH75yPIhsa5msqdQ==} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/runtime-types@0.1.12': + resolution: {integrity: sha512-eYF5teHQ+Jss4sbSVINM5tNPHxo/kJ2gKcMs7omyy7gO9PEkgUbGPZCYl06SxbBWTcJFF2vrX4epB775QTE4BQ==} + + '@cloudflare/sandbox@1.0.2': + resolution: {integrity: sha512-JPPASKBQ5PE1XLy18zzljh3oIyDleu8lK3jW/HaSGq+eh9aJMl5zwAgobuP7I2FLuRMlqA18te5MIPEBF56jlg==} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vite-plugin@1.64.0': + resolution: {integrity: sha512-h7Tunx9Ou5fUoao8O9jYTH0ParfrtNZzZVPA0IS19O7AoWA1SpYjwzDA6pZC78qzuV8sQSEOpYCN/yTShmd+gg==} + hasBin: true + peerDependencies: + vite: ^6.1.0 || ^7.0.0 || ^8.0.0 + wrangler: ^4.151.0 + + '@cloudflare/workerd-darwin-64@1.20261012.1': + resolution: {integrity: sha512-dkTL9aY3V4D40b+jBpGPCistWUPFQ/M61wlWi6i1o8MvHFFgEw7EQkcaYEv6Yd3bkPDD/3TFT4hRsMb2xVZSwQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-64@1.20261019.1': + resolution: {integrity: sha512-5ZILbAta/fBQvhmAYeCE7dKMMYIr7N9wJEX50fEGLKSIwGtvT0HzSfHNIK/O7cBQ4gHQ2WfIiPClvwcye6E0fA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261012.1': + resolution: {integrity: sha512-2/nVGcKXjNNI+DhxKSepTKvFng9H7aN2WXv6Zu67iqgI6JrxypNt5kTPv4ZHosxrcfyKMci/2RfN7GIrN0BitQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261019.1': + resolution: {integrity: sha512-cww2Ab6lVMxpTiEEjKmPI58p5EMo0vYTZSH+3WkqXJdQgc3ES66GuL247+U2B2YWajhhu2tqj7jySrXneU749g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20261012.1': + resolution: {integrity: sha512-uMe7ONHnLxStD1Ye0YFHBO36uj4cStv5P53rW+5GVWXFT8BRjqVixlH0IqCAHe1Pmcwyd1+7fVoGde89MZSCJg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-64@1.20261019.1': + resolution: {integrity: sha512-4++VRi9vkQROYqrpZYOixIJuVDmujkwrT4kszsxNFlvcmsX6Mr2GumvxIN3gIixS3vJUh3Umdtkthqj5X0S3Xw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261012.1': + resolution: {integrity: sha512-I0VMdcXk7ygG5vYDce0ePTBFdnBXC0i9WiBY5mWiv0iTinbfea6/rHqSQ7B1UMpjgrYVs9E0m4bZ9ufsk5GCFA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261019.1': + resolution: {integrity: sha512-75PVtJOwfjScQ53sbLtQXI7D8yP1c21t+WEUPaYbqkzaz4ZqfR5wAd+zuWs8G5Q7kOj2SQIvEfY5Sn8Y+UVrnA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20261012.1': + resolution: {integrity: sha512-c5hQvNx574UEoknaCFvMa/NdLrrYiALNbe6sx1okW8uOiRYWxrFuA8WpXFnBJ5XJypm0TEAHYaUTGv3rRLpY7Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workerd-windows-64@1.20261019.1': + resolution: {integrity: sha512-z49fvKTETMCnG6wnMcQwxx9CpryFceiKz04FRjq26EkhCyzRNJ6c3UXhyZz3r5f1CXnnBZc5DcU8ZouPkUJCqw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-auth@0.14.1': + resolution: {integrity: sha512-JghUxus3O6IJW8UcghCUHBvVSVEiGVQ2ESQ2GJw/EZKX8K/rP6vwzq2VT59bgWY8w3epdzYwlm3INrzFJQCACQ==} + peerDependencies: + msw: 2.14.6 + vitest: ^4.1.0 || ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vitest: + optional: true + + '@cloudflare/workers-types@5.20261021.1': + resolution: {integrity: sha512-HLu/pEx2488dO13gQoLaLmMMTcah/NqEn/c/6c/sJV9nyBY/CVQAI9Zj+YugKacrxENDmIVPU0HKIK+mU7WKzA==} + + '@cloudflare/workers-utils@0.47.3': + resolution: {integrity: sha512-Eez8j60Iglt7Yyca+vsGy5HEX0+DynkW4mNG7YjLePezh+J9OOG18XZFeQsZh6R9R27/fL6jcNEzW5SZGEiHCQ==} + peerDependencies: + vitest: ^4.1.0 || ^5.0.0 + peerDependenciesMeta: + vitest: + optional: true + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@effect/tsgo-darwin-arm64@0.56.0': + resolution: {integrity: sha512-08yYRU3OOw/jKzy582EF741xKvLJ2HK6GO+L2zuSR97b61ES7fER2fxg1xp4mv8u2Fc3bBRvk7026GzdKEGFgw==} + cpu: [arm64] + os: [darwin] + + '@effect/tsgo-darwin-x64@0.56.0': + resolution: {integrity: sha512-UQvYvVaam+EVMYm9gfHBx1WpKwLlW+ptjb2UD7qGhZdg+GzxMbl26AhuuqV2t/dN9shZ6EcFE2+++ThISMp/9g==} + cpu: [x64] + os: [darwin] + + '@effect/tsgo-linux-arm64@0.56.0': + resolution: {integrity: sha512-zzzo/w7lLqiCMuJOI+jbjIuhHob3ccmth1bD9m1E98ukMuHhIqQMOXKU9EreoQkCGeoVrxGqHbXkl6E/+68/Sg==} + cpu: [arm64] + os: [linux] + + '@effect/tsgo-linux-arm@0.56.0': + resolution: {integrity: sha512-3WuPwP8vQPqKVpfGmKlnguuAZb7NGUyAhIHQjZnaINU6Aim7WczOy2WOmKjDPSKRlonU2WyYT+Q5T3B7f0VTLw==} + cpu: [arm] + os: [linux] + + '@effect/tsgo-linux-x64@0.56.0': + resolution: {integrity: sha512-R0d3dGf8qKPtvjff+J9HL0UjIBIES6xZiU16rMMezI4K52b+DGQkaWi1OX3tSgGivjlV+NSN7UhPJ2pYKufglw==} + cpu: [x64] + os: [linux] + + '@effect/tsgo-win32-arm64@0.56.0': + resolution: {integrity: sha512-uVaLdXULUZx9U30YxlVu5PBiu+1a+9/UT2Ex7jQpIasnJy1CHWlhMbfCD/+7Ez1vqG5fLOvUESoQu5XSjs3Lnw==} + cpu: [arm64] + os: [win32] + + '@effect/tsgo-win32-x64@0.56.0': + resolution: {integrity: sha512-Cf6SDixFTkp7Aoeg2eRwJps/2BG9lvx2m+iVRjUzxdqkTbCCNKmmOAIyDslNZm2sMwaDUw/iN/BPGqxx7xMxww==} + cpu: [x64] + os: [win32] + + '@effect/tsgo@0.56.0': + resolution: {integrity: sha512-/S5G8RJjtaVjE/RhwFNTvWQwFAQyDGc+3Ig4Oynvlo3gJu8le4MUgM/DBjoiwkwBsQ+BhufGDCBpwojplerGlw==} + hasBin: true + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@napi-rs/keyring-darwin-arm64@2.1.2': + resolution: {integrity: sha512-vsInUWUzWS6/WzR/TFNwmdtmwKWjnnR8TOpvZ/pjjxDAkJB/waz37PWzp2b8AFcw1aCcq2aQ7k2vvbLQy8pTrw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@2.1.2': + resolution: {integrity: sha512-wQihYroaQ3r++R6u5c9Gj0TCw1l1F53LCb5aLOXfVvT4DsnDnpe7p7VGYo4/33bLe0iqVevtAj+ET3yKCtOlJQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@2.1.2': + resolution: {integrity: sha512-Ve5ha7MbJsq+4TYyXYiTy4IeZOnYxkBB05uH2+nIrEKBkHmyfHttF814EfWAfoJa63KqBhYRXdYJo0o1WVL2sg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@2.1.2': + resolution: {integrity: sha512-zxijTnVnTApEyJMjgnLdVQL1g1DAudku/5brrWNDRWj4J0q0nFhUZEtzUA+nY2FvgDjl4s+2YCZYXSTH4TFMUg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@2.1.2': + resolution: {integrity: sha512-PM+SAcl/yGzgGbeeCsKrnlG4T5DBIDjmDqtKm5an1bJz0o0gwSzR3BMiXf04JenkcqascZpdcIReoEvLj0cvCw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@2.1.2': + resolution: {integrity: sha512-+ERS9oyInPCALN957cViBDbYJc724/Q+rZ8ZAz4rOHj4B4v0VR4OAWThdYZgZqD54sZVV0MdyX24qva97hYoAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@2.1.2': + resolution: {integrity: sha512-pGADtqeT3U1SaFZGjkVsmZVjeNOQx0fr77S+NWszXjZboyaUOLlGKgqG6ssAWzcwxv8uPhKc9Ij17ujNZhSqLw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@2.1.2': + resolution: {integrity: sha512-Fj5TmTnl2oriuCT5DDEYlhgxYes20uGfgOg10LK9PyPVsI8/PQ1m8I4rq/4A5hkrMtY7wvFb70YmX/yPHDYWvQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@2.1.2': + resolution: {integrity: sha512-C+KhAeU6x2A43FV3dOmMvUraJb8zsC9VZfsunjZ1cgtuAb208TALaUuAY0jThOphXqhyhBJOs2xTCQhOrRkJIw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@2.1.2': + resolution: {integrity: sha512-kBq80ZZfjXoa5wHG9kgAVhYYjaPOLgHh5ngt7r1ftwFXPXDPXKH7oM2r2j2Q64cs0pVtmodVtn5Q9e6xCwzQUQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@2.1.2': + resolution: {integrity: sha512-bhgKvVbWlZ6iR/tmvb2ht6/o0OzarUX9W7hjg+G+eVdGjR1prwtQ9lYo5ZESdn/+zyF1MIFvHkOCGja9YqDcww==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@2.1.2': + resolution: {integrity: sha512-yQtFdfuwqM0ezKlYO6r2n44in2LGzd4MC679Ddhvojip3t1hn+q/4I+gJU+cIJQVd5FAZSKNrK4tGfew3UTpAQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@2.1.2': + resolution: {integrity: sha512-9i1SR6tYeQ3o1beLeVMxhN6Fq513m9TZlTs+46+4JgvSNkaFoW9S4WrpV5MjzE+0CzVoC+dZ4RfZDIgW6+afnQ==} + engines: {node: '>= 10'} + + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + + '@oxc-project/types@0.154.0': + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + + '@oxc-transform-react/binding-android-arm-eabi@0.152.0': + resolution: {integrity: sha512-HrGZlhhyJUxz5tDWnsBliNrlhSVxM9lLF468EKab1uBBUMbMzfwHdO4aOHbI8x4EbI/gIzpMXqJbtWRwBLdwZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-transform-react/binding-android-arm64@0.152.0': + resolution: {integrity: sha512-h+LtkA/nSQsfZdCe6c3F28236gIZhpl3Bu9MjwEI7X0J+CGpEEShmZuXw5b7lwqXQnhr5l1ZIIZ4NA5tU5FW+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-transform-react/binding-darwin-arm64@0.152.0': + resolution: {integrity: sha512-EycvN1vJ5gjer1+AD4Czi/Z0gpD4ud/oRbiwDV3BDOiuN5PVrR5Dz0oqkoKtE+/eKK6QqygEtZrZzc8ARJkLLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-transform-react/binding-darwin-x64@0.152.0': + resolution: {integrity: sha512-5YUXuvxXi+7GmZPho95qteMlG8WgIhCbpYOug8MhyuTZzYtrr8CDBsdMzheBgiRNj9lPkFvduhQ4MK+QVQ06CQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-transform-react/binding-freebsd-x64@0.152.0': + resolution: {integrity: sha512-HZrLNspvHfOo1umeCkeskU7UBEXWjuQSoBGidHwjUQVOhsbDNp9k3oihXenEBFgShPycLEC11wAZSD3DtiIkXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-transform-react/binding-linux-arm-gnueabihf@0.152.0': + resolution: {integrity: sha512-qfPsGd0sbaUeLtQDojSf+reHYMBHWX+4mdq1lsARkRzGjoHCvstnTr+nKb7sTtGsdPYhbSgbPINI+h1V+KSF5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform-react/binding-linux-arm-musleabihf@0.152.0': + resolution: {integrity: sha512-lYpuYevetblWvNsylYb3BxKbXd2t+BhUqwIoa3/98zmWeLbB+VBfxV8APdZV9gbw2W04HknXKU386pt/5duvfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform-react/binding-linux-arm64-gnu@0.152.0': + resolution: {integrity: sha512-8eIPBuC+1WpE3Z7AiieAVhbkt6PLEbT4Rnm4SeVOMvIUKTml+s2PSzMu2qAUtFdIB4SFVshTj9SliturT3GHCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-arm64-musl@0.152.0': + resolution: {integrity: sha512-rv4dvLnqhgen1tqb7eoB/fNfD5fq+Bf3RTH9YuJ+8SNdqOk2TjAXFUdve0XtjD9Wost0fQ605bVXVrStVY/IEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-transform-react/binding-linux-ppc64-gnu@0.152.0': + resolution: {integrity: sha512-zP9xCGiJFyHkP6B7UtVQ99nfYXe8e7eqLqZv0C+Zr2jlhNuLMBsB77QuRczlhtRQqncRWzBI8BkQzy/Lo7GyaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-riscv64-gnu@0.152.0': + resolution: {integrity: sha512-INC57B7OIgMEoUe4NquHvg77tlYL4E6aMglhKs3FY5k5oB9/+hd2y0Xg1DVco5uDtkgilnSQRM5P0Ptz7CxXPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-riscv64-musl@0.152.0': + resolution: {integrity: sha512-4oPU+QTufHexk7GOGe/JapxAfYlU5EXUsN5J0IsBC9s3SsGUTTjpmXQYiDe4dkX5/BGerE+9mYDG94jNwuk99A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-transform-react/binding-linux-s390x-gnu@0.152.0': + resolution: {integrity: sha512-xt4qy3y+9s32F/pz7ymaf1VfDRI+cKwDnW2poFID2r51dC/gln3QGGDaqv2XLaZGQT5o4uGwIry+9CQY5AHNeA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-x64-gnu@0.152.0': + resolution: {integrity: sha512-6va4CIMFzejx2i8KhLAYuunai7b85vRpZSLvuBR4bK2ywuTb6V0kJDFJwiKjWHQRNzEvRjbl887+jqa1rKl8QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-x64-musl@0.152.0': + resolution: {integrity: sha512-l1QfEbsREILsAWIcmpEsEu9PKjmN6BKmE1Oe97QJDUjeJ3z5lBn5g2feC3nof3JJc5SsG8BiY4F+cm1yHRhmeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-transform-react/binding-openharmony-arm64@0.152.0': + resolution: {integrity: sha512-UmdDypmnG1WSZxzQXd8GfhbZQq//XsvMu5/mm+I1uCW3v+9UwpD1J7H+jg91W4xtRY1c0qAhUoMsZi+6vB7Qug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-transform-react/binding-win32-arm64-msvc@0.152.0': + resolution: {integrity: sha512-JgETFwNiqqB0BfTKMamnVF1zBMnburMoeqnyBgWwKj+JdEqgFqO/0G/O+eWUfNW4xPryjh6HGuq9eNHZLRYrjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-transform-react/binding-win32-ia32-msvc@0.152.0': + resolution: {integrity: sha512-j2oFkpEG89D38wVY43OtU1s9QUmq0C1+Dpp62PB+SDDTAMsUwU2HofnhK3h9iYTAs7wH/6YqzWOM4XjQ6g/Nmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-transform-react/binding-win32-x64-msvc@0.152.0': + resolution: {integrity: sha512-iFxYqUqkN/8D0migw/RVZoEvVcJhZ1NzJuRBal0Fgt7V1yJed0QQIHd719kL4OrVmX5f5u2tNZxJB1UJiQ0D/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.89.0': + resolution: {integrity: sha512-Pflpba5F3gRAQ12sWglRxvWTHEdAusNDvmi4Y5N1T5SY1AGkM7AixOnET8qkrnLJ/F32qAlNokO6eCknT8tmnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.89.0': + resolution: {integrity: sha512-dYmyD1x7LaPlb79vZWUcpTShk8dI9z1I+IubtqrBxWNJaDhQdPHwcsvg7q5StTU4xWyd9AwPfLaTYFZY8NZgtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.89.0': + resolution: {integrity: sha512-Z/oxOPiIqiS7fItRdxaaOEC+6GD3OpGi0IxoR0dMefzQHolsKLzScw2DNcu/5gUPok3tv+G9mslvYB5bo3i0qg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.89.0': + resolution: {integrity: sha512-eAjTkHSjAxrdCPJxO6y5sPQL5pYLKb2kvxZz9UU7tXnOZk9I/HLtkvx9S7GHY883k+AmL/eC7mqFt+w7WCwk0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.89.0': + resolution: {integrity: sha512-0lNCA9Tl1n1FKpqy2/9ckp0kIuSrtJ/l9bMRgaEhOtM7TsOHshFuwOcvW8oA9teXLMOJT61LjEKWiaHDFGxF/w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.89.0': + resolution: {integrity: sha512-OTFEvi9W+JczaZD1Caj17SGCoW8H7TE+fvx0D7ATEWa1Lohn91UMxFQ6r145SiGy/yCxeIwptMys/RAM7gSVSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.89.0': + resolution: {integrity: sha512-fRxgNHvBLswj6MrhW2uBq5UDhPRQlCbHeq/Nf8yuG9zOxhDsXqX/mi2PX1axgsQxMm88el7HLiJAzglAnflYxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.89.0': + resolution: {integrity: sha512-v9ENzHvwrQoYSWTtviH1pOYc7R9UovDxVofnck50cNmPihRRTcrg5X0UlRbLOZ7IVtxPOlh2hb7kDO+FpE8SfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.89.0': + resolution: {integrity: sha512-vfffe8mQ484AzVadlUOFpgL+JDwhvs9hrzxTgQP1FVfi4ZEUUYeffXlzC9dFV7Dgnu5s/KrNKvfmG6HxBd/ISQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.89.0': + resolution: {integrity: sha512-Buonlp4hVS7RGNP5M7bPpGNQrenyzA2C64JcO/oz9C1c6dGOXJ7VEAnaiAMY7voou0JYyCJdvDVlFQc5DZX3DQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.89.0': + resolution: {integrity: sha512-1eVwJJrJ3lYIPT+cm7BcBBZZXY6IQZP/oyonaruTor/MrVyWOpvnwt1oD5CLdsHg5aVBIxXodQSvt+1gK6ck5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.89.0': + resolution: {integrity: sha512-fRQQsKUGShdEL5Fl9o9uhj3grkZbeXKLv0icQ5tspUCNYSDy8wzzEcP1WC2bKdTQ0rSszbKlO3iiHORxV22DLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.89.0': + resolution: {integrity: sha512-dEtYjIy6+fHcjKklsmtmMkZRhwHr2UbzjmpvuPtQYSeN9awQ2hx5JLms5Wvri+8P/NZtD8v5hfgHALUql+/Ycg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.89.0': + resolution: {integrity: sha512-hqjwIj5KOYM8Wjef/p7IruNi1iByLhSzbMBk62y22P/E8q7Jj0Pp+DeUTKTRYyZYCDtU1wEGlnWOnttHbyNIww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.89.0': + resolution: {integrity: sha512-iAQN3e6o9UgUCcdGwqRx/jYcKbG3HK7ur3rH6lTqVcQeNGZ85AeWIg6cgn/edwFQ0ofEZZItMwkzyCVTahnvEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.89.0': + resolution: {integrity: sha512-MNxjEByz3N/7Q4qFQuPWvc14TpN9LoD3pECEDsJTZbP/wyB7U+hduUXEl7QAbbgVezFwsb9sWtNUO6XVBndOhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.89.0': + resolution: {integrity: sha512-xxe+i9wfuFso/VnoL4mfj0UlzE+cfCFiUB06vkXCMyQdgVVmItF5FfAF4tATAktSU6uHruWR+FjqvAT9cHZZzg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.89.0': + resolution: {integrity: sha512-saFy07cO94E664glwgLmKLI40HgTL1PY86y4T6+ud4niX4H0Kdqjcmqmjps7mQNdBytV5guGbXDdyQtRfeLbLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.89.0': + resolution: {integrity: sha512-hLFJ/o0f/dTJh/EW0vmcbREYSQO9tYgfiAAlsOkiLFiJuRRWvYtFELUXN5o2ijpSo/tlO1n+YlWN58tZwPG18A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@rolldown/binding-android-arm-eabi@1.2.14': + resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.14': + resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.14': + resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.14': + resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.14': + resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.14': + resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.14': + resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.14': + resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.14': + resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.14': + resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + + '@void/deploy-cloudflare@1.2.0': + resolution: {integrity: sha512-itHcCLtlVWi+B1ZLt2X9uxesTJhcN9GE4Nor2nrUBv4njlz9UPaxfR9Uedkd1BlDCo71y/MU5vsLWmPd+hLs9w==} + + '@void/deploy-core@1.2.0': + resolution: {integrity: sha512-Xx7qKBXGhWgz/TUxDW+DozSboNTcoNHY5X3A3bys+7drBFDUbFiI+3GhQV95FzPNDivYm6R8W7yTKaHs0KWUiw==} + + '@void/edge@1.2.0': + resolution: {integrity: sha512-YFc8bnWi5xZw1YLibz6OnqQw9lavuKcioSNFjZpPc5OCoo07mnrcqcxqxyuBEDihvgB9HiLFfaPZDWqm0I+Rhw==} + + '@void/isr@1.2.0': + resolution: {integrity: sha512-7bC7RQ3Z4wggwPnmk/YYok0sh882xLYS+2lurqrG0IZnillIPBIpeNsdqKCU8/A3qL33D8rCGjtJkBgT1s7Pvg==} + + '@void/platform@1.2.0': + resolution: {integrity: sha512-BTM6Cm5+2nCu6DEKh8fEwYl4J0KrrbLwcpIhKuK6iEGGN14MgidbNzC9TNVG5eCIUarE6AcFo1DZPKqDcjHm3A==} + + '@void/react@1.2.0': + resolution: {integrity: sha512-VrPE96gNHRQDE479vawlxsrdW3d7H/c+gWPcnwnx7azEcCfDLJL6Y5wUSctoBHYXJHDtrYjzSQIZkagBTbogOg==} + peerDependencies: + react: ^19.0.0 + react-dom: ^19.0.0 + vite: ^8.0.0 + void: 1.2.0 + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + arkregex@0.0.14: + resolution: {integrity: sha512-G3I/uCtWE0IKQiPh0qqmmAFDdtjZKZQ5mNPR6gJBeAMZ7VqYCpoaDeXnLju2Zo6ywExtmxZJELeVFb0MPDjPyA==} + + arktype@2.2.9: + resolution: {integrity: sha512-Cja0IMEn19rB35on9da1i1z6QMasuS7ULffXbetCry09jvCGYT8Bi1KgYiqri5FymF4nQAXkM90yksYkgSK2EA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + better-auth@1.7.12: + resolution: {integrity: sha512-f1fEG0QdTslzbmFA+vOGFFW/XXCWkG4kBxLeH9n1vp1otTH9+ZzV6EJ6797GStWeiYYgS5Vnt9xqE12KwSyAAw==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 || ^3.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.4.4: + resolution: {integrity: sha512-Gq4XdGFy4dmUxFEAX2nj0ycFcFbD9ZWh8YROouZoyXeF1M11TpYbplk0pDByTFF8Wz+MYa6W9052c9m7dD+mfw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + better-sqlite3@13.0.3: + resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} + engines: {node: '>=22'} + + blake3-jit@1.1.0: + resolution: {integrity: sha512-5lNvUdIw2roA6LSV39E1mDXN8WVK8kC0dGZtaZoErkVktSgUjgRueTv4WVqHIUMHGerkfhxBuqFtMNuo/GdLrA==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + cf@1.0.0-beta.18: + resolution: {integrity: sha512-SG9NqB6xEZf/WZm6+E2v0OIY5JA0b9DUgl1VOOlF0D5Y+Nk5PyhHonJOd6K2/g6f261h+Z0FwTEK69c9bfIq4g==} + engines: {node: '>=22'} + hasBin: true + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dotenv@16.3.1: + resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} + engines: {node: '>=12'} + + drizzle-arktype@0.1.3: + resolution: {integrity: sha512-X66GB2pz7Nb+NmCZefDXpdoglxjGYnB2yRU5umAK2stVkl4rvV6i6XbMg1+w1HiY/ydC8gJVq4jKAARYazpb3g==} + peerDependencies: + arktype: '>=2.0.0' + drizzle-orm: '>=0.36.0' + + drizzle-kit@0.31.11: + resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==} + hasBin: true + + drizzle-orm@0.45.4: + resolution: {integrity: sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@netlify/db': '>=0.4.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@netlify/db': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + drizzle-valibot@0.4.2: + resolution: {integrity: sha512-tzjT7g0Di/HX7426marIy8IDtWODjPgrwvgscdevLQRUe5rzYzRhx6bDsYLdDFF9VI/eaYgnjNeF/fznWJoUjg==} + peerDependencies: + drizzle-orm: '>=0.36.0' + valibot: '>=1.0.0-beta.7' + + drizzle-zod@0.8.3: + resolution: {integrity: sha512-66yVOuvGhKJnTdiqj1/Xaaz9/qzOdRJADpDa68enqS6g3t0kpNkwNYjUuaeXgZfO/UWuIM9HIhSlJ6C5ZraMww==} + peerDependencies: + drizzle-orm: '>=0.36.0' + zod: ^3.25.0 || ^4.0.0 + + effect@4.0.5: + resolution: {integrity: sha512-BY4/28NlpOd99yzmV741xbVGmp+1U7tks4oB7F/Png64bTa5tm5eCA6W0RiXsnuLDDoJpjfVSo4Dnq4BZb/89Q==} + + email-validator@2.0.4: + resolution: {integrity: sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==} + engines: {node: '>4.0'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@7.0.13: + resolution: {integrity: sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==} + engines: {node: '>= 4'} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + kysely@0.29.7: + resolution: {integrity: sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ==} + engines: {node: '>=22.0.0'} + + lefthook-android-arm64@2.2.2: + resolution: {integrity: sha512-wb9rQQFLX7u1dJLxvMrq9VUfMYi5kxM7tJQ6Y4mWEOwaU+SmIm2wjoT/nromKq27Y8fh2Qd7FgCD3G1wfW8CnQ==} + cpu: [arm64] + os: [android] + + lefthook-darwin-arm64@2.2.2: + resolution: {integrity: sha512-6V8D3bLnD/60Xhqn0o2EQD6H3Fe/U9LGkt4WF1O/3eBHbux+5ZJqoH0hfrC+Bi9Dy1Ve1AWxiAY7U2asYl8qVA==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@2.2.2: + resolution: {integrity: sha512-fH/kArZ9ZZ65wCFtvBMgGcq25qwQpkQ5jp93ba+8wpjgxxzjczYGluwHvM/NQDJWLOFXuKXW0yFs76SramQkyQ==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@2.2.2: + resolution: {integrity: sha512-MCRsU01ju989QQffOfS3gCeFfyVDXs5B/FzR65fmrNvqcxb98fldvfzzoAbnK2q6SBJlRT3QT1tNEMCxddCwlA==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@2.2.2: + resolution: {integrity: sha512-LkKuJU0S2oLimeoHwPTy1TLWB6GcN0t5lLxiXNlsBKUkr6R47QRcvPMZMoXmu3IetQtd+WliXMLE8qTwi2fqBg==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@2.2.2: + resolution: {integrity: sha512-d+HFtS2DEKaOnD7vRU0tUz8pNgHwsUYmQ22+1dyXpOrXGyzNXR0AqX5KsIRF36JGhVvO5X5Anw5QYgR3mxVSuA==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@2.2.2: + resolution: {integrity: sha512-N/OlQyAhcU6RAXjrwFxLsh70sn96rBoi+XkuLJ8CVBkk9Ci3VDXQayDZTNz5B1xML8RwoMcV8Wblniy1xFOmSA==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@2.2.2: + resolution: {integrity: sha512-6G/gKXcpIwC+XcODg7ELZYP/hIrVIHQxhy2GoRdVyecmpGMFj+BZo3iqcUmeWusQMuL+UdNexbkrUaeJalIkNw==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@2.2.2: + resolution: {integrity: sha512-eMJGi3/tqKEqu7XCZlqwQWRtK5JlL/9diaqqgRlEdlRD7GxwzHZN21z6awbITum9emea3lALvXFe6V8odQLGOA==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@2.2.2: + resolution: {integrity: sha512-4P1EaQb5xG1zhYD1RLJ6TEGdFkmHkB+Slqx6yRDPKuBDNxMiD0+kTAAdUY2E8xPuRR35xHpVPR+b3/HtzPrjQQ==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@2.2.2: + resolution: {integrity: sha512-9Ck4zancQilBfXT3ZeRipm7Lcq4RQfxyMpy8SAVINw0t/BrbjsT25V46ZuziERcwdajnE4myfMzC33GoCBwr7g==} + cpu: [x64] + os: [win32] + + lefthook@2.2.2: + resolution: {integrity: sha512-37fYHAhL12r1twaEV0PA5TwoIT491eTGGYjhhXxlYNMq3DSkiGZBQ++Jfk5maXj0vq52NRauLtMikBKtbNJs3Q==} + hasBin: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru.min@1.1.5: + resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + mime@4.1.1: + resolution: {integrity: sha512-YAApRsTpM2+gnUFHhRNGfkX8NK0V5xj1nyQLufGlwEI+eX4xRsTcel2IXDuMEGSGjBvzykNW4VuoEACgwYe7BQ==} + engines: {node: '>=16'} + hasBin: true + + miniflare@5.20261012.0-beta: + resolution: {integrity: sha512-Cjrs+vHHQ7krLO4HYELYpYBJBuMhcnNZD/Z5z/UcQSnFqWSQY3DhtTCFalXgI1/yO1iK+YrRKK8EYkumrEopxg==} + engines: {node: '>=22.0.0'} + + miniflare@5.20261012.1-beta: + resolution: {integrity: sha512-8Canhy6g9+vAhqMc0o80YphqYIwpiA3aAp8aX+uDXU7J/qbUeoZzcII9c2V1P4uT/eHD4SB93qet1o1bRY3BuQ==} + engines: {node: '>=22.0.0'} + + miniflare@5.20261019.1-beta: + resolution: {integrity: sha512-1n7MN2UqZnPniyYAqNuV1YEAaja66ozKGVCc5ZTmYQhTubnoAwGLccwb+ZWYLOajuw3pOaJ70u//bZMuURlRew==} + engines: {node: '>=22.0.0'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + mysql2@3.24.6: + resolution: {integrity: sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanostores@1.5.5: + resolution: {integrity: sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==} + engines: {node: ^20.0.0 || >=22.0.0} + + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + oxc-transform-react@0.152.0: + resolution: {integrity: sha512-CAx+SKDU4uSOmQnyn7daMj/v/h8tF3fnaUMi4yImKAARPnXV0BieHe/qw1LTcK4KlnIuAhpfUJfSfNKIBh8HqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxlint@1.89.0: + resolution: {integrity: sha512-a25hkfo61okGPvJz432fRIPoxlKtNnOVy9w4rzrmGgNm4iVTPwpSQlmdqNWnp6p9QyyPVfAZRgnf5XvSBZFk/A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathslash@0.1.0: + resolution: {integrity: sha512-mJSfhMv8Ra0gN4xGqzRQ7IpUSc4v6rV+r3X5FY9HrxC9Te2ZvtZe6RPS6UDmgOVUI5DKGDHuPXRuNzhffawG2Q==} + engines: {node: '>=22'} + + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} + + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.1: + resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + + postal-mime@4.0.11: + resolution: {integrity: sha512-T0zAqYnuSB8s+zL9WdiGj/2DeWeObEjqrcw2LWq+hgwpCSbLt7xujsFXn6Kgr1wsLl8DJh+v+lG9GQSQfMcUyA==} + engines: {node: '>=20'} + + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rolldown@1.2.14: + resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@3.1.4: + resolution: {integrity: sha512-iEmdEew+Tn1BwMGMP8LzGBEPC7Mf1sySjglCeEPb/Vchm9bnGezePvacYcId+fGprQzIng+TBkzqfJ/qS7AsBg==} + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sql-escaper@1.5.2: + resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-hyperlinks@4.6.0: + resolution: {integrity: sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ==} + engines: {node: '>=20'} + + terminal-link@5.0.0: + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} + engines: {node: '>=20'} + + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + valibot@1.5.0: + resolution: {integrity: sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + void@1.2.0: + resolution: {integrity: sha512-MSbF2UVk1twGb+5P9KJNBzJJyOAwe6MLgpraQ3XsG2cJkP9gCKARyyR4Q7s/Lt0hhX29xSfseolpsWr67aynjw==} + engines: {node: '>=24.21.0'} + hasBin: true + peerDependencies: + '@void/md': 1.2.0 + arktype: '>=2.0.0' + valibot: '>=1.0.0-beta.7' + vite: ^8.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@void/md': + optional: true + arktype: + optional: true + valibot: + optional: true + zod: + optional: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + workerd@1.20261012.1: + resolution: {integrity: sha512-tEHoMPsMga0seVjxfsR8Pd3f8IRqYwkq++dTUKI5Gdgj6zuSDluYwisydq+dn90SsIACwBquJUQ1nBpcOqaWTA==} + engines: {node: '>=16'} + hasBin: true + + workerd@1.20261019.1: + resolution: {integrity: sha512-UUYRUq/7DmeCl11YriNbcVbjUZVLtt8uuCXFu+nz77hGFf8d4DEVXoCYtnA+MlBatE4+wxoj47DVqzK3xGBAiw==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.151.3: + resolution: {integrity: sha512-mzPQLmkAhZUV7CdiAhE+hiIAJlS9xPL6vZe1B8oQFuWxFu7nmQnvHAVjcAcabwSGkXFXH9uspzXwHOFrfbQmqQ==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20261019.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + +snapshots: + + '@ark/schema@0.56.8': + dependencies: + '@ark/util': 0.56.8 + + '@ark/util@0.56.8': {} + + '@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5)': + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.4(zod@4.6.7) + jose: 6.2.13 + kysely: 0.29.7 + nanostores: 1.5.5 + zod: 4.6.7 + + '@better-auth/drizzle-adapter@1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1))': + dependencies: + '@better-auth/core': 1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + optionalDependencies: + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1) + + '@better-auth/kysely-adapter@1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(kysely@0.29.7)': + dependencies: + '@better-auth/core': 1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + optionalDependencies: + kysely: 0.29.7 + + '@better-auth/memory-adapter@1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0)': + dependencies: + '@better-auth/core': 1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + + '@better-auth/mongo-adapter@1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0)': + dependencies: + '@better-auth/core': 1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + + '@better-auth/prisma-adapter@1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0)': + dependencies: + '@better-auth/core': 1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + + '@better-auth/telemetry@1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)': + dependencies: + '@better-auth/core': 1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-fetch/fetch@1.3.2': {} + + '@cloudflare/build-output-utils@0.9.3': + dependencies: + '@cloudflare/config': 0.27.0 + + '@cloudflare/codemods@0.5.1': {} + + '@cloudflare/config@0.27.0': + dependencies: + zod: 4.6.5 + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/runtime-types@0.1.12(@types/node@26.6.5)': + dependencies: + miniflare: 5.20261012.1-beta(@types/node@26.6.5) + workerd: 1.20261012.1 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + '@cloudflare/sandbox@1.0.2': + dependencies: + aws4fetch: 1.0.20 + zod: 4.6.7 + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261012.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261012.1 + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261019.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261019.1 + + '@cloudflare/vite-plugin@1.64.0(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(wrangler@4.151.3(@cloudflare/workers-types@5.20261021.1)(@types/node@26.6.5))': + dependencies: + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261012.1) + miniflare: 5.20261012.0-beta(@types/node@26.6.5) + unenv: 2.0.0-rc.24 + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + workerd: 1.20261012.1 + wrangler: 4.151.3(@cloudflare/workers-types@5.20261021.1)(@types/node@26.6.5) + ws: 8.21.0 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20261012.1': + optional: true + + '@cloudflare/workerd-darwin-64@1.20261019.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261012.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261019.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261012.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261019.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261012.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261019.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261012.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261019.1': + optional: true + + '@cloudflare/workers-auth@0.14.1(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))': + dependencies: + '@cloudflare/workers-utils': 0.47.3(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + undici: 7.29.1 + optionalDependencies: + vitest: 5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + + '@cloudflare/workers-types@5.20261021.1': {} + + '@cloudflare/workers-utils@0.47.3(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))': + dependencies: + chalk: 5.3.0 + dotenv: 16.3.1 + undici: 7.29.1 + optionalDependencies: + vitest: 5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@drizzle-team/brocli@0.10.2': {} + + '@effect/tsgo-darwin-arm64@0.56.0': + optional: true + + '@effect/tsgo-darwin-x64@0.56.0': + optional: true + + '@effect/tsgo-linux-arm64@0.56.0': + optional: true + + '@effect/tsgo-linux-arm@0.56.0': + optional: true + + '@effect/tsgo-linux-x64@0.56.0': + optional: true + + '@effect/tsgo-win32-arm64@0.56.0': + optional: true + + '@effect/tsgo-win32-x64@0.56.0': + optional: true + + '@effect/tsgo@0.56.0': + optionalDependencies: + '@effect/tsgo-darwin-arm64': 0.56.0 + '@effect/tsgo-darwin-x64': 0.56.0 + '@effect/tsgo-linux-arm': 0.56.0 + '@effect/tsgo-linux-arm64': 0.56.0 + '@effect/tsgo-linux-x64': 0.56.0 + '@effect/tsgo-win32-arm64': 0.56.0 + '@effect/tsgo-win32-x64': 0.56.0 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.3 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/keyring-darwin-arm64@2.1.2': + optional: true + + '@napi-rs/keyring-darwin-x64@2.1.2': + optional: true + + '@napi-rs/keyring-freebsd-x64@2.1.2': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@2.1.2': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@2.1.2': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@2.1.2': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@2.1.2': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@2.1.2': + optional: true + + '@napi-rs/keyring-linux-x64-musl@2.1.2': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@2.1.2': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@2.1.2': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@2.1.2': + optional: true + + '@napi-rs/keyring@2.1.2': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 2.1.2 + '@napi-rs/keyring-darwin-x64': 2.1.2 + '@napi-rs/keyring-freebsd-x64': 2.1.2 + '@napi-rs/keyring-linux-arm-gnueabihf': 2.1.2 + '@napi-rs/keyring-linux-arm64-gnu': 2.1.2 + '@napi-rs/keyring-linux-arm64-musl': 2.1.2 + '@napi-rs/keyring-linux-riscv64-gnu': 2.1.2 + '@napi-rs/keyring-linux-x64-gnu': 2.1.2 + '@napi-rs/keyring-linux-x64-musl': 2.1.2 + '@napi-rs/keyring-win32-arm64-msvc': 2.1.2 + '@napi-rs/keyring-win32-ia32-msvc': 2.1.2 + '@napi-rs/keyring-win32-x64-msvc': 2.1.2 + + '@noble/ciphers@2.4.0': {} + + '@noble/hashes@2.4.0': {} + + '@oxc-project/types@0.154.0': {} + + '@oxc-transform-react/binding-android-arm-eabi@0.152.0': + optional: true + + '@oxc-transform-react/binding-android-arm64@0.152.0': + optional: true + + '@oxc-transform-react/binding-darwin-arm64@0.152.0': + optional: true + + '@oxc-transform-react/binding-darwin-x64@0.152.0': + optional: true + + '@oxc-transform-react/binding-freebsd-x64@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-arm-gnueabihf@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-arm-musleabihf@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-arm64-gnu@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-arm64-musl@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-ppc64-gnu@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-riscv64-gnu@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-riscv64-musl@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-s390x-gnu@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-x64-gnu@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-x64-musl@0.152.0': + optional: true + + '@oxc-transform-react/binding-openharmony-arm64@0.152.0': + optional: true + + '@oxc-transform-react/binding-win32-arm64-msvc@0.152.0': + optional: true + + '@oxc-transform-react/binding-win32-ia32-msvc@0.152.0': + optional: true + + '@oxc-transform-react/binding-win32-x64-msvc@0.152.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.89.0': + optional: true + + '@oxlint/binding-android-arm64@1.89.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.89.0': + optional: true + + '@oxlint/binding-darwin-x64@1.89.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.89.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.89.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.89.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.89.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.89.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.89.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.89.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.89.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.89.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.89.0': + optional: true + + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@rolldown/binding-android-arm-eabi@1.2.14': + optional: true + + '@rolldown/binding-android-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-x64@1.2.14': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.14': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.14': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.14': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.14': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.14': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.6.5': + dependencies: + undici-types: 8.9.0 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-react@6.1.2(oxc-transform-react@0.152.0)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + optionalDependencies: + oxc-transform-react: 0.152.0 + + '@vitest/mocker@5.0.5(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + + '@vitest/spy@5.0.5': {} + + '@void/deploy-cloudflare@1.2.0': + dependencies: + '@void/deploy-core': 1.2.0 + jsonc-parser: 3.3.1 + + '@void/deploy-core@1.2.0': {} + + '@void/edge@1.2.0': + dependencies: + '@void/deploy-core': 1.2.0 + + '@void/isr@1.2.0': + dependencies: + '@cloudflare/workers-types': 5.20261021.1 + '@void/edge': 1.2.0 + + '@void/platform@1.2.0': + dependencies: + pathslash: 0.1.0 + + '@void/react@1.2.0(oxc-transform-react@0.152.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(void@1.2.0(@types/node@26.6.5)(arktype@2.2.9)(kysely@0.29.7)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(valibot@1.5.0(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(wrangler@4.151.3(@cloudflare/workers-types@5.20261021.1)(@types/node@26.6.5))(zod@4.6.7))': + dependencies: + '@vitejs/plugin-react': 6.1.2(oxc-transform-react@0.152.0)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + void: 1.2.0(@types/node@26.6.5)(arktype@2.2.9)(kysely@0.29.7)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(valibot@1.5.0(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(wrangler@4.151.3(@cloudflare/workers-types@5.20261021.1)(@types/node@26.6.5))(zod@4.6.7) + transitivePeerDependencies: + - '@rolldown/plugin-babel' + - babel-plugin-react-compiler + - oxc-transform-react + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + arkregex@0.0.14: + dependencies: + '@ark/util': 0.56.8 + + arktype@2.2.9: + dependencies: + '@ark/schema': 0.56.8 + '@ark/util': 0.56.8 + arkregex: 0.0.14 + + assertion-error@2.0.1: {} + + aws-ssl-profiles@1.1.2: {} + + aws4fetch@1.0.20: {} + + balanced-match@4.0.4: {} + + better-auth@1.7.12(drizzle-kit@0.31.11)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1))(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))): + dependencies: + '@better-auth/core': 1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5) + '@better-auth/drizzle-adapter': 1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1)) + '@better-auth/kysely-adapter': 1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(kysely@0.29.7) + '@better-auth/memory-adapter': 1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0) + '@better-auth/mongo-adapter': 1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0) + '@better-auth/prisma-adapter': 1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0) + '@better-auth/telemetry': 1.7.12(@better-auth/core@1.7.12(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.4(zod@4.6.7))(jose@6.2.13)(kysely@0.29.7)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.4.4(zod@4.6.7) + defu: 6.1.7 + jose: 6.2.13 + kysely: 0.29.7 + nanostores: 1.5.5 + zod: 4.6.7 + optionalDependencies: + drizzle-kit: 0.31.11 + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1) + mysql2: 3.24.6(@types/node@26.6.5) + pg: 8.23.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + vitest: 5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + transitivePeerDependencies: + - '@opentelemetry/api' + + better-call@1.4.4(zod@4.6.7): + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + rou3: 0.9.2 + set-cookie-parser: 3.1.4 + optionalDependencies: + zod: 4.6.7 + + better-sqlite3@13.0.3: + dependencies: + node-addon-api: 8.9.2 + + blake3-jit@1.1.0: {} + + blake3-wasm@2.1.5: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + buffer-from@1.1.2: {} + + cf@1.0.0-beta.18(@types/node@26.6.5): + dependencies: + '@cloudflare/build-output-utils': 0.9.3 + '@cloudflare/config': 0.27.0 + '@cloudflare/runtime-types': 0.1.12(@types/node@26.6.5) + blake3-wasm: 2.1.5 + miniflare: 5.20261012.1-beta(@types/node@26.6.5) + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + chai@6.3.0: {} + + chalk@5.3.0: {} + + cookie@1.1.1: {} + + csstype@3.2.3: {} + + defu@6.1.7: {} + + detect-libc@2.1.2: {} + + dotenv@16.3.1: {} + + drizzle-arktype@0.1.3(arktype@2.2.9)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1)): + dependencies: + arktype: 2.2.9 + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1) + + drizzle-kit@0.31.11: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.15 + + drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1): + optionalDependencies: + '@cloudflare/workers-types': 5.20261021.1 + better-sqlite3: 13.0.3 + kysely: 0.29.7 + mysql2: 3.24.6(@types/node@26.6.5) + pg: 8.23.1 + + drizzle-valibot@0.4.2(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1))(valibot@1.5.0(typescript@7.0.2)): + dependencies: + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1) + valibot: 1.5.0(typescript@7.0.2) + + drizzle-zod@0.8.3(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1))(zod@4.6.7): + dependencies: + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1) + zod: 4.6.7 + + effect@4.0.5: {} + + email-validator@2.0.4: {} + + environment@1.1.0: {} + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + 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 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + + graceful-fs@4.2.11: {} + + has-flag@5.0.1: {} + + hono@4.13.13: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@7.0.13: {} + + is-property@1.0.2: {} + + jiti@2.7.0: {} + + jose@6.2.13: {} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: {} + + kysely@0.29.7: {} + + lefthook-android-arm64@2.2.2: + optional: true + + lefthook-darwin-arm64@2.2.2: + optional: true + + lefthook-darwin-x64@2.2.2: + optional: true + + lefthook-freebsd-arm64@2.2.2: + optional: true + + lefthook-freebsd-x64@2.2.2: + optional: true + + lefthook-linux-arm64@2.2.2: + optional: true + + lefthook-linux-x64@2.2.2: + optional: true + + lefthook-openbsd-arm64@2.2.2: + optional: true + + lefthook-openbsd-x64@2.2.2: + optional: true + + lefthook-windows-arm64@2.2.2: + optional: true + + lefthook-windows-x64@2.2.2: + optional: true + + lefthook@2.2.2: + optionalDependencies: + lefthook-android-arm64: 2.2.2 + lefthook-darwin-arm64: 2.2.2 + lefthook-darwin-x64: 2.2.2 + lefthook-freebsd-arm64: 2.2.2 + lefthook-freebsd-x64: 2.2.2 + lefthook-linux-arm64: 2.2.2 + lefthook-linux-x64: 2.2.2 + lefthook-openbsd-arm64: 2.2.2 + lefthook-openbsd-x64: 2.2.2 + lefthook-windows-arm64: 2.2.2 + lefthook-windows-x64: 2.2.2 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + long@5.3.2: {} + + lru.min@1.1.5: {} + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mime@4.1.1: {} + + miniflare@5.20261012.0-beta(@types/node@26.6.5): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.5(@types/node@26.6.5) + undici: 7.29.1 + workerd: 1.20261012.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + miniflare@5.20261012.1-beta(@types/node@26.6.5): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.5(@types/node@26.6.5) + undici: 7.29.1 + workerd: 1.20261012.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + miniflare@5.20261019.1-beta(@types/node@26.6.5): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.5(@types/node@26.6.5) + undici: 7.29.1 + workerd: 1.20261019.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + mysql2@3.24.6(@types/node@26.6.5): + dependencies: + '@types/node': 26.6.5 + aws-ssl-profiles: 1.1.2 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.5 + named-placeholders: 1.1.6 + sql-escaper: 1.5.2 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.5 + + nanoid@3.3.20: {} + + nanostores@1.5.5: {} + + node-addon-api@8.9.2: {} + + obug@3.0.0: {} + + oxc-transform-react@0.152.0: + optionalDependencies: + '@oxc-transform-react/binding-android-arm-eabi': 0.152.0 + '@oxc-transform-react/binding-android-arm64': 0.152.0 + '@oxc-transform-react/binding-darwin-arm64': 0.152.0 + '@oxc-transform-react/binding-darwin-x64': 0.152.0 + '@oxc-transform-react/binding-freebsd-x64': 0.152.0 + '@oxc-transform-react/binding-linux-arm-gnueabihf': 0.152.0 + '@oxc-transform-react/binding-linux-arm-musleabihf': 0.152.0 + '@oxc-transform-react/binding-linux-arm64-gnu': 0.152.0 + '@oxc-transform-react/binding-linux-arm64-musl': 0.152.0 + '@oxc-transform-react/binding-linux-ppc64-gnu': 0.152.0 + '@oxc-transform-react/binding-linux-riscv64-gnu': 0.152.0 + '@oxc-transform-react/binding-linux-riscv64-musl': 0.152.0 + '@oxc-transform-react/binding-linux-s390x-gnu': 0.152.0 + '@oxc-transform-react/binding-linux-x64-gnu': 0.152.0 + '@oxc-transform-react/binding-linux-x64-musl': 0.152.0 + '@oxc-transform-react/binding-openharmony-arm64': 0.152.0 + '@oxc-transform-react/binding-win32-arm64-msvc': 0.152.0 + '@oxc-transform-react/binding-win32-ia32-msvc': 0.152.0 + '@oxc-transform-react/binding-win32-x64-msvc': 0.152.0 + + oxlint@1.89.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.89.0 + '@oxlint/binding-android-arm64': 1.89.0 + '@oxlint/binding-darwin-arm64': 1.89.0 + '@oxlint/binding-darwin-x64': 1.89.0 + '@oxlint/binding-freebsd-x64': 1.89.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.89.0 + '@oxlint/binding-linux-arm-musleabihf': 1.89.0 + '@oxlint/binding-linux-arm64-gnu': 1.89.0 + '@oxlint/binding-linux-arm64-musl': 1.89.0 + '@oxlint/binding-linux-ppc64-gnu': 1.89.0 + '@oxlint/binding-linux-riscv64-gnu': 1.89.0 + '@oxlint/binding-linux-riscv64-musl': 1.89.0 + '@oxlint/binding-linux-s390x-gnu': 1.89.0 + '@oxlint/binding-linux-x64-gnu': 1.89.0 + '@oxlint/binding-linux-x64-musl': 1.89.0 + '@oxlint/binding-openharmony-arm64': 1.89.0 + '@oxlint/binding-win32-arm64-msvc': 1.89.0 + '@oxlint/binding-win32-ia32-msvc': 1.89.0 + '@oxlint/binding-win32-x64-msvc': 1.89.0 + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + pathslash@0.1.0: {} + + pg-cloudflare@1.4.1: + optional: true + + pg-connection-string@2.14.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.1): + dependencies: + pg: 8.23.1 + + pg-protocol@1.16.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.1: + dependencies: + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.23.1) + pg-protocol: 1.16.1 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + + postal-mime@4.0.11: {} + + postcss@8.5.31: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + pure-rand@8.4.2: {} + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react@19.3.1: {} + + resolve-pkg-maps@1.0.0: {} + + retry@0.12.0: {} + + rolldown@1.2.14: + dependencies: + '@oxc-project/types': 0.154.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.14 + '@rolldown/binding-android-arm64': 1.2.14 + '@rolldown/binding-darwin-arm64': 1.2.14 + '@rolldown/binding-darwin-x64': 1.2.14 + '@rolldown/binding-freebsd-x64': 1.2.14 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.14 + '@rolldown/binding-linux-arm64-gnu': 1.2.14 + '@rolldown/binding-linux-arm64-musl': 1.2.14 + '@rolldown/binding-linux-ppc64-gnu': 1.2.14 + '@rolldown/binding-linux-s390x-gnu': 1.2.14 + '@rolldown/binding-linux-x64-gnu': 1.2.14 + '@rolldown/binding-linux-x64-musl': 1.2.14 + '@rolldown/binding-openharmony-arm64': 1.2.14 + '@rolldown/binding-win32-arm64-msvc': 1.2.14 + '@rolldown/binding-win32-x64-msvc': 1.2.14 + + rou3@0.9.2: {} + + safer-buffer@2.1.2: {} + + scheduler@0.28.0: {} + + semver@7.8.5: {} + + set-cookie-parser@3.1.4: {} + + sharp@0.35.5(@types/node@26.6.5): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 26.6.5 + + signal-exit@3.0.7: {} + + source-map-js@1.2.2: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + split2@4.2.0: {} + + sql-escaper@1.5.2: {} + + std-env@4.3.0: {} + + supports-color@10.2.2: {} + + supports-hyperlinks@4.6.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + terminal-link@5.0.0: + dependencies: + ansi-escapes: 7.3.0 + supports-hyperlinks: 4.6.0 + + tinybench@6.2.2: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tslib@2.8.1: + optional: true + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.9.0: {} + + undici@7.29.1: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + valibot@1.5.0(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 + + vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.31 + rolldown: 1.2.14 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.5 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.15 + yaml: 2.9.1 + + vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 26.6.5 + transitivePeerDependencies: + - msw + + void@1.2.0(@types/node@26.6.5)(arktype@2.2.9)(kysely@0.29.7)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(valibot@1.5.0(typescript@7.0.2))(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(wrangler@4.151.3(@cloudflare/workers-types@5.20261021.1)(@types/node@26.6.5))(zod@4.6.7): + dependencies: + '@cloudflare/build-output-utils': 0.9.3 + '@cloudflare/codemods': 0.5.1 + '@cloudflare/config': 0.27.0 + '@cloudflare/sandbox': 1.0.2 + '@cloudflare/vite-plugin': 1.64.0(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(wrangler@4.151.3(@cloudflare/workers-types@5.20261021.1)(@types/node@26.6.5)) + '@cloudflare/workers-auth': 0.14.1(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + '@cloudflare/workers-types': 5.20261021.1 + '@napi-rs/keyring': 2.1.2 + '@void/deploy-cloudflare': 1.2.0 + '@void/deploy-core': 1.2.0 + '@void/edge': 1.2.0 + '@void/isr': 1.2.0 + '@void/platform': 1.2.0 + better-auth: 1.7.12(drizzle-kit@0.31.11)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1))(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + better-sqlite3: 13.0.3 + blake3-jit: 1.1.0 + cf: 1.0.0-beta.18(@types/node@26.6.5) + drizzle-arktype: 0.1.3(arktype@2.2.9)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1)) + drizzle-kit: 0.31.11 + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1) + drizzle-valibot: 0.4.2(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1))(valibot@1.5.0(typescript@7.0.2)) + drizzle-zod: 0.8.3(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261021.1)(better-sqlite3@13.0.3)(kysely@0.29.7)(mysql2@3.24.6(@types/node@26.6.5))(pg@8.23.1))(zod@4.6.7) + email-validator: 2.0.4 + hono: 4.13.13 + ignore: 7.0.13 + jiti: 2.7.0 + jsonc-parser: 3.3.1 + mime: 4.1.1 + miniflare: 5.20261012.1-beta(@types/node@26.6.5) + minimatch: 10.2.6 + mysql2: 3.24.6(@types/node@26.6.5) + pg: 8.23.1 + postal-mime: 4.0.11 + proper-lockfile: 4.1.2 + terminal-link: 5.0.0 + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + yaml: 2.9.1 + optionalDependencies: + arktype: 2.2.9 + valibot: 1.5.0(typescript@7.0.2) + zod: 4.6.7 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@lynx-js/react' + - '@neondatabase/serverless' + - '@netlify/db' + - '@op-engineering/op-sqlite' + - '@opentelemetry/api' + - '@planetscale/database' + - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/node' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - bufferutil + - bun-types + - expo-sqlite + - gel + - knex + - kysely + - mongodb + - msw + - next + - pg-native + - postgres + - prisma + - react + - react-dom + - solid-js + - sql.js + - sqlite3 + - svelte + - utf-8-validate + - vitest + - vue + - wrangler + + why-is-node-running@3.2.1: {} + + workerd@1.20261012.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261012.1 + '@cloudflare/workerd-darwin-arm64': 1.20261012.1 + '@cloudflare/workerd-linux-64': 1.20261012.1 + '@cloudflare/workerd-linux-arm64': 1.20261012.1 + '@cloudflare/workerd-windows-64': 1.20261012.1 + + workerd@1.20261019.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261019.1 + '@cloudflare/workerd-darwin-arm64': 1.20261019.1 + '@cloudflare/workerd-linux-64': 1.20261019.1 + '@cloudflare/workerd-linux-arm64': 1.20261019.1 + '@cloudflare/workerd-windows-64': 1.20261019.1 + + wrangler@4.151.3(@cloudflare/workers-types@5.20261021.1)(@types/node@26.6.5): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261019.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.2 + miniflare: 5.20261019.1-beta(@types/node@26.6.5) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261019.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20261021.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + xtend@4.0.2: {} + + yaml@2.9.1: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod@4.6.5: {} + + zod@4.6.7: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..be1ccde --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,24 @@ +minimumReleaseAgeExclude: + - '@void/deploy-cloudflare@1.2.0' + - '@void/deploy-core@1.2.0' + - '@void/edge@1.2.0' + - '@void/isr@1.2.0' + - '@void/platform@1.2.0' + - void@1.2.0 + - '@void/react@1.2.0' + - '@effect/tsgo-darwin-arm64@0.56.0' + - '@effect/tsgo-darwin-x64@0.56.0' + - '@effect/tsgo-linux-arm64@0.56.0' + - '@effect/tsgo-linux-arm@0.56.0' + - '@effect/tsgo-linux-x64@0.56.0' + - '@effect/tsgo-win32-arm64@0.56.0' + - '@effect/tsgo-win32-x64@0.56.0' + - '@effect/tsgo@0.56.0' + +allowBuilds: + better-sqlite3: false + esbuild: true + workerd: true + sharp: true + lefthook: true + unrs-resolver: true diff --git a/scripts/oxlint-boundaries.mjs b/scripts/oxlint-boundaries.mjs new file mode 100644 index 0000000..8c8bb92 --- /dev/null +++ b/scripts/oxlint-boundaries.mjs @@ -0,0 +1,35 @@ +import { dirname, resolve } from "node:path"; + +export default { + meta: { name: "strokes-boundaries" }, + rules: { + "no-platform-in-domain": { + meta: { type: "problem", schema: [], messages: { forbidden: "Keep domain code pure and browser/server dependencies in their owning modules." } }, + create(context) { + const filename = context.filename; + const domain = filename.includes("/domain/"); + const browser = filename.includes("/browser/"); + const page = filename.includes("/pages/") && !filename.endsWith(".server.ts"); + function check(node) { + const source = node.source; + if (!source) return; + const name = source.value; + if (typeof name !== "string") { + if (domain || browser || page) context.report({ node, messageId: "forbidden" }); + return; + } + const target = name.startsWith(".") ? resolve(dirname(filename), name) : name; + const provider = target.includes("/providers/") || target.includes("/routes/") || target.endsWith(".server") || target.endsWith(".server.ts"); + const platform = /^(?:node:|react(?:-dom)?(?:\/|$)|void(?:\/|$)|@void\/)/.test(name); + const forbidden = domain + ? provider || target.includes("/browser/") || target.includes("/pages/") || platform || (!name.startsWith(".") && name !== "effect" && !name.startsWith("effect/")) + : (browser || page) + ? provider || name.startsWith("node:") || /^void\/(?:env|db|auth|sandbox)(?:\/|$)/.test(name) + : (filename.includes("/routes/") || filename.includes("/providers/")) && target.includes("/browser/"); + if (forbidden) context.report({ node, messageId: "forbidden" }); + } + return { ImportDeclaration: check, ExportNamedDeclaration: check, ExportAllDeclaration: check, ImportExpression: check }; + }, + }, + }, +}; diff --git a/src/features/practice/browser/practice.tsx b/src/features/practice/browser/practice.tsx new file mode 100644 index 0000000..adfc3c5 --- /dev/null +++ b/src/features/practice/browser/practice.tsx @@ -0,0 +1,15 @@ +export function Practice() { + return ( +
+

Strokes

+

Practice Hindi handwriting with your finger, pen, or mouse.

+ +
+ ); +} diff --git a/tests/browser/fixtures.ts b/tests/browser/fixtures.ts new file mode 100644 index 0000000..e7e0d07 --- /dev/null +++ b/tests/browser/fixtures.ts @@ -0,0 +1 @@ +export { test, expect } from "@playwright/test"; diff --git a/tests/browser/practice.spec.ts b/tests/browser/practice.spec.ts new file mode 100644 index 0000000..f43b717 --- /dev/null +++ b/tests/browser/practice.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from "./fixtures"; + +test("opens a readable practice page with a square pad on phone and desktop", async ({ page }) => { + for (const viewport of [{ width: 390, height: 844 }, { width: 1280, height: 800 }]) { + await page.setViewportSize(viewport); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Strokes", exact: true })).toBeVisible(); + await expect(page.getByText("Practice Hindi handwriting with your finger, pen, or mouse.")).toBeVisible(); + const pad = page.getByRole("img", { name: "Hindi writing practice area" }); + await expect(pad).toBeVisible(); + const bounds = await pad.boundingBox(); + expect(bounds).not.toBeNull(); + if (bounds === null) throw new Error("Practice pad has no visible bounds"); + expect(Math.abs(bounds.width - bounds.height)).toBeLessThan(1); + expect(bounds.width).toBeLessThanOrEqual(viewport.width); + } +}); diff --git a/tests/fence.test.mjs b/tests/fence.test.mjs new file mode 100644 index 0000000..9a4f669 --- /dev/null +++ b/tests/fence.test.mjs @@ -0,0 +1,43 @@ +import { strict as assert } from "node:assert"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +test("the fence rejects invalid types, floating Effects and cross-boundary imports", () => { + const directory = mkdtempSync(join(process.cwd(), ".fence-fixtures-")); + const probe = join(directory, "domain", "probe.ts"); + mkdirSync(join(directory, "domain")); + mkdirSync(join(directory, "browser")); + writeFileSync(join(directory, "browser", "adapter.ts"), "export const adapter = 1;\n"); + writeFileSync(join(directory, "tsconfig.json"), JSON.stringify({ extends: "../tsconfig.json", include: ["domain/probe.ts"], exclude: [] })); + const compile = () => spawnSync("./node_modules/.bin/tsc", ["--noEmit", "--project", join(directory, "tsconfig.json")], { encoding: "utf8" }); + const lint = () => spawnSync("./node_modules/.bin/oxlint", ["--deny-warnings", probe], { encoding: "utf8" }); + try { + writeFileSync(probe, 'export const points: number = "broken";\n'); + let result = compile(); + assert.notEqual(result.status, 0, "ordinary type violations must fail"); + assert.match(result.stdout + result.stderr, /not assignable/); + writeFileSync(probe, 'import { Effect } from "effect";\nEffect.succeed(1);\n'); + result = compile(); + assert.notEqual(result.status, 0, "Effect diagnostics must fail the compiler"); + assert.match(result.stdout + result.stderr, /floatingEffect/); + for (const source of [ + 'import { adapter } from "../browser/adapter"; export const value = adapter;', + 'export { adapter } from "../browser/adapter";', + 'export const adapter = import("../browser/adapter");', + 'import { readFile } from "node:fs"; export const adapter = readFile;', + ]) { + writeFileSync(probe, source); + result = lint(); + assert.notEqual(result.status, 0, "domain platform imports must fail"); + assert.match(result.stdout + result.stderr, /no-platform-in-domain/); + } + writeFileSync(probe, "export const points: number = 3;\n"); + execFileSync("./node_modules/.bin/tsc", ["--noEmit", "--project", join(directory, "tsconfig.json")]); + result = lint(); + assert.equal(result.status, 0, result.stdout + result.stderr); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..34457bb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,46 @@ +{ + "extends": "./.void/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": [ + "void/env", + "node" + ], + "jsx": "react-jsx", + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "plugins": [ + { + "name": "@effect/language-service", + "diagnosticSeverity": { + "floatingEffect": "error", + "anyUnknownInErrorContext": "error", + "missingEffectContext": "error", + "missingEffectError": "error", + "unsafeEffectTypeAssertion": "error" + }, + "ignoreEffectWarningsInTscExitCode": false, + "ignoreEffectErrorsInTscExitCode": false + } + ] + }, + "include": [ + "src", + "pages", + "tests", + "*.ts", + "*.mts" + ] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b634b1d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import { voidPlugin } from "void"; +import { voidReact } from "@void/react/plugin"; + +export default defineConfig({ + plugins: [ + voidPlugin(), + voidReact({ react: { compiler: true } }), + ], +}); diff --git a/void.config.ts b/void.config.ts new file mode 100644 index 0000000..4278f2a --- /dev/null +++ b/void.config.ts @@ -0,0 +1,8 @@ +// Generated by void init. +import { defineConfig } from 'void/config'; + +export default defineConfig({ + "worker": { + "compatibility_date": "2026-02-24" + } +}); From edf4d8733f73b2255de6d2e1894dab92a7fa1003 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 22 Oct 2026 09:26:40 +0530 Subject: [PATCH 02/22] fix: close browser and domain fence gaps --- .gitignore | 2 ++ .oxlintrc.json | 5 ++--- README.md | 2 +- scripts/oxlint-boundaries.mjs | 2 +- src/features/practice/browser/practice.tsx | 4 ++-- tests/fence.test.mjs | 11 +++++++++++ tsconfig.json | 4 +++- 7 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 73a5f36..edb7932 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ dist playwright-report/ test-results/ .fence-fixtures-*/ + +.impeccable/questions/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 529e3ba..d030d0e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -15,15 +15,14 @@ "strokes-boundaries/no-platform-in-domain": "error", "typescript/no-explicit-any": "error", "typescript/no-non-null-assertion": "error", - "typescript/consistent-type-imports": "error", - "jsx-a11y/prefer-tag-over-role": "off" + "typescript/consistent-type-imports": "error" }, "overrides": [ { "files": [ "src/**/domain/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}", - ".fence-fixtures/domain/*.ts" + ".fence-fixtures-*/domain/*.ts" ], "rules": { "no-restricted-globals": [ diff --git a/README.md b/README.md index b8313c7..f45354b 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,6 @@ The generated `dev`, `build`, `preview`, and `postinstall` commands are retained `prepare` patches the pinned TypeScript 7.0.2 compiler using compatible `@effect/tsgo` 0.56.0 and installs Lefthook. Every commit runs check and test; CI installs from the frozen lockfile, installs Chromium, then runs check, test, and build. Do not bypass hooks. The fence regression test temporarily introduces invalid types, a floating Effect, and forbidden imports, requires each to fail, and removes the probes afterward. -Oxlint's `jsx-a11y/prefer-tag-over-role` is disabled because it incorrectly demands that the accessible SVG writing pad become an HTML image; the pad needs SVG geometry and an explicit image role. Other accessibility correctness rules remain enabled. The initial shell is deliberately unstyled and provides no tracing or grading yet. +Oxlint's `jsx-a11y/prefer-tag-over-role` has one line-specific exception because it incorrectly demands that the accessible SVG writing pad become an HTML image; the pad needs SVG geometry and an explicit image role. Other accessibility correctness rules remain enabled. The initial shell is deliberately unstyled and provides no tracing or grading yet. A browser smoke check at phone and desktop viewport sizes proves that the local shell loads and the pad stays square. This is emulation, not physical-device validation. No deployment or real-phone verification has been performed. diff --git a/scripts/oxlint-boundaries.mjs b/scripts/oxlint-boundaries.mjs index 8c8bb92..d342e46 100644 --- a/scripts/oxlint-boundaries.mjs +++ b/scripts/oxlint-boundaries.mjs @@ -24,7 +24,7 @@ export default { const forbidden = domain ? provider || target.includes("/browser/") || target.includes("/pages/") || platform || (!name.startsWith(".") && name !== "effect" && !name.startsWith("effect/")) : (browser || page) - ? provider || name.startsWith("node:") || /^void\/(?:env|db|auth|sandbox)(?:\/|$)/.test(name) + ? provider || name === "void" || name.startsWith("node:") || /^void\/(?:env|db|auth|sandbox)(?:\/|$)/.test(name) : (filename.includes("/routes/") || filename.includes("/providers/")) && target.includes("/browser/"); if (forbidden) context.report({ node, messageId: "forbidden" }); } diff --git a/src/features/practice/browser/practice.tsx b/src/features/practice/browser/practice.tsx index adfc3c5..9a07071 100644 --- a/src/features/practice/browser/practice.tsx +++ b/src/features/practice/browser/practice.tsx @@ -3,8 +3,8 @@ export function Practice() {

Strokes

Practice Hindi handwriting with your finger, pen, or mouse.

- Date: Thu, 22 Oct 2026 09:28:12 +0530 Subject: [PATCH 03/22] docs: record approved Strokes product context --- PRODUCT.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 PRODUCT.md diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..e75c83c --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,46 @@ +# Product + + + +## Platform + +web + +## Users + +A Hindi learner practicing isolated letters in a mobile browser, using a finger or stylus. Mouse input supports the same exercise on desktop. The approved specification also identifies the learner as a TypeScript developer who wants an understandable starting point. Age and whether an adult guides practice remain undecided. + +## Product Purpose + +Strokes makes one Hindi letter available for tracing, revealing, retrying, and eventually checking one directed stroke at a time. Success means reliable local interaction and honest feedback against original, reviewed lesson content. + +## Operating Context + +The writing surface must work on a phone, allow page scrolling outside the pad, recover from interrupted gestures, and preserve alignment across display sizes. Actual iPhone Safari and Android Chrome checks are required separately from browser emulation. + +## Capabilities and Constraints + +- Void and React own the app; deterministic geometry stays independent. Effect owns parsing boundaries, external work, and typed failures when needed. +- The checked-in lesson owns the teaching sequence. React owns the temporary attempt. There are no accounts, uploads, durable progress, analytics, or database. +- One original letter comes first. The specific letter and reviewed sequence are open; any development fixture must carry an explicit unreviewed label. +- Clear and Retry reset the exercise. Reveal shows ordered reference strokes without accepting input or completing practice. +- Stroke direction, position, and scale matter. Tolerances remain provisional until original finger and stylus recordings support calibration. + +## Brand Commitments + +The learner-facing product name is Strokes. TimTim names the repository. No visual identity is established yet. + +## Evidence on Hand + +The owner approved the product and test seams in SPEC.md and GitHub issue #1 on 2026-10-22. The Skritter research informs a next-stroke approach but provides no reusable implementation or lesson data. Reviewed Hindi content, calibrated matcher fixtures, and physical-device evidence are not yet available. + +## Product Principles + +- Keep practice local and understandable. +- Make the expected action and current step clear. +- Separate learner retries from application failures. +- Claim completion only after the required evidence exists. + +## Accessibility & Inclusion + +Use labeled semantic controls, visible keyboard focus, readable feedback beyond color, and adjacent pad instructions. Handwriting itself requires a pointing device in this version. A pronunciation label is text; audio is outside scope. From 8b253c5b18c858ac373c16e13c80526273cf5813 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 22 Oct 2026 09:30:09 +0530 Subject: [PATCH 04/22] fix: reject server packages from browser modules --- scripts/oxlint-boundaries.mjs | 9 ++++++++- tests/fence.test.mjs | 23 ++++++++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/scripts/oxlint-boundaries.mjs b/scripts/oxlint-boundaries.mjs index d342e46..4690436 100644 --- a/scripts/oxlint-boundaries.mjs +++ b/scripts/oxlint-boundaries.mjs @@ -1,5 +1,12 @@ +import { isBuiltin } from "node:module"; import { dirname, resolve } from "node:path"; +const browserVoidImports = new Set([ + "void/client", "void/client/react", "void/client/vue", "void/client/svelte", "void/client/solid", + "void/sse/client", "void/live/client", "void/pages-client", "void/pages-prefetch", + "@void/react", "@void/react/pages-client", +]); + export default { meta: { name: "strokes-boundaries" }, rules: { @@ -24,7 +31,7 @@ export default { const forbidden = domain ? provider || target.includes("/browser/") || target.includes("/pages/") || platform || (!name.startsWith(".") && name !== "effect" && !name.startsWith("effect/")) : (browser || page) - ? provider || name === "void" || name.startsWith("node:") || /^void\/(?:env|db|auth|sandbox)(?:\/|$)/.test(name) + ? provider || isBuiltin(name) || name.startsWith("node:") || ((name === "void" || name.startsWith("void/") || name.startsWith("@void/")) && !browserVoidImports.has(name)) : (filename.includes("/routes/") || filename.includes("/providers/")) && target.includes("/browser/"); if (forbidden) context.report({ node, messageId: "forbidden" }); } diff --git a/tests/fence.test.mjs b/tests/fence.test.mjs index 3e72c18..b80009e 100644 --- a/tests/fence.test.mjs +++ b/tests/fence.test.mjs @@ -39,11 +39,24 @@ test("the fence rejects invalid types, floating Effects and cross-boundary impor assert.notEqual(result.status, 0, "domain browser globals must fail"); assert.match(result.stdout + result.stderr, /no-restricted-globals/); } - const browserProbe = join(directory, "browser", "probe.ts"); - writeFileSync(browserProbe, 'import { defineHandler } from "void"; export const handler = defineHandler;'); - result = spawnSync("./node_modules/.bin/oxlint", ["--deny-warnings", browserProbe], { encoding: "utf8" }); - assert.notEqual(result.status, 0, "browser imports of the server entry must fail"); - assert.match(result.stdout + result.stderr, /no-platform-in-domain/); + mkdirSync(join(directory, "pages")); + for (const folder of ["browser", "pages"]) { + const browserProbe = join(directory, folder, "probe.ts"); + for (const name of ["void", "fs", "fs/promises", "path", "child_process", "node:fs", "void/handler", "void/kv", "void/storage", "@void/react/pages-server", "@void/react/plugin"]) { + writeFileSync(browserProbe, `import * as dependency from "${name}"; export const value = dependency;`); + result = spawnSync("./node_modules/.bin/oxlint", ["--deny-warnings", browserProbe], { encoding: "utf8" }); + assert.notEqual(result.status, 0, `${folder} server import ${name} must fail`); + assert.match(result.stdout + result.stderr, /no-platform-in-domain/); + } + for (const name of ["react", "@void/react", "@void/react/pages-client", "void/client", "void/client/react", "void/sse/client", "void/live/client", "void/pages-client", "void/pages-prefetch"]) { + writeFileSync(browserProbe, `import * as dependency from "${name}"; export const value = dependency;`); + result = spawnSync("./node_modules/.bin/oxlint", ["--deny-warnings", browserProbe], { encoding: "utf8" }); + assert.equal(result.status, 0, `${folder} browser import ${name} must pass: ${result.stdout}${result.stderr}`); + } + } + writeFileSync(probe, 'import { Effect } from "effect"; export const value = Effect.succeed(1);'); + result = lint(); + assert.equal(result.status, 0, result.stdout + result.stderr); writeFileSync(probe, "export const points: number = 3;\n"); execFileSync("./node_modules/.bin/tsc", ["--noEmit", "--project", join(directory, "tsconfig.json")]); result = lint(); From 3b23af2b969d8755ee5b98f1736bb320e0c4d13f Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 22 Oct 2026 09:48:57 +0530 Subject: [PATCH 05/22] docs: choose the Strokes drawing-frame design --- DESIGN.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..db92145 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,69 @@ +--- +name: Strokes +description: A registered drawing frame for Hindi handwriting practice +colors: + primary: "#2348d1" + primary-deep: "#17349e" + ground: "#edf1f6" + surface: "#ffffff" + ink: "#15223b" + muted: "#526079" + rule: "#c9d3e3" + guide: "#c6d2f6" +typography: + body: + fontFamily: "system-ui, sans-serif" + fontSize: "1rem" + lineHeight: 1.5 + title: + fontFamily: "system-ui, sans-serif" + fontSize: "2rem" + fontWeight: 650 + lineHeight: 1.15 +rounded: + control: "8px" +spacing: + small: "8px" + group: "16px" + section: "32px" +--- + +# Design System: Strokes + +## Overview + +**Creative North Star: "Printmaker's proof"** + +A cool white working field, cobalt ink, registration corners, and explicit directed-stroke proofs. The user delegated the visual choice on 2026-10-22. The reference metaphor explains aligned drawing and repeated attempts; it introduces no printing terminology into learner instructions. + +## Colors + +White owns the writing surface; a cool neutral ground separates the workspace from its surroundings. Cobalt marks active ink and actions. Dark ink and muted blue-gray support readable instructions. Faint blue guides are reference geometry, never status text. + +## Typography + +Use system sans for controls and instructions, with readable fixed rem sizes. The lesson glyph and reference paths carry the visual identity. Use the platform Devanagari fallback for the glyph label; the actual teaching paths are authored SVG geometry, never a font outline. + +## Layout + +The square writing surface leads. A compact lesson header precedes it; instructions and labeled controls remain adjacent. On wide screens, an instruction rail sits beside the larger drawing frame. On phones, the rail moves below the pad and controls stay reachable. Avoid a dashboard, lesson catalog, or decorative card grid. + +## Elevation & Depth + +Flat working surfaces with registration marks and subtle border contrast. No decorative glows, glass, or simulated paper texture. Depth must not confuse the writing boundary. + +## Shapes + +Keep the pad geometrically square with modest corner treatment. Controls have consistent gently rounded corners and generous touch areas. Registration marks remain outside the input bounds. + +## Components + +The pad is a real SVG with a shared lesson coordinate system. Reference geometry is faint; learner ink is cobalt. Reveal exposes numbered directed reference strokes without accepting input. Buttons use text labels, visible focus, hover and pressed states. Feedback uses readable text with a stable position to avoid layout movement during drawing. + +## Do's and Don'ts + +- Do prioritize the pad under ordinary indoor and daylight phone use. +- Do keep human review and development-fixture status honest and readable. +- Do use semantic HTML and SVG for all instructions, controls, and drawing. +- Don't add streaks, scores, mastery claims, a lesson catalog, or persistent history. +- Don't use imagery or animation where it would compete with the drawing task. From ceb495728d00ef87f76e2e7d6b8ac892b3e964af Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 22 Oct 2026 09:50:39 +0530 Subject: [PATCH 06/22] docs: record delegated practice composition --- ...-features-practice-browser-practice-tsx.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .impeccable/surfaces/src-features-practice-browser-practice-tsx.md diff --git a/.impeccable/surfaces/src-features-practice-browser-practice-tsx.md b/.impeccable/surfaces/src-features-practice-browser-practice-tsx.md new file mode 100644 index 0000000..68883f3 --- /dev/null +++ b/.impeccable/surfaces/src-features-practice-browser-practice-tsx.md @@ -0,0 +1,26 @@ +--- +version: 1 +slug: "src-features-practice-browser-practice-tsx" +primary_target: "src/features/practice/browser/practice.tsx" +related_targets: [] +--- + + + +# Practice surface + +Mode: Operate. Scope: the single-letter practice page and its responsive controls. + +The user delegated direction and composition on 2026-10-22. Keep the square pad large enough for finger drawing and the control row adjacent. Numbered reference hints appear on Reveal; use the same checked-in directed paths as the faint guide. Avoid duplicated hint panels. + +Composition reference: /Users/jxsh/.codex/generated_images/01a161ab-fc56-7072-819f-9052df880277/exec-23f5a8b9-c24e-4c90-92c7-ab78138bc58f.png. The other two generated compositions are alternatives only. These are preview-only mockups created with the built-in image tool; no raster asset belongs in the app. + +Implementation inventory: semantic HTML for labels, instructions, feedback and buttons; SVG for authored lesson geometry, guide, directed hints and learner ink; CSS for registration corners, responsive layout and states. No photos, textures, raster text, icon library, or external fonts needed. Ignore invented completion/audio affordances in the generated comps. Do not copy their proposed teaching order as reviewed evidence. + +Open gates: original lesson review, calibrated matching, and actual iPhone/Android/stylus checks. A tracing-only development lesson must say review pending and must not imply automatic grading or checked completion. From 6ed8e98bb02ee79317f908ddd987cdabd12fcec4 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 22 Oct 2026 10:50:48 +0530 Subject: [PATCH 07/22] feat: add local Hindi tracing preview --- README.md | 8 +- package.json | 3 +- src/features/practice/SPEC.md | 29 ++++ src/features/practice/browser/practice.css | 65 ++++++++ src/features/practice/browser/practice.tsx | 105 +++++++++++-- src/features/practice/browser/use-drawing.ts | 127 +++++++++++++++ .../practice/domain/development-lesson.ts | 26 ++++ src/features/practice/domain/lesson.ts | 44 ++++++ tests/browser/tracing.spec.ts | 145 ++++++++++++++++++ tests/content/lesson.test.ts | 41 +++++ tsconfig.json | 3 +- vitest.config.ts | 3 + 12 files changed, 583 insertions(+), 16 deletions(-) create mode 100644 src/features/practice/SPEC.md create mode 100644 src/features/practice/browser/practice.css create mode 100644 src/features/practice/browser/use-drawing.ts create mode 100644 src/features/practice/domain/development-lesson.ts create mode 100644 src/features/practice/domain/lesson.ts create mode 100644 tests/browser/tracing.spec.ts create mode 100644 tests/content/lesson.test.ts create mode 100644 vitest.config.ts diff --git a/README.md b/README.md index f45354b..0ffe823 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A small Hindi handwriting practice app for mobile browsers. -Status: local Void/React practice shell and automated fence implemented. Tracing, stroke checking, calibration, content review, and device verification are still pending; no deployment exists. +Status: local Void/React tracing preview and automated fence implemented. The original development letter is explicitly unreviewed. Stroke checking, calibration, content review, and real-device verification remain pending; no deployment exists. Use React and Void. Use rat-stack as the engineering reference: Effect for external work and typed failures, and compiler, lint, tests, and hooks for enforcement. The owner's explicit choices replace Foldkit with React and Alchemy with Void deployment. @@ -119,11 +119,11 @@ The app is the actual Void 1.2.0 React Static Pages starter, without database or The generated `dev`, `build`, `preview`, and `postinstall` commands are retained. The enforcement scripts are: - `pnpm check`: Void type generation, strict TypeScript with patched Effect diagnostics, and Oxlint including import boundaries. -- `pnpm test`: intentional compiler/import failure probes followed by the complete-page Playwright checks. +- `pnpm test`: intentional compiler/import failure probes, lesson-content checks, and complete-page Playwright checks. - `pnpm build`: Void's client and Worker build, without publishing. `prepare` patches the pinned TypeScript 7.0.2 compiler using compatible `@effect/tsgo` 0.56.0 and installs Lefthook. Every commit runs check and test; CI installs from the frozen lockfile, installs Chromium, then runs check, test, and build. Do not bypass hooks. The fence regression test temporarily introduces invalid types, a floating Effect, and forbidden imports, requires each to fail, and removes the probes afterward. -Oxlint's `jsx-a11y/prefer-tag-over-role` has one line-specific exception because it incorrectly demands that the accessible SVG writing pad become an HTML image; the pad needs SVG geometry and an explicit image role. Other accessibility correctness rules remain enabled. The initial shell is deliberately unstyled and provides no tracing or grading yet. +Oxlint's `jsx-a11y/prefer-tag-over-role` has one line-specific exception because it incorrectly demands that the accessible SVG writing pad become an HTML image; the pad needs SVG geometry and an explicit image role. Other accessibility correctness rules remain enabled. The tracing preview supports local ink, ordered Reveal hints, Clear, and Retry. It provides no automatic grading or completion claims. -A browser smoke check at phone and desktop viewport sizes proves that the local shell loads and the pad stays square. This is emulation, not physical-device validation. No deployment or real-phone verification has been performed. +Browser checks cover live ink, separate gestures, cancellation, capture loss, blur, resize, layout changes, secondary pointers, release outside, and repeated controls. Phone and desktop viewport checks prove that the pad stays square. This is emulation, not physical-device validation. No deployment or real-phone verification has been performed. diff --git a/package.json b/package.json index ba95b00..8ea0a1d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "check": "pnpm typecheck && pnpm lint", "typecheck": "void prepare && tsc --noEmit", "lint": "oxlint --deny-warnings", - "test": "pnpm test:fence && pnpm test:browser", + "test": "pnpm test:fence && pnpm test:content && pnpm test:browser", + "test:content": "vitest run", "test:browser": "playwright test", "prepare": "effect-tsgo patch && lefthook install", "test:fence": "node --test tests/fence.test.mjs" diff --git a/src/features/practice/SPEC.md b/src/features/practice/SPEC.md new file mode 100644 index 0000000..eb79e5a --- /dev/null +++ b/src/features/practice/SPEC.md @@ -0,0 +1,29 @@ +# Practice + +Provide one local Hindi tracing exercise before automatic checking. The original +development letter has not received human content review and must say so visibly. +No completion, scoring, persistence, or pedagogical-validity claim belongs here yet. + +## Ownership + +- `domain/development-lesson.ts` owns original directed reference geometry and hints. +- `domain/lesson.ts` parses unknown content into a usable, branded lesson or a + distinct content failure. Points must be finite, in bounds, and nondegenerate. +- `browser/practice.tsx` renders the letter, shared SVG coordinate frame, live ink, + honest status, ordered Reveal hints, and labeled Clear/Retry controls. +- `browser/use-drawing.ts` owns one captured pointer and temporary React state. + Interrupted input is discarded; finished gestures survive layout changes. +- `browser/practice.css` owns the responsive white/cobalt drawing-frame system. + +## Behavior and checks + +Drawing maps browser coordinates through the SVG transform. A second pointer is +ignored. Release ends one gesture, including outside the pad. Cancellation, lost +capture, blur, hidden document, and layout changes stop unfinished input safely. +Clear and Retry reset ink and revealed hints. Reveal never accepts a stroke. +Attempts remain in memory and disappear when the page is left or refreshed. + +`tests/content/lesson.test.ts` checks the content boundary and authored ordering. +`tests/browser/tracing.spec.ts` checks public ink/status/control behavior and +interruption recovery. Actual iPhone/Android and finger/stylus checks remain open. +Stroke matching and calibration follow only after the lesson-review dependency. diff --git a/src/features/practice/browser/practice.css b/src/features/practice/browser/practice.css new file mode 100644 index 0000000..b0bd338 --- /dev/null +++ b/src/features/practice/browser/practice.css @@ -0,0 +1,65 @@ +:root { + font-family: system-ui, sans-serif; + color: #15223b; + background: #edf1f6; + font-synthesis: none; + line-height: 1.5; + font-size: 16px; +} +* { box-sizing: border-box; } +body { margin: 0; } +button { font: inherit; } +.practice-app { max-width: 1120px; margin: 0 auto; padding: 0 32px 48px; } +.app-header { min-height: 88px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #c9d3e3; } +h1 { margin: 0; font-size: 1.75rem; line-height: 1.15; font-weight: 700; letter-spacing: -.025em; } +.review-notice { margin: 0; padding: 8px 12px; background: #e3e9ff; color: #17349e; font-size: .875rem; border-radius: 6px; } +.practice-layout { display: grid; grid-template-columns: minmax(0, 500px) minmax(240px, 1fr); gap: 48px; padding-top: 16px; align-items: start; } +.lesson-identity { display: flex; align-items: center; gap: 24px; min-height: 72px; margin-bottom: 16px; } +.lesson-glyph { font-size: 4rem; line-height: 1; padding-right: 24px; border-right: 1px solid #c9d3e3; } +.pronunciation { font-size: 2rem; line-height: 1.15; } +.lesson-identity p { margin: 4px 0 0; font-size: .875rem; color: #526079; } +.pad-frame { position: relative; background: white; } +.pad-frame::before, .pad-frame::after { content: ""; position: absolute; z-index: 1; pointer-events: none; inset: -5px; background: linear-gradient(#2348d1,#2348d1) left top / 16px 3px no-repeat, linear-gradient(#2348d1,#2348d1) left top / 3px 16px no-repeat, linear-gradient(#2348d1,#2348d1) right bottom / 16px 3px no-repeat, linear-gradient(#2348d1,#2348d1) right bottom / 3px 16px no-repeat; } +.pad-frame::after { transform: scaleX(-1); } +.writing-pad { display: block; width: 100%; aspect-ratio: 1; border: 1px solid #c9d3e3; touch-action: none; user-select: none; color: #2348d1; overflow: hidden; } +.registration-grid { fill: none; stroke: #dce3ee; stroke-width: .2; stroke-dasharray: 1.2 1.2; } +.reference { fill: none; stroke: #c6d2f6; stroke-width: 2.8; stroke-linecap: round; stroke-linejoin: round; } +.reference.revealed { stroke: #2348d1; stroke-width: 1.6; } +.learner-ink, .preview-ink { fill: none; stroke: #2348d1; stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; } +.stroke-number circle { fill: #2348d1; } +.stroke-number text { font-family: system-ui, sans-serif; font-size: 4px; fill: white; } +.pad-instructions { margin: 20px 0 4px; text-align: center; font-size: .9375rem; } +.practice-status { display: block; min-height: 3em; margin: 0 0 12px; text-align: center; color: #526079; font-size: .9375rem; } +.practice-controls { display: grid; grid-template-columns: 1fr 1.3fr 1fr; gap: 12px; } +.practice-controls button { min-height: 48px; padding: 10px 16px; border: 1px solid #2348d1; border-radius: 8px; background: white; color: #2348d1; font-weight: 600; cursor: pointer; } +.practice-controls button:hover { background: #e3e9ff; } +.practice-controls button:active { background: #d3ddff; } +.practice-controls button.primary { background: #2348d1; color: white; } +.practice-controls button.primary:hover { background: #17349e; } +.practice-controls button:focus-visible { outline: 3px solid #15223b; outline-offset: 4px; } +.practice-controls button:disabled { opacity: .5; cursor: wait; } +.reference-rail { padding-top: 88px; max-width: 340px; } +h2 { font-size: 1.125rem; line-height: 1.35; margin: 0 0 8px; font-weight: 650; } +.rail-intro p { margin: 0; color: #526079; } +.reveal-instruction { margin: 24px 0; } +.stroke-list { list-style: none; padding: 0; margin: 24px 0; } +.stroke-list li { display: grid; grid-template-columns: 28px 1fr; gap: 12px; padding: 16px 0; border-top: 1px solid #c9d3e3; } +.sequence-number { display: grid; place-items: center; height: 28px; border-radius: 50%; color: white; background: #2348d1; font-weight: 650; } +.stroke-preview { width: 80px; height: 80px; display: block; } +.stroke-list p { margin: 8px 0 0; font-size: .9375rem; } +.lesson-review { margin: 24px 0 12px; color: #526079; font-size: .875rem; } +.privacy-note { border-top: 1px solid #c9d3e3; padding-top: 16px; color: #526079; font-size: .875rem; } +@media (max-width: 760px) { + .practice-app { padding: 0 20px 32px; } + .app-header { min-height: 80px; gap: 12px; } + h1 { font-size: 1.5rem; } + .review-notice { max-width: 190px; font-size: .75rem; padding: 6px 8px; } + .practice-layout { grid-template-columns: minmax(0, 1fr); gap: 32px; padding-top: 16px; } + .lesson-identity { min-height: 72px; gap: 20px; margin-bottom: 16px; } + .lesson-glyph { font-size: 3.5rem; padding-right: 20px; } + .pronunciation { font-size: 1.75rem; } + .reference-rail { padding-top: 0; } + .practice-controls { gap: 8px; } + .practice-controls button { padding: 10px 8px; } + .pad-instructions { margin-top: 16px; } +} diff --git a/src/features/practice/browser/practice.tsx b/src/features/practice/browser/practice.tsx index 9a07071..2703ea0 100644 --- a/src/features/practice/browser/practice.tsx +++ b/src/features/practice/browser/practice.tsx @@ -1,15 +1,100 @@ +import { useId, useState } from "react"; +import { DEVELOPMENT_LESSON } from "../domain/development-lesson"; +import { parseLesson } from "../domain/lesson"; +import type { Lesson, Point } from "../domain/lesson"; +import { useDrawing } from "./use-drawing"; +import "./practice.css"; + +const CONTENT = parseLesson(DEVELOPMENT_LESSON); + +function pointsAttribute(points: readonly Point[]): string { + return points.map((point) => `${point.x},${point.y}`).join(" "); +} + export function Practice() { + if (CONTENT.kind === "unusable") { + return

Strokes

Lesson unavailable

{CONTENT.message}

Your writing has not been checked. Reload the page to try again.

; + } + return ; +} + +function TracingPractice({ lesson }: { readonly lesson: Lesson }) { + const { padRef, ready, attempt, reset: resetDrawing, abort: abortDrawing, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture } = useDrawing(); + const [revealed, setRevealed] = useState(false); + const arrowId = useId(); + const { x, y, width, height } = lesson.bounds; + const viewBox = `${x} ${y} ${width} ${height}`; + const count = attempt.strokes.length; + let feedback = "Ready to trace. Follow the faint guide."; + if (count > 0) feedback = `${count} ${count === 1 ? "stroke" : "strokes"} drawn. Keep tracing or start again.`; + if (attempt.feedback === "drawing") feedback = "Drawing… Lift your finger or pen to finish this stroke."; + if (attempt.feedback === "interrupted") feedback = "Stroke interrupted. Your finished strokes are safe. Try again."; + + if (!ready) feedback = "Preparing the writing area…"; + + function reset(): void { + resetDrawing(); + setRevealed(false); + } + + function reveal(): void { + abortDrawing(); + setRevealed(true); + } + return ( -
-

Strokes

-

Practice Hindi handwriting with your finger, pen, or mouse.

- {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- SVG needs an image role while retaining drawing geometry. */} - +
+
+

Strokes

+

Development lesson — review pending

+
+
+
+
{lesson.glyph}
{lesson.pronunciation}

Hindi letter practice

+
+ {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- SVG needs an image role while retaining drawing geometry. */} + + + + + {lesson.strokes.map((stroke, index) => )} + + + {attempt.strokes.map((stroke, index) => )} + {attempt.active && } + + {revealed && lesson.strokes.map((stroke, index) => {index + 1})} + +
+

Practice Hindi handwriting with your finger, pen, or mouse.

+ {feedback} +
+ + + +
+
+ +
); } diff --git a/src/features/practice/browser/use-drawing.ts b/src/features/practice/browser/use-drawing.ts new file mode 100644 index 0000000..25dc2d5 --- /dev/null +++ b/src/features/practice/browser/use-drawing.ts @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PointerEvent, RefObject } from "react"; +import type { Point } from "../domain/lesson"; + +interface ActiveStroke { + readonly pointerId: number; + readonly points: readonly Point[]; +} +interface Attempt { + readonly strokes: readonly (readonly Point[])[]; + readonly active: ActiveStroke | null; + readonly feedback: "ready" | "drawing" | "interrupted"; +} +interface Capture { + readonly pointerId: number; + readonly matrix: DOMMatrix; +} +interface Drawing { + readonly padRef: RefObject; + readonly ready: boolean; + readonly attempt: Attempt; + readonly reset: () => void; + readonly abort: () => void; + readonly onPointerDown: (event: PointerEvent) => void; + readonly onPointerMove: (event: PointerEvent) => void; + readonly onPointerUp: (event: PointerEvent) => void; + readonly onPointerCancel: (event: PointerEvent) => void; + readonly onLostPointerCapture: (event: PointerEvent) => void; +} +const EMPTY: Attempt = { strokes: [], active: null, feedback: "ready" }; + +function sameMatrix(left: DOMMatrix, right: DOMMatrix): boolean { + return left.a === right.a && left.b === right.b && left.c === right.c && left.d === right.d && left.e === right.e && left.f === right.f; +} +function toPoint(svg: SVGSVGElement, x: number, y: number): { readonly point: Point; readonly matrix: DOMMatrix } | null { + const matrix = svg.getScreenCTM(); + if (!matrix || matrix.a * matrix.d - matrix.b * matrix.c === 0) return null; + const point = new DOMPoint(x, y).matrixTransform(matrix.inverse()); + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null; + return { point: { x: point.x, y: point.y }, matrix }; +} + +export function useDrawing(): Drawing { + const padRef = useRef(null); + const captureRef = useRef(null); + const [ready, setReady] = useState(false); + const [attempt, setAttempt] = useState(EMPTY); + + const release = useCallback((): void => { + const capture = captureRef.current; + captureRef.current = null; + const pad = padRef.current; + if (capture && pad?.hasPointerCapture(capture.pointerId)) pad.releasePointerCapture(capture.pointerId); + }, []); + + const abort = useCallback((): void => { + if (!captureRef.current) return; + release(); + setAttempt((previous) => ({ ...previous, active: null, feedback: "interrupted" })); + }, [release]); + + useEffect(() => { + const pad = padRef.current; + if (!pad) return; + const observer = new ResizeObserver(() => { + const capture = captureRef.current; + const matrix = pad.getScreenCTM(); + if (capture && (!matrix || !sameMatrix(capture.matrix, matrix))) abort(); + }); + observer.observe(pad); + setReady(true); + const onVisibilityChange = (): void => { if (document.hidden) abort(); }; + window.addEventListener("blur", abort); + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("resize", abort); + window.addEventListener("scroll", abort, true); + window.visualViewport?.addEventListener("resize", abort); + window.visualViewport?.addEventListener("scroll", abort); + return () => { + observer.disconnect(); + window.removeEventListener("blur", abort); + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("resize", abort); + window.removeEventListener("scroll", abort, true); + window.visualViewport?.removeEventListener("resize", abort); + window.visualViewport?.removeEventListener("scroll", abort); + release(); + }; + }, [abort, release]); + + function onPointerDown(event: PointerEvent): void { + if (!ready || captureRef.current || event.button !== 0) return; + const mapped = toPoint(event.currentTarget, event.clientX, event.clientY); + if (!mapped) return; + event.currentTarget.setPointerCapture(event.pointerId); + captureRef.current = { pointerId: event.pointerId, matrix: mapped.matrix }; + setAttempt((previous) => ({ ...previous, active: { pointerId: event.pointerId, points: [mapped.point] }, feedback: "drawing" })); + } + + function onPointerMove(event: PointerEvent): void { + const capture = captureRef.current; + if (!capture || event.pointerId !== capture.pointerId) return; + const mapped = toPoint(event.currentTarget, event.clientX, event.clientY); + if (!mapped || !sameMatrix(capture.matrix, mapped.matrix)) return abort(); + setAttempt((previous) => previous.active ? { ...previous, active: { ...previous.active, points: [...previous.active.points, mapped.point] } } : previous); + } + + function onPointerUp(event: PointerEvent): void { + const capture = captureRef.current; + if (!capture || event.pointerId !== capture.pointerId) return; + const mapped = toPoint(event.currentTarget, event.clientX, event.clientY); + if (!mapped || !sameMatrix(capture.matrix, mapped.matrix)) return abort(); + release(); + setAttempt((previous) => previous.active ? { strokes: [...previous.strokes, [...previous.active.points, mapped.point]], active: null, feedback: "ready" } : previous); + } + + function onInterrupted(event: PointerEvent): void { + if (event.pointerId === captureRef.current?.pointerId) abort(); + } + + function reset(): void { + release(); + setAttempt(EMPTY); + } + + return { padRef, ready, attempt, reset, abort, onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onInterrupted, onLostPointerCapture: onInterrupted }; +} diff --git a/src/features/practice/domain/development-lesson.ts b/src/features/practice/domain/development-lesson.ts new file mode 100644 index 0000000..93f3fb0 --- /dev/null +++ b/src/features/practice/domain/development-lesson.ts @@ -0,0 +1,26 @@ +export const DEVELOPMENT_LESSON = { + id: "la-development-1", + glyph: "ल", + pronunciation: "la", + review: "pending", + bounds: { x: 0, y: 0, width: 100, height: 100 }, + strokes: [ + { + hint: "Start low on the left. Curve up and around to meet the right stem.", + points: [ + { x: 38, y: 73 }, { x: 30, y: 66 }, { x: 25, y: 57 }, + { x: 25, y: 48 }, { x: 29, y: 41 }, { x: 35, y: 38 }, + { x: 41, y: 41 }, { x: 45, y: 48 }, { x: 47, y: 57 }, + { x: 51, y: 49 }, { x: 58, y: 44 }, { x: 67, y: 44 }, + ], + }, + { + hint: "Draw the right stem from top to bottom.", + points: [{ x: 67, y: 25 }, { x: 67, y: 76 }], + }, + { + hint: "Draw the headline from left to right.", + points: [{ x: 22, y: 25 }, { x: 77, y: 25 }], + }, + ], +}; diff --git a/src/features/practice/domain/lesson.ts b/src/features/practice/domain/lesson.ts new file mode 100644 index 0000000..add793e --- /dev/null +++ b/src/features/practice/domain/lesson.ts @@ -0,0 +1,44 @@ +import { Result, Schema } from "effect"; + +const FINITE = Schema.Number.check(Schema.isFinite()); +const LABEL = Schema.String.check(Schema.makeFilter((value) => value.trim().length > 0)); +export const Point = Schema.Struct({ x: FINITE, y: FINITE }); +export interface Point extends Schema.Schema.Type {} + +const REFERENCE_STROKE = Schema.Struct({ + hint: LABEL, + points: Schema.NonEmptyArray(Point).check(Schema.makeFilter((points) => { + const length = points.reduce((total, point, index) => { + const previous = points[index - 1]; + return previous ? total + Math.hypot(point.x - previous.x, point.y - previous.y) : total; + }, 0); + return length > 0 && Number.isFinite(length); + })), +}); +const LESSON = Schema.Struct({ + id: LABEL, + glyph: LABEL, + pronunciation: LABEL, + review: Schema.Literal("pending"), + bounds: Schema.Struct({ x: FINITE, y: FINITE, width: FINITE, height: FINITE }), + strokes: Schema.NonEmptyArray(REFERENCE_STROKE), +}).check(Schema.makeFilter((lesson) => { + const { x, y, width, height } = lesson.bounds; + if (width <= 0 || height <= 0 || !Number.isFinite(x + width) || !Number.isFinite(y + height)) return false; + return lesson.strokes.every((stroke) => stroke.points.every((point) => + point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height, + )); +})).pipe(Schema.brand("Lesson")); +export interface Lesson extends Schema.Schema.Type {} + +export type LessonContent = + | { readonly kind: "ready"; readonly lesson: Lesson } + | { readonly kind: "unusable"; readonly message: string }; + +export function parseLesson(input: unknown): LessonContent { + const result = Schema.decodeUnknownResult(LESSON)(input); + if (Result.isFailure(result)) { + return { kind: "unusable", message: "This lesson cannot be opened. Its content needs repair." }; + } + return { kind: "ready", lesson: result.success }; +} diff --git a/tests/browser/tracing.spec.ts b/tests/browser/tracing.spec.ts new file mode 100644 index 0000000..878b3f8 --- /dev/null +++ b/tests/browser/tracing.spec.ts @@ -0,0 +1,145 @@ +import { test, expect } from "./fixtures"; +import type { Locator } from "@playwright/test"; + +async function inkPixels(pad: Locator): Promise { + const png = (await pad.screenshot()).toString("base64"); + return pad.evaluate(async (_element, encoded) => { + const response = await fetch(`data:image/png;base64,${encoded}`); + const bitmap = await createImageBitmap(await response.blob()); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("No screenshot decoder"); + context.drawImage(bitmap, 0, 0); + bitmap.close(); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + for (let index = 0; index < pixels.length; index += 4) { + const red = pixels[index] ?? 255; + const green = pixels[index + 1] ?? 255; + const blue = pixels[index + 2] ?? 0; + if (blue > red * 1.5 && blue > green * 1.5) count++; + } + return count; + }, png); +} + +test.use({ viewport: { width: 1200, height: 1200 } }); + +test("shows live local ink, separate gestures, and a clear tracing-only reset", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Development lesson — review pending", { exact: true })).toBeVisible(); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + const pad = page.getByRole("img", { name: "Hindi writing practice area" }); + const bounds = await pad.boundingBox(); + if (!bounds) throw new Error("No writing pad"); + expect(await inkPixels(pad)).toBe(0); + await page.mouse.move(bounds.x + bounds.width * 0.25, bounds.y + bounds.height * 0.4); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width * 0.5, bounds.y + bounds.height * 0.65, { steps: 8 }); + await expect(page.getByRole("status")).toContainText("Drawing…"); + expect(await inkPixels(pad)).toBeGreaterThan(100); + await page.mouse.up(); + await expect(page.getByRole("status")).toContainText("1 stroke drawn"); + await page.mouse.move(bounds.x + bounds.width * 0.65, bounds.y + bounds.height * 0.3); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width * 0.65, bounds.y + bounds.height * 0.7, { steps: 6 }); + await page.mouse.up(); + await expect(page.getByRole("status")).toContainText("2 strokes drawn"); + await page.getByRole("button", { name: "Clear", exact: true }).click(); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + expect(await inkPixels(pad)).toBe(0); +}); + +test("Reveal exposes ordered direction hints without checking or completing, and repeated Retry resets it", async ({ page }) => { + await page.goto("/"); + const reveal = page.getByRole("button", { name: "Reveal", exact: true }); + await reveal.click(); + await expect(reveal).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("listitem")).toHaveCount(3); + await expect(page.getByText("Tracing only. Your writing is not checked or scored.", { exact: true })).toBeVisible(); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + for (let repeat = 0; repeat < 2; repeat++) { + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(reveal).toHaveAttribute("aria-pressed", "false"); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + await reveal.click(); + } +}); + +for (const interruption of ["cancel", "capture loss", "blur", "resize", "layout shift", "Clear", "Retry"] as const) { + test(`${interruption} discards unfinished ink and permits the next gesture`, async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + const pad = page.getByRole("img", { name: "Hindi writing practice area" }); + const bounds = await pad.boundingBox(); + if (!bounds) throw new Error("No pad"); + const start = { x: bounds.x + bounds.width * .2, y: bounds.y + bounds.height * .4 }; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 70, start.y + 50, { steps: 5 }); + await page.mouse.up(); + await expect(page.getByRole("status")).toContainText("1 stroke drawn"); + const finished = await inkPixels(pad); + expect(finished).toBeGreaterThan(100); + await pad.evaluate((element) => element.addEventListener("pointerdown", (event) => { + if (event instanceof PointerEvent) element.setAttribute("data-test-pointer", String(event.pointerId)); + }, { once: true })); + await page.mouse.move(start.x + 120, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 170, start.y + 60, { steps: 4 }); + await expect(page.getByRole("status")).toContainText("Drawing…"); + const id = Number(await pad.getAttribute("data-test-pointer")); + if (interruption === "cancel") await pad.dispatchEvent("pointercancel", { pointerId: id }); + if (interruption === "capture loss") { + await pad.evaluate((element, pointerId) => element.releasePointerCapture(pointerId), id); + await page.mouse.move(start.x + 180, start.y + 60); + } + if (interruption === "blur") await page.evaluate(() => window.dispatchEvent(new Event("blur"))); + if (interruption === "resize") await page.setViewportSize({ width: 1000, height: 1200 }); + if (interruption === "layout shift") { + await pad.evaluate((element) => { element.style.transform = "translateY(20px)"; }); + await page.mouse.move(start.x + 180, start.y + 60); + } + if (interruption === "Clear" || interruption === "Retry") { + await page.getByRole("button", { name: interruption, exact: true }).focus(); + await page.keyboard.press("Enter"); + } + await expect(page.getByRole("status")).toContainText(interruption === "Clear" || interruption === "Retry" ? "Ready to trace" : "Stroke interrupted"); + await page.mouse.up(); + if (interruption === "cancel" || interruption === "capture loss" || interruption === "blur") { + // Pixel coverage tolerates subpixel rasterization while detecting leftover unfinished ink. + expect(Math.abs(await inkPixels(pad) - finished)).toBeLessThan(finished * .05); + } + const nextBounds = await pad.boundingBox(); + if (!nextBounds) throw new Error("No resized pad"); + await page.mouse.move(nextBounds.x + 30, nextBounds.y + 30); + await page.mouse.down(); + await page.mouse.move(nextBounds.x + 80, nextBounds.y + 80, { steps: 4 }); + await page.mouse.up(); + await expect(page.getByRole("status")).toContainText(interruption === "Clear" || interruption === "Retry" ? "1 stroke drawn" : "2 strokes drawn"); + }); +} + +test("other pointers do not interrupt capture, and releasing outside does not leave ghost ink", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + const pad = page.getByRole("img", { name: "Hindi writing practice area" }); + const bounds = await pad.boundingBox(); + if (!bounds) throw new Error("No pad"); + await page.mouse.move(bounds.x + 80, bounds.y + 80); + await page.mouse.down(); + for (const type of ["pointerdown", "pointermove", "pointerup", "pointercancel"]) await pad.dispatchEvent(type, { pointerId: 999, pointerType: "touch", button: 0, buttons: 1, clientX: bounds.x + 200, clientY: bounds.y + 200 }); + await expect(page.getByRole("status")).toContainText("Drawing…"); + await page.mouse.move(bounds.x + bounds.width + 30, bounds.y + 100, { steps: 10 }); + await page.mouse.up(); + await expect(page.getByRole("status")).toContainText("1 stroke drawn"); + const finished = await inkPixels(pad); + await page.mouse.move(bounds.x + 90, bounds.y + 90, { steps: 5 }); + expect(Math.abs(await inkPixels(pad) - finished)).toBeLessThan(finished * .05); + await page.mouse.down(); + await page.mouse.move(bounds.x + 140, bounds.y + 140, { steps: 5 }); + await page.mouse.up(); + await expect(page.getByRole("status")).toContainText("2 strokes drawn"); +}); diff --git a/tests/content/lesson.test.ts b/tests/content/lesson.test.ts new file mode 100644 index 0000000..5ad1f4c --- /dev/null +++ b/tests/content/lesson.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "vitest"; +import { parseLesson } from "../../src/features/practice/domain/lesson"; +import { DEVELOPMENT_LESSON } from "../../src/features/practice/domain/development-lesson"; + +test("the original development lesson parses with honest review status and ordered strokes", () => { + const result = parseLesson(DEVELOPMENT_LESSON); + expect(result.kind).toBe("ready"); + if (result.kind !== "ready") throw new Error("Development lesson did not parse"); + expect(result.lesson.glyph).toBe("ल"); + expect(result.lesson.pronunciation).toBe("la"); + expect(result.lesson.review).toBe("pending"); + expect(result.lesson.strokes).toHaveLength(3); +}); + +test("unusable content fails before interaction instead of becoming handwriting feedback", () => { + const first = DEVELOPMENT_LESSON.strokes[0]; + if (!first) throw new Error("Fixture needs a stroke"); + const invalid = [ + null, + { ...DEVELOPMENT_LESSON, id: " " }, + { ...DEVELOPMENT_LESSON, pronunciation: "" }, + { ...DEVELOPMENT_LESSON, glyph: "" }, + { ...DEVELOPMENT_LESSON, strokes: [] }, + ...[0, -1, NaN, Infinity].map((width) => ({ ...DEVELOPMENT_LESSON, bounds: { ...DEVELOPMENT_LESSON.bounds, width } })), + ...[[], [{ x: 20, y: 30 }], [{ x: 20, y: 30 }, { x: 20, y: 30 }], [{ x: NaN, y: 30 }, { x: 30, y: 40 }], [{ x: -1, y: 20 }, { x: 40, y: 20 }], [{ x: 20, y: 20 }, { x: 101, y: 20 }]].map((points) => ({ ...DEVELOPMENT_LESSON, strokes: [{ ...first, points }] })), + ]; + for (const input of invalid) { + expect(parseLesson(input).kind).toBe("unusable"); + } +}); + + +test("the content boundary preserves the authored teaching order", () => { + const result = parseLesson({ ...DEVELOPMENT_LESSON, strokes: [ + { hint: "Right to left", points: [{ x: 70, y: 20 }, { x: 40, y: 20 }] }, + { hint: "Down", points: [{ x: 10, y: 30 }, { x: 10, y: 70 }] }, + ] }); + expect(result.kind).toBe("ready"); + if (result.kind !== "ready") throw new Error("Authored sequence did not parse"); + expect(result.lesson.strokes.map((stroke) => stroke.points[0])).toEqual([{ x: 70, y: 20 }, { x: 10, y: 30 }]); +}); diff --git a/tsconfig.json b/tsconfig.json index 7820add..726907e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "skipLibCheck": true, "types": [ "void/env", - "node" + "node", + "vite/client" ], "jsx": "react-jsx", "noUncheckedIndexedAccess": true, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f2fefa6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ test: { include: ["tests/content/**/*.test.ts"] } }); From 4a1e2c3ed0a6b10a59a889abee07d81873fb0fd1 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 22 Oct 2026 11:13:19 +0530 Subject: [PATCH 08/22] fix: verify tracing states and resize alignment --- pages/index.tsx | 7 ++- src/features/practice/SPEC.md | 3 +- src/features/practice/browser/practice.css | 8 +-- src/features/practice/browser/practice.tsx | 16 +++--- tests/browser/tracing.spec.ts | 59 +++++++++++++++++++--- tests/content/lesson.test.ts | 15 ++++++ 6 files changed, 86 insertions(+), 22 deletions(-) diff --git a/pages/index.tsx b/pages/index.tsx index cc0196b..f1ad153 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,5 +1,10 @@ import { Practice } from "../src/features/practice/browser/practice"; +import { DEVELOPMENT_LESSON } from "../src/features/practice/domain/development-lesson"; +import { parseLesson } from "../src/features/practice/domain/lesson"; + +const CONTENT = parseLesson(DEVELOPMENT_LESSON); + export default function HomePage() { - return ; + return ; } diff --git a/src/features/practice/SPEC.md b/src/features/practice/SPEC.md index eb79e5a..20dd142 100644 --- a/src/features/practice/SPEC.md +++ b/src/features/practice/SPEC.md @@ -9,6 +9,7 @@ No completion, scoring, persistence, or pedagogical-validity claim belongs here - `domain/development-lesson.ts` owns original directed reference geometry and hints. - `domain/lesson.ts` parses unknown content into a usable, branded lesson or a distinct content failure. Points must be finite, in bounds, and nondegenerate. +- `pages/index.tsx` parses the checked-in content once and supplies its result. - `browser/practice.tsx` renders the letter, shared SVG coordinate frame, live ink, honest status, ordered Reveal hints, and labeled Clear/Retry controls. - `browser/use-drawing.ts` owns one captured pointer and temporary React state. @@ -23,7 +24,7 @@ capture, blur, hidden document, and layout changes stop unfinished input safely. Clear and Retry reset ink and revealed hints. Reveal never accepts a stroke. Attempts remain in memory and disappear when the page is left or refreshed. -`tests/content/lesson.test.ts` checks the content boundary and authored ordering. +`tests/content/lesson.test.ts` checks parsing, authored ordering, and the unusable-content render. `tests/browser/tracing.spec.ts` checks public ink/status/control behavior and interruption recovery. Actual iPhone/Android and finger/stylus checks remain open. Stroke matching and calibration follow only after the lesson-review dependency. diff --git a/src/features/practice/browser/practice.css b/src/features/practice/browser/practice.css index b0bd338..3a76006 100644 --- a/src/features/practice/browser/practice.css +++ b/src/features/practice/browser/practice.css @@ -10,11 +10,11 @@ body { margin: 0; } button { font: inherit; } .practice-app { max-width: 1120px; margin: 0 auto; padding: 0 32px 48px; } -.app-header { min-height: 88px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #c9d3e3; } +.app-header { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #c9d3e3; } h1 { margin: 0; font-size: 1.75rem; line-height: 1.15; font-weight: 700; letter-spacing: -.025em; } .review-notice { margin: 0; padding: 8px 12px; background: #e3e9ff; color: #17349e; font-size: .875rem; border-radius: 6px; } -.practice-layout { display: grid; grid-template-columns: minmax(0, 500px) minmax(240px, 1fr); gap: 48px; padding-top: 16px; align-items: start; } -.lesson-identity { display: flex; align-items: center; gap: 24px; min-height: 72px; margin-bottom: 16px; } +.practice-layout { display: grid; grid-template-columns: minmax(0, 460px) minmax(240px, 1fr); gap: 48px; padding-top: 16px; align-items: start; } +.lesson-identity { display: flex; align-items: center; gap: 24px; min-height: 64px; margin-bottom: 12px; } .lesson-glyph { font-size: 4rem; line-height: 1; padding-right: 24px; border-right: 1px solid #c9d3e3; } .pronunciation { font-size: 2rem; line-height: 1.15; } .lesson-identity p { margin: 4px 0 0; font-size: .875rem; color: #526079; } @@ -38,7 +38,7 @@ h1 { margin: 0; font-size: 1.75rem; line-height: 1.15; font-weight: 700; letter- .practice-controls button.primary:hover { background: #17349e; } .practice-controls button:focus-visible { outline: 3px solid #15223b; outline-offset: 4px; } .practice-controls button:disabled { opacity: .5; cursor: wait; } -.reference-rail { padding-top: 88px; max-width: 340px; } +.reference-rail { padding-top: 76px; max-width: 340px; } h2 { font-size: 1.125rem; line-height: 1.35; margin: 0 0 8px; font-weight: 650; } .rail-intro p { margin: 0; color: #526079; } .reveal-instruction { margin: 24px 0; } diff --git a/src/features/practice/browser/practice.tsx b/src/features/practice/browser/practice.tsx index 2703ea0..76f5adf 100644 --- a/src/features/practice/browser/practice.tsx +++ b/src/features/practice/browser/practice.tsx @@ -1,21 +1,17 @@ import { useId, useState } from "react"; -import { DEVELOPMENT_LESSON } from "../domain/development-lesson"; -import { parseLesson } from "../domain/lesson"; -import type { Lesson, Point } from "../domain/lesson"; +import type { Lesson, LessonContent, Point } from "../domain/lesson"; import { useDrawing } from "./use-drawing"; import "./practice.css"; -const CONTENT = parseLesson(DEVELOPMENT_LESSON); - function pointsAttribute(points: readonly Point[]): string { return points.map((point) => `${point.x},${point.y}`).join(" "); } -export function Practice() { - if (CONTENT.kind === "unusable") { - return

Strokes

Lesson unavailable

{CONTENT.message}

Your writing has not been checked. Reload the page to try again.

; +export function Practice({ content }: { readonly content: LessonContent }) { + if (content.kind === "unusable") { + return

Strokes

Lesson unavailable

{content.message}

Your writing has not been checked. Reload the page to try again.

; } - return ; + return ; } function TracingPractice({ lesson }: { readonly lesson: Lesson }) { @@ -25,7 +21,7 @@ function TracingPractice({ lesson }: { readonly lesson: Lesson }) { const { x, y, width, height } = lesson.bounds; const viewBox = `${x} ${y} ${width} ${height}`; const count = attempt.strokes.length; - let feedback = "Ready to trace. Follow the faint guide."; + let feedback = revealed ? "Reference shown. Trace along it, or choose Retry for the faint guide." : "Ready to trace. Follow the faint guide."; if (count > 0) feedback = `${count} ${count === 1 ? "stroke" : "strokes"} drawn. Keep tracing or start again.`; if (attempt.feedback === "drawing") feedback = "Drawing… Lift your finger or pen to finish this stroke."; if (attempt.feedback === "interrupted") feedback = "Stroke interrupted. Your finished strokes are safe. Try again."; diff --git a/tests/browser/tracing.spec.ts b/tests/browser/tracing.spec.ts index 878b3f8..426e50a 100644 --- a/tests/browser/tracing.spec.ts +++ b/tests/browser/tracing.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "./fixtures"; import type { Locator } from "@playwright/test"; -async function inkPixels(pad: Locator): Promise { +async function inkScreenshot(pad: Locator): Promise<{ readonly count: number; readonly bounds: { readonly left: number; readonly top: number; readonly right: number; readonly bottom: number } }> { const png = (await pad.screenshot()).toString("base64"); return pad.evaluate(async (_element, encoded) => { const response = await fetch(`data:image/png;base64,${encoded}`); @@ -15,16 +15,26 @@ async function inkPixels(pad: Locator): Promise { bitmap.close(); const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; let count = 0; + let left = canvas.width, top = canvas.height, right = 0, bottom = 0; for (let index = 0; index < pixels.length; index += 4) { const red = pixels[index] ?? 255; const green = pixels[index + 1] ?? 255; const blue = pixels[index + 2] ?? 0; - if (blue > red * 1.5 && blue > green * 1.5) count++; + if (blue > red * 1.5 && blue > green * 1.5) { + count++; + const x = index / 4 % canvas.width, y = Math.floor(index / 4 / canvas.width); + left = Math.min(left, x); top = Math.min(top, y); + right = Math.max(right, x); bottom = Math.max(bottom, y); + } } - return count; + return { count, bounds: { left: left / canvas.width, top: top / canvas.height, right: right / canvas.width, bottom: bottom / canvas.height } }; }, png); } +async function inkPixels(pad: Locator): Promise { + return (await inkScreenshot(pad)).count; +} + test.use({ viewport: { width: 1200, height: 1200 } }); test("shows live local ink, separate gestures, and a clear tracing-only reset", async ({ page }) => { @@ -59,7 +69,7 @@ test("Reveal exposes ordered direction hints without checking or completing, and await expect(reveal).toHaveAttribute("aria-pressed", "true"); await expect(page.getByRole("listitem")).toHaveCount(3); await expect(page.getByText("Tracing only. Your writing is not checked or scored.", { exact: true })).toBeVisible(); - await expect(page.getByRole("status")).toContainText("Ready to trace"); + await expect(page.getByRole("status")).toHaveText("Reference shown. Trace along it, or choose Retry for the faint guide."); for (let repeat = 0; repeat < 2; repeat++) { await page.getByRole("button", { name: "Retry", exact: true }).click(); await expect(reveal).toHaveAttribute("aria-pressed", "false"); @@ -81,7 +91,8 @@ for (const interruption of ["cancel", "capture loss", "blur", "resize", "layout await page.mouse.move(start.x + 70, start.y + 50, { steps: 5 }); await page.mouse.up(); await expect(page.getByRole("status")).toContainText("1 stroke drawn"); - const finished = await inkPixels(pad); + const finishedInk = await inkScreenshot(pad); + const finished = finishedInk.count; expect(finished).toBeGreaterThan(100); await pad.evaluate((element) => element.addEventListener("pointerdown", (event) => { if (event instanceof PointerEvent) element.setAttribute("data-test-pointer", String(event.pointerId)); @@ -97,7 +108,7 @@ for (const interruption of ["cancel", "capture loss", "blur", "resize", "layout await page.mouse.move(start.x + 180, start.y + 60); } if (interruption === "blur") await page.evaluate(() => window.dispatchEvent(new Event("blur"))); - if (interruption === "resize") await page.setViewportSize({ width: 1000, height: 1200 }); + if (interruption === "resize") await page.setViewportSize({ width: 390, height: 844 }); if (interruption === "layout shift") { await pad.evaluate((element) => { element.style.transform = "translateY(20px)"; }); await page.mouse.move(start.x + 180, start.y + 60); @@ -114,6 +125,14 @@ for (const interruption of ["cancel", "capture loss", "blur", "resize", "layout } const nextBounds = await pad.boundingBox(); if (!nextBounds) throw new Error("No resized pad"); + if (interruption === "resize") { + expect(nextBounds.width).toBeLessThan(bounds.width * .8); + const resizedInk = await inkScreenshot(pad); + expect(resizedInk.count).toBeGreaterThan(50); + for (const edge of ["left", "top", "right", "bottom"] as const) { + expect(Math.abs(resizedInk.bounds[edge] - finishedInk.bounds[edge])).toBeLessThan(.01); + } + } await page.mouse.move(nextBounds.x + 30, nextBounds.y + 30); await page.mouse.down(); await page.mouse.move(nextBounds.x + 80, nextBounds.y + 80, { steps: 4 }); @@ -143,3 +162,31 @@ test("other pointers do not interrupt capture, and releasing outside does not le await page.mouse.up(); await expect(page.getByRole("status")).toContainText("2 strokes drawn"); }); + + +test("phone layout supports drawing, reset, and visible keyboard focus", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + const pad = page.getByRole("img", { name: "Hindi writing practice area" }); + const bounds = await pad.boundingBox(); + if (!bounds) throw new Error("No phone pad"); + expect(bounds.width).toBeLessThan(390); + await page.mouse.move(bounds.x + bounds.width * .2, bounds.y + bounds.height * .3); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width * .6, bounds.y + bounds.height * .7, { steps: 8 }); + await page.mouse.up(); + await expect(page.getByRole("status")).toContainText("1 stroke drawn"); + expect(await inkPixels(pad)).toBeGreaterThan(100); + await page.keyboard.press("Tab"); + const clear = page.getByRole("button", { name: "Clear", exact: true }); + await expect(clear).toBeFocused(); + expect(await clear.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("solid"); + await page.keyboard.press("Enter"); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + expect(await inkPixels(pad)).toBe(0); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Reveal", exact: true })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeFocused(); +}); diff --git a/tests/content/lesson.test.ts b/tests/content/lesson.test.ts index 5ad1f4c..47ac423 100644 --- a/tests/content/lesson.test.ts +++ b/tests/content/lesson.test.ts @@ -1,3 +1,6 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Practice } from "../../src/features/practice/browser/practice"; import { expect, test } from "vitest"; import { parseLesson } from "../../src/features/practice/domain/lesson"; import { DEVELOPMENT_LESSON } from "../../src/features/practice/domain/development-lesson"; @@ -39,3 +42,15 @@ test("the content boundary preserves the authored teaching order", () => { if (result.kind !== "ready") throw new Error("Authored sequence did not parse"); expect(result.lesson.strokes.map((stroke) => stroke.points[0])).toEqual([{ x: 70, y: 20 }, { x: 10, y: 30 }]); }); + + +test("invalid content renders a distinct failure without learner controls or completion", () => { + const markup = renderToStaticMarkup(createElement(Practice, { content: parseLesson({ ...DEVELOPMENT_LESSON, strokes: [] }) })); + expect(markup).toContain('role="alert"'); + expect(markup).toContain("Lesson unavailable"); + expect(markup).toContain("Its content needs repair."); + expect(markup).not.toContain("Hindi writing practice area"); + expect(markup).not.toContain(" Date: Thu, 22 Oct 2026 11:13:45 +0530 Subject: [PATCH 09/22] docs: record the implemented practice design --- .impeccable/design.json | 204 ++++++++++++++++++++++++++++++++++++++++ DESIGN.md | 50 +++++++++- PRODUCT.md | 2 +- 3 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 .impeccable/design.json diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..72eb5c5 --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,204 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-10-22T05:37:28.158120+00:00", + "title": "Design System: Strokes", + "extensions": { + "colorMeta": { + "primary": { + "role": "primary", + "displayName": "Primary", + "canonical": "#2348d1", + "tonalRamp": [ + "oklch(15% 0.2 265)", + "oklch(27% 0.2 265)", + "oklch(39% 0.2 265)", + "oklch(51% 0.2 265)", + "oklch(63% 0.2 265)", + "oklch(75% 0.2 265)", + "oklch(87% 0.2 265)", + "oklch(95% 0.2 265)" + ] + }, + "primary-deep": { + "role": "primary", + "displayName": "Primary Deep", + "canonical": "#17349e", + "tonalRamp": [ + "oklch(15% 0.18 265)", + "oklch(27% 0.18 265)", + "oklch(39% 0.18 265)", + "oklch(51% 0.18 265)", + "oklch(63% 0.18 265)", + "oklch(75% 0.18 265)", + "oklch(87% 0.18 265)", + "oklch(95% 0.18 265)" + ] + }, + "ground": { + "role": "neutral", + "displayName": "Ground", + "canonical": "#edf1f6", + "tonalRamp": [ + "oklch(15% 0.015 265)", + "oklch(27% 0.015 265)", + "oklch(39% 0.015 265)", + "oklch(51% 0.015 265)", + "oklch(63% 0.015 265)", + "oklch(75% 0.015 265)", + "oklch(87% 0.015 265)", + "oklch(95% 0.015 265)" + ] + }, + "surface": { + "role": "neutral", + "displayName": "Surface", + "canonical": "#ffffff", + "tonalRamp": [ + "oklch(15% 0 265)", + "oklch(27% 0 265)", + "oklch(39% 0 265)", + "oklch(51% 0 265)", + "oklch(63% 0 265)", + "oklch(75% 0 265)", + "oklch(87% 0 265)", + "oklch(95% 0 265)" + ] + }, + "ink": { + "role": "neutral", + "displayName": "Ink", + "canonical": "#15223b", + "tonalRamp": [ + "oklch(15% 0.04 265)", + "oklch(27% 0.04 265)", + "oklch(39% 0.04 265)", + "oklch(51% 0.04 265)", + "oklch(63% 0.04 265)", + "oklch(75% 0.04 265)", + "oklch(87% 0.04 265)", + "oklch(95% 0.04 265)" + ] + }, + "muted": { + "role": "neutral", + "displayName": "Muted", + "canonical": "#526079", + "tonalRamp": [ + "oklch(15% 0.04 265)", + "oklch(27% 0.04 265)", + "oklch(39% 0.04 265)", + "oklch(51% 0.04 265)", + "oklch(63% 0.04 265)", + "oklch(75% 0.04 265)", + "oklch(87% 0.04 265)", + "oklch(95% 0.04 265)" + ] + }, + "rule": { + "role": "neutral", + "displayName": "Rule", + "canonical": "#c9d3e3", + "tonalRamp": [ + "oklch(15% 0.025 265)", + "oklch(27% 0.025 265)", + "oklch(39% 0.025 265)", + "oklch(51% 0.025 265)", + "oklch(63% 0.025 265)", + "oklch(75% 0.025 265)", + "oklch(87% 0.025 265)", + "oklch(95% 0.025 265)" + ] + }, + "guide": { + "role": "primary", + "displayName": "Guide", + "canonical": "#c6d2f6", + "tonalRamp": [ + "oklch(15% 0.08 265)", + "oklch(27% 0.08 265)", + "oklch(39% 0.08 265)", + "oklch(51% 0.08 265)", + "oklch(63% 0.08 265)", + "oklch(75% 0.08 265)", + "oklch(87% 0.08 265)", + "oklch(95% 0.08 265)" + ] + }, + "grid": { + "role": "neutral", + "displayName": "Grid", + "canonical": "#dce3ee", + "tonalRamp": [ + "oklch(15% 0.02 265)", + "oklch(27% 0.02 265)", + "oklch(39% 0.02 265)", + "oklch(51% 0.02 265)", + "oklch(63% 0.02 265)", + "oklch(75% 0.02 265)", + "oklch(87% 0.02 265)", + "oklch(95% 0.02 265)" + ] + }, + "primary-soft": { + "role": "primary", + "displayName": "Primary Soft", + "canonical": "#e3e9ff", + "tonalRamp": [ + "oklch(15% 0.055 265)", + "oklch(27% 0.055 265)", + "oklch(39% 0.055 265)", + "oklch(51% 0.055 265)", + "oklch(63% 0.055 265)", + "oklch(75% 0.055 265)", + "oklch(87% 0.055 265)", + "oklch(95% 0.055 265)" + ] + }, + "primary-pressed": { + "role": "primary", + "displayName": "Primary Pressed", + "canonical": "#d3ddff", + "tonalRamp": [ + "oklch(15% 0.07 265)", + "oklch(27% 0.07 265)", + "oklch(39% 0.07 265)", + "oklch(51% 0.07 265)", + "oklch(63% 0.07 265)", + "oklch(75% 0.07 265)", + "oklch(87% 0.07 265)", + "oklch(95% 0.07 265)" + ] + } + }, + "shadows": [], + "motion": [], + "breakpoints": [ + { + "name": "phone", + "value": "760px" + } + ] + }, + "components": [ + { + "name": "Reveal button", + "kind": "button", + "html": "", + "css": "button { font: 600 16px system-ui, sans-serif; min-height:48px; padding:10px 16px; border:1px solid #2348d1; border-radius:8px; background:#2348d1; color:white; cursor:pointer; } button:hover { background:#17349e; } button:focus-visible { outline:3px solid #15223b; outline-offset:4px; } button:disabled { opacity:.5; cursor:wait; }" + } + ], + "narrative": { + "northStar": "Printmaker's proof", + "overview": "A cool white working field, cobalt ink, registration corners, and explicit directed-stroke proofs. The user delegated the visual choice on 2026-10-22. The reference metaphor explains aligned drawing and repeated attempts; it introduces no printing terminology into learner instructions.", + "rules": [], + "dos": [ + "Do prioritize the pad under ordinary indoor and daylight phone use.", + "Do keep human review and development-fixture status honest and readable.", + "Do use semantic HTML and SVG for all instructions, controls, and drawing." + ], + "donts": [ + "Don't add streaks, scores, mastery claims, a lesson catalog, or persistent history.", + "Don't use imagery or animation where it would compete with the drawing task." + ] + } +} diff --git a/DESIGN.md b/DESIGN.md index db92145..8fd9f4d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -10,6 +10,9 @@ colors: muted: "#526079" rule: "#c9d3e3" guide: "#c6d2f6" + grid: "#dce3ee" + primary-soft: "#e3e9ff" + primary-pressed: "#d3ddff" typography: body: fontFamily: "system-ui, sans-serif" @@ -17,15 +20,54 @@ typography: lineHeight: 1.5 title: fontFamily: "system-ui, sans-serif" + fontSize: "1.75rem" + fontWeight: 700 + lineHeight: 1.15 + title-phone: + fontSize: "1.5rem" + pronunciation: fontSize: "2rem" - fontWeight: 650 lineHeight: 1.15 + section: + fontSize: "1.125rem" + fontWeight: 650 + lineHeight: 1.35 + feedback: + fontSize: "0.9375rem" + small: + fontSize: "0.875rem" + caption: + fontSize: "0.75rem" + glyph: + fontSize: "4rem" + lineHeight: 1 + glyph-phone: + fontSize: "3.5rem" + lineHeight: 1 rounded: control: "8px" + notice: "6px" spacing: small: "8px" group: "16px" section: "32px" +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.surface}" + rounded: "{rounded.control}" + padding: "10px 16px" + button-primary-hover: + backgroundColor: "{colors.primary-deep}" + button-secondary: + backgroundColor: "{colors.surface}" + textColor: "{colors.primary}" + rounded: "{rounded.control}" + padding: "10px 16px" + button-secondary-hover: + backgroundColor: "{colors.primary-soft}" + button-secondary-active: + backgroundColor: "{colors.primary-pressed}" --- # Design System: Strokes @@ -42,11 +84,11 @@ White owns the writing surface; a cool neutral ground separates the workspace fr ## Typography -Use system sans for controls and instructions, with readable fixed rem sizes. The lesson glyph and reference paths carry the visual identity. Use the platform Devanagari fallback for the glyph label; the actual teaching paths are authored SVG geometry, never a font outline. +Use system sans for controls and instructions, with readable fixed rem sizes. Pronunciation uses the title size on phones. The lesson glyph and reference paths carry the visual identity. Use the platform Devanagari fallback for the glyph label; the actual teaching paths are authored SVG geometry, never a font outline. Numbered SVG hints use four lesson-coordinate units, scaling with the drawing frame. ## Layout -The square writing surface leads. A compact lesson header precedes it; instructions and labeled controls remain adjacent. On wide screens, an instruction rail sits beside the larger drawing frame. On phones, the rail moves below the pad and controls stay reachable. Avoid a dashboard, lesson catalog, or decorative card grid. +The square writing surface leads. A compact lesson header precedes it; instructions and labeled controls remain adjacent. On wide screens, an instruction rail sits beside the drawing frame. At the phone breakpoint (760px), the rail moves below the pad and controls stay reachable. Avoid a dashboard, lesson catalog, or decorative card grid. ## Elevation & Depth @@ -58,7 +100,7 @@ Keep the pad geometrically square with modest corner treatment. Controls have co ## Components -The pad is a real SVG with a shared lesson coordinate system. Reference geometry is faint; learner ink is cobalt. Reveal exposes numbered directed reference strokes without accepting input. Buttons use text labels, visible focus, hover and pressed states. Feedback uses readable text with a stable position to avoid layout movement during drawing. +The pad is a real SVG with a shared lesson coordinate system. Reference geometry is faint; learner ink is cobalt. Reveal exposes numbered directed reference strokes without marking a stroke accepted or completing practice; the learner may trace over the revealed reference. Buttons use text labels, visible focus, hover and pressed states. They have a minimum touch height of 48px; preparation disables them visibly. Feedback uses readable text with a stable position to avoid layout movement during drawing. ## Do's and Don'ts diff --git a/PRODUCT.md b/PRODUCT.md index e75c83c..1ffc2d0 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -28,7 +28,7 @@ The writing surface must work on a phone, allow page scrolling outside the pad, ## Brand Commitments -The learner-facing product name is Strokes. TimTim names the repository. No visual identity is established yet. +The learner-facing product name is Strokes. TimTim names the repository. The owner delegated the visual direction on 2026-10-22; DESIGN.md records the selected drawing-frame system. ## Evidence on Hand From 4e1c8acbb2ca27a2a3af14c18fb813736d98f322 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 16:42:38 +0530 Subject: [PATCH 10/22] refactor: colocate practice and use Tailwind --- .gitignore | 1 + ...-features-practice-browser-practice-tsx.md | 2 +- .oxlintrc.json | 21 +- AGENTS.md | 72 ++-- README.md | 8 +- {research => docs/research}/rea-summary.json | 0 package.json | 5 +- pages/index.tsx | 10 - playwright.config.ts | 3 +- pnpm-lock.yaml | 346 ++++++++++++++++++ scripts/oxlint-boundaries.mjs | 10 +- src/features/practice/SPEC.md | 30 -- .../practice/__tests__}/browser/fixtures.ts | 0 .../__tests__}/browser/practice.spec.ts | 0 .../__tests__}/browser/tracing.spec.ts | 0 .../practice/__tests__/content/lesson.test.ts | 103 ++++++ .../practice/__tests__}/fence.test.mjs | 42 ++- src/features/practice/browser/practice.css | 65 ---- src/features/practice/browser/practice.tsx | 96 ----- src/features/practice/components/practice.tsx | 282 ++++++++++++++ src/features/practice/domain/lesson.ts | 90 +++-- .../{browser => use-cases}/use-drawing.ts | 0 src/pages/index.tsx | 35 ++ src/styles.css | 23 ++ tests/content/lesson.test.ts | 56 --- tsconfig.json | 80 ++-- vite.config.ts | 2 + vitest.config.ts | 2 +- void.config.ts | 1 + 29 files changed, 1011 insertions(+), 374 deletions(-) rename {research => docs/research}/rea-summary.json (100%) delete mode 100644 pages/index.tsx delete mode 100644 src/features/practice/SPEC.md rename {tests => src/features/practice/__tests__}/browser/fixtures.ts (100%) rename {tests => src/features/practice/__tests__}/browser/practice.spec.ts (100%) rename {tests => src/features/practice/__tests__}/browser/tracing.spec.ts (100%) create mode 100644 src/features/practice/__tests__/content/lesson.test.ts rename {tests => src/features/practice/__tests__}/fence.test.mjs (67%) delete mode 100644 src/features/practice/browser/practice.css delete mode 100644 src/features/practice/browser/practice.tsx create mode 100644 src/features/practice/components/practice.tsx rename src/features/practice/{browser => use-cases}/use-drawing.ts (100%) create mode 100644 src/pages/index.tsx create mode 100644 src/styles.css delete mode 100644 tests/content/lesson.test.ts diff --git a/.gitignore b/.gitignore index edb7932..046b8e9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ test-results/ .fence-fixtures-*/ .impeccable/questions/ +.keepup/map.html diff --git a/.impeccable/surfaces/src-features-practice-browser-practice-tsx.md b/.impeccable/surfaces/src-features-practice-browser-practice-tsx.md index 68883f3..f33994c 100644 --- a/.impeccable/surfaces/src-features-practice-browser-practice-tsx.md +++ b/.impeccable/surfaces/src-features-practice-browser-practice-tsx.md @@ -1,7 +1,7 @@ --- version: 1 slug: "src-features-practice-browser-practice-tsx" -primary_target: "src/features/practice/browser/practice.tsx" +primary_target: "src/features/practice/components/practice.tsx" related_targets: [] --- diff --git a/.oxlintrc.json b/.oxlintrc.json index d030d0e..879a443 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -6,7 +6,8 @@ "jsx-a11y" ], "jsPlugins": [ - "./scripts/oxlint-boundaries.mjs" + "./scripts/oxlint-boundaries.mjs", + "oxlint-tailwindcss" ], "categories": { "correctness": "error" @@ -15,13 +16,18 @@ "strokes-boundaries/no-platform-in-domain": "error", "typescript/no-explicit-any": "error", "typescript/no-non-null-assertion": "error", - "typescript/consistent-type-imports": "error" + "typescript/consistent-type-imports": "error", + "tailwindcss/no-conflicting-classes": "error", + "tailwindcss/no-unknown-classes": "error", + "tailwindcss/no-dynamic-classes": "error", + "tailwindcss/no-duplicate-classes": "error", + "tailwindcss/no-deprecated-classes": "error" }, "overrides": [ { "files": [ "src/**/domain/**/*.{ts,tsx}", - "pages/**/*.{ts,tsx}", + "src/pages/**/*.{ts,tsx}", ".fence-fixtures-*/domain/*.ts" ], "rules": { @@ -42,9 +48,16 @@ ], "ignorePatterns": [ "node_modules", + ".agents", + ".claude", ".void", "dist", "test-results", "playwright-report" - ] + ], + "settings": { + "tailwindcss": { + "entryPoint": "src/styles.css" + } + } } diff --git a/AGENTS.md b/AGENTS.md index c22cdcf..7c0e0b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,27 +1,45 @@ -# Strokes - -Read [README.md](README.md) for the product, investigation evidence, data ownership, and first implementation slice. - -Project repository and issue tracker: [joshpitzalis/TimTim](https://github.com/joshpitzalis/TimTim). Publish implementation specifications to GitHub Issues with the `ready-for-agent` label after required test-seam confirmation. Create that label if absent. - -## Build reference - -Use [rat-stack](https://ratstack.sh/llms.txt) as the engineering reference. Search its rules and skills before code changes. Read the owning rule and matching skill, then inspect the installed package instructions and pinned source for Effect, XState, and infrastructure work. - -Owner decisions: React for the UI; no Foldkit. Void owns the framework and deployment; use it instead of Alchemy. Effect owns external work and typed failures. Retain rat-stack's boundary and fence patterns while following these explicit framework choices. - -## Product boundaries - -Start with mobile tracing of one reviewed Hindi letter. Keep the input loop local and geometry pure. Author original lesson templates; Skritter's code and data are research references only. - -The checked-in lesson is authoritative for the teaching sequence. React state owns the temporary attempt. Add durable progress only after naming its writer and authoritative store. - -## Enforcement - -The Void/React scaffold uses exact dependency pins, strict type checking with Effect diagnostics, Oxlint import boundaries, browser tests, commit hooks, and CI. Run `pnpm check`, `pnpm test`, and `pnpm build`; derive commands from the package scripts and preserve the fence. Read installed Void guidance at `node_modules/void/skills/void/SKILL.md` and matching `node_modules/void/docs/` before framework changes. Read `node_modules/effect/AGENTS.md` and pinned source before Effect changes. - -Keep provider implementations outside domain code. Framework routes adapt shared contracts when server actions are introduced. Keep browser APIs in browser modules. Report violations of these patterns explicitly and fix their cause; preserve checks rather than bypassing them. - -## Verification - -Use geometry properties for the matcher and browser interaction checks for drawing, clearing, resizing, and cancellation. Validate mobile behavior on real devices. Report local checks, deployment, and device verification separately. +# Project conventions + +TypeScript web app using Effect. Follow the installed Effect version and existing project conventions. + +## Feature ownership + +- Build self-contained vertical slices. Keep feature-specific domain code, use cases, services, controllers, UI, tests, and supporting code together. +- Removing functionality should mean deleting its feature module and removing minimal, explicit registration. Document unavoidable external dependencies or cleanup in the feature README. +- Expose a small public API; do not import another feature's internals. Extract shared code only when it is genuinely shared. +- Preserve applied database migrations; deleting code must not implicitly delete persisted data or external resources. +- Colocate tests in a `__tests__/` folder inside each feature, such as `src/features/installation/__tests__/`. Keep feature-specific fixtures and test helpers there too. +- When a feature starts as a single file, give it a feature folder when adding tests so its implementation and tests stay together. +- Verify changed behavior with appropriate checks. + +## Structure and responsibilities + +Keep named features under `src/features/`. Start small: a whole feature can begin as one file, such as `src/features/installation.ts`. As responsibilities grow, use this structure: + +```text +src/features/installation/ + index.ts # Public API and feature wiring + domain/ # Business types, schemas, rules, and business errors + use-cases/ # Business actions; coordinate domain rules and services + services/ # Effect service contracts, implementations, and Layers + controller.ts # HTTP input, use-case calls, response/error mapping +src/main.ts # Compose Layers and start the app +``` + +- Domain code must not depend on HTTP, databases, controllers, use cases, or service implementations. +- Controllers call use cases; use cases depend on service contracts and domain rules. Keep SQL and external API details in services. +- Keep each service's contract, implementation, and Layer together by default. Acquire external resources through Effect, never as import-time side effects. +- Use these names: `use-cases/`, `services/`, and `controller.ts`. + +## Grow files incrementally + +- Aim for roughly 200 lines per file; this is a soft limit. +- Any module—feature, use case, service, controller, or utility—may start as one file. +- As it grows, replace the file with a same-named folder containing `index.ts`. Extract the most semantically isolated concept into a descriptively named sibling file; repeat as needed. +- Example: `utils.ts` → `utils/index.ts` + `utils/formatCurrency.ts`. +- The index may retain cohesive implementation; it need not be only re-exports. Avoid redundant nested folders and arbitrary splits. +- Preserve public exports and update imports according to the project's module-resolution rules. +- Use cases and services may each own `helpers/` for named supporting operations and `lib/` for files such as `errors.ts`, `utils.ts`, and `constants.ts`. These files follow the owning module's dependency boundaries. +- Create only needed files and folders. Keep supporting code local; do not scaffold empty directories or speculative abstractions. + +Verify changed behavior with appropriate checks and keep tests near their owner. \ No newline at end of file diff --git a/README.md b/README.md index 0ffe823..9d5457f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Use React and Void. Use rat-stack as the engineering reference: Effect for exter The implementation specification is [SPEC.md](SPEC.md), published as [GitHub issue #1](https://github.com/joshpitzalis/TimTim/issues/1). The owner confirmed the test seams and four-ticket breakdown on 2026-10-22. Implementation tickets [#2](https://github.com/joshpitzalis/TimTim/issues/2), [#3](https://github.com/joshpitzalis/TimTim/issues/3), [#4](https://github.com/joshpitzalis/TimTim/issues/4), and [#5](https://github.com/joshpitzalis/TimTim/issues/5) are labeled `ready-for-agent` and linked in dependency order; #2 can start immediately. +The follow-on step 3 specification is [SPEC-LESSONS.md](SPEC-LESSONS.md): a small reviewed lesson catalog, then practice completion saved on this browser. It depends on the checked single-letter exercise and awaits confirmation of its proposed test seams before publication. + ## What the Skritter investigation found The open Chrome tab was readable through native accessibility. Chrome did not expose a debugging endpoint, so REA could not attach to the authenticated page. The study session was left unchanged. @@ -26,7 +28,7 @@ I downloaded the public scripts linked by `https://skritter.com/study` and ran R These are static code findings from the legacy web bundle. They do not prove which branches ran in your session or describe Skritter's native apps. No trained recognizer was needed to explain this particular code path. REA evidence: `ev_ac4cc745a3f06625034762dfdcc80b0b49437d7c2367df61f361a94018d9353b`. -The source hashes, selection, and limitations are in [research/rea-summary.json](research/rea-summary.json). Full temporary evidence is at `/private/tmp/skritter-rea-evidence.json`. +The source hashes, selection, and limitations are in [docs/research/rea-summary.json](docs/research/rea-summary.json). Full temporary evidence is at `/private/tmp/skritter-rea-evidence.json`. ## The smallest useful implementation @@ -114,7 +116,9 @@ pnpm exec playwright install chromium pnpm dev ``` -The app is the actual Void 1.2.0 React Static Pages starter, without database or deployment setup. Void's page entry renders the browser practice module. Lesson content and pure geometry belong under the practice domain; browser drawing belongs under its browser folder. Provider adapters and server routes cannot be imported into either domain or browser code. There is no server mutation or cloud state. +The app is the actual Void 1.2.0 React Static Pages starter, without database or deployment setup. Void's page entry renders the browser practice module. Lesson content and pure geometry belong under the practice domain; the tracing UI belongs under `components/` and its browser drawing hook under `use-cases/`. Provider adapters and server routes cannot be imported into either domain or browser code. There is no server mutation or cloud state. + +Tailwind utilities own the practice styles. `src/styles.css` imports Tailwind and defines the shared theme; `oxlint-tailwindcss` rejects unknown, conflicting, duplicate, dynamic, and deprecated classes. The generated `dev`, `build`, `preview`, and `postinstall` commands are retained. The enforcement scripts are: diff --git a/research/rea-summary.json b/docs/research/rea-summary.json similarity index 100% rename from research/rea-summary.json rename to docs/research/rea-summary.json diff --git a/package.json b/package.json index 8ea0a1d..1ce74fc 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:content": "vitest run", "test:browser": "playwright test", "prepare": "effect-tsgo patch && lefthook install", - "test:fence": "node --test tests/fence.test.mjs" + "test:fence": "node --test src/features/practice/__tests__/fence.test.mjs" }, "dependencies": { "effect": "4.0.5", @@ -25,6 +25,7 @@ "devDependencies": { "@effect/tsgo": "0.56.0", "@playwright/test": "1.64.1", + "@tailwindcss/vite": "4.3.3", "@types/node": "26.6.5", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", @@ -33,6 +34,8 @@ "lefthook": "2.2.2", "oxc-transform-react": "0.152.0", "oxlint": "1.89.0", + "oxlint-tailwindcss": "1.16.0", + "tailwindcss": "4.3.3", "typescript": "7.0.2", "vite": "8.3.4", "vitest": "5.0.5", diff --git a/pages/index.tsx b/pages/index.tsx deleted file mode 100644 index f1ad153..0000000 --- a/pages/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Practice } from "../src/features/practice/browser/practice"; - -import { DEVELOPMENT_LESSON } from "../src/features/practice/domain/development-lesson"; -import { parseLesson } from "../src/features/practice/domain/lesson"; - -const CONTENT = parseLesson(DEVELOPMENT_LESSON); - -export default function HomePage() { - return ; -} diff --git a/playwright.config.ts b/playwright.config.ts index c91c70d..65d792b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,8 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ - testDir: "tests/browser", + testDir: "src/features", + testMatch: "**/__tests__/browser/**/*.spec.ts", fullyParallel: true, forbidOnly: Boolean(process.env.CI), retries: 0, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cc864b..4e09814 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: '@playwright/test': specifier: 1.64.1 version: 1.64.1 + '@tailwindcss/vite': + specifier: 4.3.3 + version: 4.3.3(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@types/node': specifier: 26.6.5 version: 26.6.5 @@ -209,6 +212,12 @@ importers: oxlint: specifier: 1.89.0 version: 1.89.0 + oxlint-tailwindcss: + specifier: 1.16.0 + version: 1.16.0 + tailwindcss: + specifier: 4.3.3 + version: 4.3.3 typescript: specifier: 7.0.2 version: 7.0.2 @@ -1087,6 +1096,12 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1559,6 +1574,100 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2016,6 +2125,10 @@ packages: resolution: {integrity: sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==} engines: {node: '>4.0'} + enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} + engines: {node: '>=10.13.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -2171,36 +2284,73 @@ packages: resolution: {integrity: sha512-37fYHAhL12r1twaEV0PA5TwoIT491eTGGYjhhXxlYNMq3DSkiGZBQ++Jfk5maXj0vq52NRauLtMikBKtbNJs3Q==} hasBin: true + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-arm64@1.33.0: resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-darwin-x64@1.33.0: resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-freebsd-x64@1.33.0: resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} @@ -2208,6 +2358,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} @@ -2215,6 +2372,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} @@ -2222,6 +2386,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} @@ -2229,18 +2400,34 @@ packages: os: [linux] libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss-win32-x64-msvc@1.33.0: resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} @@ -2252,6 +2439,9 @@ packages: resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} @@ -2307,6 +2497,10 @@ packages: resolution: {integrity: sha512-CAx+SKDU4uSOmQnyn7daMj/v/h8tF3fnaUMi4yImKAARPnXV0BieHe/qw1LTcK4KlnIuAhpfUJfSfNKIBh8HqQ==} engines: {node: ^20.19.0 || >=22.12.0} + oxlint-tailwindcss@1.16.0: + resolution: {integrity: sha512-qwkw3gahMiLOVkZAInULbvnFaFceanEnk+GxpMyNZU4bq2nRmZd3xD4En0EhIakEBiIt15RiGwQ7AuhrllNoNw==} + engines: {node: ^20.19.0 || >=22.12.0} + oxlint@1.89.0: resolution: {integrity: sha512-a25hkfo61okGPvJz432fRIPoxlKtNnOVy9w4rzrmGgNm4iVTPwpSQlmdqNWnp6p9QyyPVfAZRgnf5XvSBZFk/A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2491,6 +2685,13 @@ packages: resolution: {integrity: sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ==} engines: {node: '>=20'} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + terminal-link@5.0.0: resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} engines: {node: '>=20'} @@ -3239,6 +3440,16 @@ snapshots: '@img/sharp-win32-x64@0.35.5': optional: true + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -3493,6 +3704,74 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.26.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.2 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3759,6 +4038,11 @@ snapshots: email-validator@2.0.4: {} + enhanced-resolve@5.26.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + environment@1.1.0: {} error-stack-parser-es@1.0.5: {} @@ -3944,39 +4228,88 @@ snapshots: lefthook-windows-arm64: 2.2.2 lefthook-windows-x64: 2.2.2 + lightningcss-android-arm64@1.32.0: + optional: true + lightningcss-android-arm64@1.33.0: optional: true + lightningcss-darwin-arm64@1.32.0: + optional: true + lightningcss-darwin-arm64@1.33.0: optional: true + lightningcss-darwin-x64@1.32.0: + optional: true + lightningcss-darwin-x64@1.33.0: optional: true + lightningcss-freebsd-x64@1.32.0: + optional: true + lightningcss-freebsd-x64@1.33.0: optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + lightningcss-linux-arm64-gnu@1.33.0: optional: true + lightningcss-linux-arm64-musl@1.32.0: + optional: true + lightningcss-linux-arm64-musl@1.33.0: optional: true + lightningcss-linux-x64-gnu@1.32.0: + optional: true + lightningcss-linux-x64-gnu@1.33.0: optional: true + lightningcss-linux-x64-musl@1.32.0: + optional: true + lightningcss-linux-x64-musl@1.33.0: optional: true + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + lightningcss-win32-arm64-msvc@1.33.0: optional: true + lightningcss-win32-x64-msvc@1.32.0: + optional: true + lightningcss-win32-x64-msvc@1.33.0: optional: true + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + 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 + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 @@ -3997,6 +4330,10 @@ snapshots: lru.min@1.1.5: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -4091,6 +4428,11 @@ snapshots: '@oxc-transform-react/binding-win32-ia32-msvc': 0.152.0 '@oxc-transform-react/binding-win32-x64-msvc': 0.152.0 + oxlint-tailwindcss@1.16.0: + dependencies: + '@tailwindcss/node': 4.3.3 + tailwindcss: 4.3.3 + oxlint@1.89.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.89.0 @@ -4289,6 +4631,10 @@ snapshots: has-flag: 5.0.1 supports-color: 10.2.2 + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + terminal-link@5.0.0: dependencies: ansi-escapes: 7.3.0 diff --git a/scripts/oxlint-boundaries.mjs b/scripts/oxlint-boundaries.mjs index 4690436..40a1600 100644 --- a/scripts/oxlint-boundaries.mjs +++ b/scripts/oxlint-boundaries.mjs @@ -7,6 +7,10 @@ const browserVoidImports = new Set([ "@void/react", "@void/react/pages-client", ]); +function isBrowserModule(filename) { + return ["/browser/", "/components/", "/use-cases/"].some((folder) => filename.includes(folder)); +} + export default { meta: { name: "strokes-boundaries" }, rules: { @@ -15,7 +19,7 @@ export default { create(context) { const filename = context.filename; const domain = filename.includes("/domain/"); - const browser = filename.includes("/browser/"); + const browser = isBrowserModule(filename); const page = filename.includes("/pages/") && !filename.endsWith(".server.ts"); function check(node) { const source = node.source; @@ -29,10 +33,10 @@ export default { const provider = target.includes("/providers/") || target.includes("/routes/") || target.endsWith(".server") || target.endsWith(".server.ts"); const platform = /^(?:node:|react(?:-dom)?(?:\/|$)|void(?:\/|$)|@void\/)/.test(name); const forbidden = domain - ? provider || target.includes("/browser/") || target.includes("/pages/") || platform || (!name.startsWith(".") && name !== "effect" && !name.startsWith("effect/")) + ? provider || isBrowserModule(target) || target.includes("/pages/") || platform || (!name.startsWith(".") && name !== "effect" && !name.startsWith("effect/")) : (browser || page) ? provider || isBuiltin(name) || name.startsWith("node:") || ((name === "void" || name.startsWith("void/") || name.startsWith("@void/")) && !browserVoidImports.has(name)) - : (filename.includes("/routes/") || filename.includes("/providers/")) && target.includes("/browser/"); + : (filename.includes("/routes/") || filename.includes("/providers/")) && isBrowserModule(target); if (forbidden) context.report({ node, messageId: "forbidden" }); } return { ImportDeclaration: check, ExportNamedDeclaration: check, ExportAllDeclaration: check, ImportExpression: check }; diff --git a/src/features/practice/SPEC.md b/src/features/practice/SPEC.md deleted file mode 100644 index 20dd142..0000000 --- a/src/features/practice/SPEC.md +++ /dev/null @@ -1,30 +0,0 @@ -# Practice - -Provide one local Hindi tracing exercise before automatic checking. The original -development letter has not received human content review and must say so visibly. -No completion, scoring, persistence, or pedagogical-validity claim belongs here yet. - -## Ownership - -- `domain/development-lesson.ts` owns original directed reference geometry and hints. -- `domain/lesson.ts` parses unknown content into a usable, branded lesson or a - distinct content failure. Points must be finite, in bounds, and nondegenerate. -- `pages/index.tsx` parses the checked-in content once and supplies its result. -- `browser/practice.tsx` renders the letter, shared SVG coordinate frame, live ink, - honest status, ordered Reveal hints, and labeled Clear/Retry controls. -- `browser/use-drawing.ts` owns one captured pointer and temporary React state. - Interrupted input is discarded; finished gestures survive layout changes. -- `browser/practice.css` owns the responsive white/cobalt drawing-frame system. - -## Behavior and checks - -Drawing maps browser coordinates through the SVG transform. A second pointer is -ignored. Release ends one gesture, including outside the pad. Cancellation, lost -capture, blur, hidden document, and layout changes stop unfinished input safely. -Clear and Retry reset ink and revealed hints. Reveal never accepts a stroke. -Attempts remain in memory and disappear when the page is left or refreshed. - -`tests/content/lesson.test.ts` checks parsing, authored ordering, and the unusable-content render. -`tests/browser/tracing.spec.ts` checks public ink/status/control behavior and -interruption recovery. Actual iPhone/Android and finger/stylus checks remain open. -Stroke matching and calibration follow only after the lesson-review dependency. diff --git a/tests/browser/fixtures.ts b/src/features/practice/__tests__/browser/fixtures.ts similarity index 100% rename from tests/browser/fixtures.ts rename to src/features/practice/__tests__/browser/fixtures.ts diff --git a/tests/browser/practice.spec.ts b/src/features/practice/__tests__/browser/practice.spec.ts similarity index 100% rename from tests/browser/practice.spec.ts rename to src/features/practice/__tests__/browser/practice.spec.ts diff --git a/tests/browser/tracing.spec.ts b/src/features/practice/__tests__/browser/tracing.spec.ts similarity index 100% rename from tests/browser/tracing.spec.ts rename to src/features/practice/__tests__/browser/tracing.spec.ts diff --git a/src/features/practice/__tests__/content/lesson.test.ts b/src/features/practice/__tests__/content/lesson.test.ts new file mode 100644 index 0000000..360e339 --- /dev/null +++ b/src/features/practice/__tests__/content/lesson.test.ts @@ -0,0 +1,103 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Practice } from "../../../../pages/index"; +import { expect, test } from "vitest"; +import { parseLesson } from "../../domain/lesson"; +import { DEVELOPMENT_LESSON } from "../../domain/development-lesson"; + +test("the original development lesson parses with honest review status and ordered strokes", () => { + const result = parseLesson(DEVELOPMENT_LESSON); + expect(result.kind).toBe("ready"); + if (result.kind !== "ready") + throw new Error("Development lesson did not parse"); + expect(result.lesson.glyph).toBe("ल"); + expect(result.lesson.pronunciation).toBe("la"); + expect(result.lesson.review).toBe("pending"); + expect(result.lesson.strokes).toHaveLength(3); +}); + +test("unusable content fails before interaction instead of becoming handwriting feedback", () => { + const first = DEVELOPMENT_LESSON.strokes[0]; + if (!first) throw new Error("Fixture needs a stroke"); + const invalid = [ + null, + { ...DEVELOPMENT_LESSON, id: " " }, + { ...DEVELOPMENT_LESSON, pronunciation: "" }, + { ...DEVELOPMENT_LESSON, glyph: "" }, + { ...DEVELOPMENT_LESSON, strokes: [] }, + ...[0, -1, NaN, Infinity].map((width) => ({ + ...DEVELOPMENT_LESSON, + bounds: { ...DEVELOPMENT_LESSON.bounds, width }, + })), + ...[ + [], + [{ x: 20, y: 30 }], + [ + { x: 20, y: 30 }, + { x: 20, y: 30 }, + ], + [ + { x: NaN, y: 30 }, + { x: 30, y: 40 }, + ], + [ + { x: -1, y: 20 }, + { x: 40, y: 20 }, + ], + [ + { x: 20, y: 20 }, + { x: 101, y: 20 }, + ], + ].map((points) => ({ + ...DEVELOPMENT_LESSON, + strokes: [{ ...first, points }], + })), + ]; + for (const input of invalid) { + expect(parseLesson(input).kind).toBe("unusable"); + } +}); + +test("the content boundary preserves the authored teaching order", () => { + const result = parseLesson({ + ...DEVELOPMENT_LESSON, + strokes: [ + { + hint: "Right to left", + points: [ + { x: 70, y: 20 }, + { x: 40, y: 20 }, + ], + }, + { + hint: "Down", + points: [ + { x: 10, y: 30 }, + { x: 10, y: 70 }, + ], + }, + ], + }); + expect(result.kind).toBe("ready"); + if (result.kind !== "ready") + throw new Error("Authored sequence did not parse"); + expect(result.lesson.strokes.map((stroke) => stroke.points[0])).toEqual([ + { x: 70, y: 20 }, + { x: 10, y: 30 }, + ]); +}); + +test("invalid content renders a distinct failure without learner controls or completion", () => { + const markup = renderToStaticMarkup( + createElement(Practice, { + content: parseLesson({ ...DEVELOPMENT_LESSON, strokes: [] }), + }), + ); + expect(markup).toContain('role="alert"'); + expect(markup).toContain("Lesson unavailable"); + expect(markup).toContain("Its content needs repair."); + expect(markup).not.toContain("Hindi writing practice area"); + expect(markup).not.toContain(" { + const directory = mkdtempSync(join(process.cwd(), ".fence-fixtures-")); + const probe = join(directory, "probe.tsx"); + try { + for (const [expression, rule] of [ + ['"timtim-invalid-class"', "no-unknown-classes"], + ['"flex block"', "no-conflicting-classes"], + ['"flex flex"', "no-duplicate-classes"], + ['`text-${size}`', "no-dynamic-classes"], + ['"flex gap-4 text-accent"', null], + ]) { + writeFileSync(probe, `export const Probe = ({ size }: { size: string }) =>
{size}
;`); + const result = spawnSync("./node_modules/.bin/oxlint", ["--deny-warnings", probe], { encoding: "utf8" }); + const output = result.stdout + result.stderr; + if (rule) { + assert.notEqual(result.status, 0, `${expression} must fail`); + assert.ok(output.includes(rule), output); + } else { + assert.equal(result.status, 0, output); + } + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test("the fence rejects invalid types, floating Effects and cross-boundary imports", () => { const directory = mkdtempSync(join(process.cwd(), ".fence-fixtures-")); const probe = join(directory, "domain", "probe.ts"); @@ -26,6 +52,9 @@ test("the fence rejects invalid types, floating Effects and cross-boundary impor 'import { adapter } from "../browser/adapter"; export const value = adapter;', 'export { adapter } from "../browser/adapter";', 'export const adapter = import("../browser/adapter");', + 'import { adapter } from "../components/adapter"; export const value = adapter;', + 'export { adapter } from "../use-cases/adapter";', + 'export const adapter = import("../components/adapter");', 'import { readFile } from "node:fs"; export const adapter = readFile;', ]) { writeFileSync(probe, source); @@ -40,7 +69,8 @@ test("the fence rejects invalid types, floating Effects and cross-boundary impor assert.match(result.stdout + result.stderr, /no-restricted-globals/); } mkdirSync(join(directory, "pages")); - for (const folder of ["browser", "pages"]) { + for (const folder of ["browser", "components", "use-cases", "pages"]) { + mkdirSync(join(directory, folder), { recursive: true }); const browserProbe = join(directory, folder, "probe.ts"); for (const name of ["void", "fs", "fs/promises", "path", "child_process", "node:fs", "void/handler", "void/kv", "void/storage", "@void/react/pages-server", "@void/react/plugin"]) { writeFileSync(browserProbe, `import * as dependency from "${name}"; export const value = dependency;`); @@ -54,6 +84,16 @@ test("the fence rejects invalid types, floating Effects and cross-boundary impor assert.equal(result.status, 0, `${folder} browser import ${name} must pass: ${result.stdout}${result.stderr}`); } } + for (const folder of ["routes", "providers"]) { + mkdirSync(join(directory, folder)); + for (const target of ["components", "use-cases"]) { + const serverProbe = join(directory, folder, "probe.ts"); + writeFileSync(serverProbe, `export { adapter } from "../${target}/adapter";`); + result = spawnSync("./node_modules/.bin/oxlint", ["--deny-warnings", serverProbe], { encoding: "utf8" }); + assert.notEqual(result.status, 0, `${folder} importing ${target} must fail`); + assert.match(result.stdout + result.stderr, /no-platform-in-domain/); + } + } writeFileSync(probe, 'import { Effect } from "effect"; export const value = Effect.succeed(1);'); result = lint(); assert.equal(result.status, 0, result.stdout + result.stderr); diff --git a/src/features/practice/browser/practice.css b/src/features/practice/browser/practice.css deleted file mode 100644 index 3a76006..0000000 --- a/src/features/practice/browser/practice.css +++ /dev/null @@ -1,65 +0,0 @@ -:root { - font-family: system-ui, sans-serif; - color: #15223b; - background: #edf1f6; - font-synthesis: none; - line-height: 1.5; - font-size: 16px; -} -* { box-sizing: border-box; } -body { margin: 0; } -button { font: inherit; } -.practice-app { max-width: 1120px; margin: 0 auto; padding: 0 32px 48px; } -.app-header { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #c9d3e3; } -h1 { margin: 0; font-size: 1.75rem; line-height: 1.15; font-weight: 700; letter-spacing: -.025em; } -.review-notice { margin: 0; padding: 8px 12px; background: #e3e9ff; color: #17349e; font-size: .875rem; border-radius: 6px; } -.practice-layout { display: grid; grid-template-columns: minmax(0, 460px) minmax(240px, 1fr); gap: 48px; padding-top: 16px; align-items: start; } -.lesson-identity { display: flex; align-items: center; gap: 24px; min-height: 64px; margin-bottom: 12px; } -.lesson-glyph { font-size: 4rem; line-height: 1; padding-right: 24px; border-right: 1px solid #c9d3e3; } -.pronunciation { font-size: 2rem; line-height: 1.15; } -.lesson-identity p { margin: 4px 0 0; font-size: .875rem; color: #526079; } -.pad-frame { position: relative; background: white; } -.pad-frame::before, .pad-frame::after { content: ""; position: absolute; z-index: 1; pointer-events: none; inset: -5px; background: linear-gradient(#2348d1,#2348d1) left top / 16px 3px no-repeat, linear-gradient(#2348d1,#2348d1) left top / 3px 16px no-repeat, linear-gradient(#2348d1,#2348d1) right bottom / 16px 3px no-repeat, linear-gradient(#2348d1,#2348d1) right bottom / 3px 16px no-repeat; } -.pad-frame::after { transform: scaleX(-1); } -.writing-pad { display: block; width: 100%; aspect-ratio: 1; border: 1px solid #c9d3e3; touch-action: none; user-select: none; color: #2348d1; overflow: hidden; } -.registration-grid { fill: none; stroke: #dce3ee; stroke-width: .2; stroke-dasharray: 1.2 1.2; } -.reference { fill: none; stroke: #c6d2f6; stroke-width: 2.8; stroke-linecap: round; stroke-linejoin: round; } -.reference.revealed { stroke: #2348d1; stroke-width: 1.6; } -.learner-ink, .preview-ink { fill: none; stroke: #2348d1; stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; } -.stroke-number circle { fill: #2348d1; } -.stroke-number text { font-family: system-ui, sans-serif; font-size: 4px; fill: white; } -.pad-instructions { margin: 20px 0 4px; text-align: center; font-size: .9375rem; } -.practice-status { display: block; min-height: 3em; margin: 0 0 12px; text-align: center; color: #526079; font-size: .9375rem; } -.practice-controls { display: grid; grid-template-columns: 1fr 1.3fr 1fr; gap: 12px; } -.practice-controls button { min-height: 48px; padding: 10px 16px; border: 1px solid #2348d1; border-radius: 8px; background: white; color: #2348d1; font-weight: 600; cursor: pointer; } -.practice-controls button:hover { background: #e3e9ff; } -.practice-controls button:active { background: #d3ddff; } -.practice-controls button.primary { background: #2348d1; color: white; } -.practice-controls button.primary:hover { background: #17349e; } -.practice-controls button:focus-visible { outline: 3px solid #15223b; outline-offset: 4px; } -.practice-controls button:disabled { opacity: .5; cursor: wait; } -.reference-rail { padding-top: 76px; max-width: 340px; } -h2 { font-size: 1.125rem; line-height: 1.35; margin: 0 0 8px; font-weight: 650; } -.rail-intro p { margin: 0; color: #526079; } -.reveal-instruction { margin: 24px 0; } -.stroke-list { list-style: none; padding: 0; margin: 24px 0; } -.stroke-list li { display: grid; grid-template-columns: 28px 1fr; gap: 12px; padding: 16px 0; border-top: 1px solid #c9d3e3; } -.sequence-number { display: grid; place-items: center; height: 28px; border-radius: 50%; color: white; background: #2348d1; font-weight: 650; } -.stroke-preview { width: 80px; height: 80px; display: block; } -.stroke-list p { margin: 8px 0 0; font-size: .9375rem; } -.lesson-review { margin: 24px 0 12px; color: #526079; font-size: .875rem; } -.privacy-note { border-top: 1px solid #c9d3e3; padding-top: 16px; color: #526079; font-size: .875rem; } -@media (max-width: 760px) { - .practice-app { padding: 0 20px 32px; } - .app-header { min-height: 80px; gap: 12px; } - h1 { font-size: 1.5rem; } - .review-notice { max-width: 190px; font-size: .75rem; padding: 6px 8px; } - .practice-layout { grid-template-columns: minmax(0, 1fr); gap: 32px; padding-top: 16px; } - .lesson-identity { min-height: 72px; gap: 20px; margin-bottom: 16px; } - .lesson-glyph { font-size: 3.5rem; padding-right: 20px; } - .pronunciation { font-size: 1.75rem; } - .reference-rail { padding-top: 0; } - .practice-controls { gap: 8px; } - .practice-controls button { padding: 10px 8px; } - .pad-instructions { margin-top: 16px; } -} diff --git a/src/features/practice/browser/practice.tsx b/src/features/practice/browser/practice.tsx deleted file mode 100644 index 76f5adf..0000000 --- a/src/features/practice/browser/practice.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useId, useState } from "react"; -import type { Lesson, LessonContent, Point } from "../domain/lesson"; -import { useDrawing } from "./use-drawing"; -import "./practice.css"; - -function pointsAttribute(points: readonly Point[]): string { - return points.map((point) => `${point.x},${point.y}`).join(" "); -} - -export function Practice({ content }: { readonly content: LessonContent }) { - if (content.kind === "unusable") { - return

Strokes

Lesson unavailable

{content.message}

Your writing has not been checked. Reload the page to try again.

; - } - return ; -} - -function TracingPractice({ lesson }: { readonly lesson: Lesson }) { - const { padRef, ready, attempt, reset: resetDrawing, abort: abortDrawing, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture } = useDrawing(); - const [revealed, setRevealed] = useState(false); - const arrowId = useId(); - const { x, y, width, height } = lesson.bounds; - const viewBox = `${x} ${y} ${width} ${height}`; - const count = attempt.strokes.length; - let feedback = revealed ? "Reference shown. Trace along it, or choose Retry for the faint guide." : "Ready to trace. Follow the faint guide."; - if (count > 0) feedback = `${count} ${count === 1 ? "stroke" : "strokes"} drawn. Keep tracing or start again.`; - if (attempt.feedback === "drawing") feedback = "Drawing… Lift your finger or pen to finish this stroke."; - if (attempt.feedback === "interrupted") feedback = "Stroke interrupted. Your finished strokes are safe. Try again."; - - if (!ready) feedback = "Preparing the writing area…"; - - function reset(): void { - resetDrawing(); - setRevealed(false); - } - - function reveal(): void { - abortDrawing(); - setRevealed(true); - } - - return ( -
-
-

Strokes

-

Development lesson — review pending

-
-
-
-
{lesson.glyph}
{lesson.pronunciation}

Hindi letter practice

-
- {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- SVG needs an image role while retaining drawing geometry. */} - - - - - {lesson.strokes.map((stroke, index) => )} - - - {attempt.strokes.map((stroke, index) => )} - {attempt.active && } - - {revealed && lesson.strokes.map((stroke, index) => {index + 1})} - -
-

Practice Hindi handwriting with your finger, pen, or mouse.

- {feedback} -
- - - -
-
- -
-
- ); -} diff --git a/src/features/practice/components/practice.tsx b/src/features/practice/components/practice.tsx new file mode 100644 index 0000000..a32852e --- /dev/null +++ b/src/features/practice/components/practice.tsx @@ -0,0 +1,282 @@ +import { useId, useState } from "react"; +import type { Lesson, Point } from "../domain/lesson"; +import { useDrawing } from "../use-cases/use-drawing"; + +function pointsAttribute(points: readonly Point[]): string { + return points.map((point) => `${point.x},${point.y}`).join(" "); +} + +export function TracingPractice({ lesson }: { readonly lesson: Lesson }) { + const { + padRef, + ready, + attempt, + reset: resetDrawing, + abort: abortDrawing, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onLostPointerCapture, + } = useDrawing(); + const [revealed, setRevealed] = useState(false); + const arrowId = useId(); + const { x, y, width, height } = lesson.bounds; + const viewBox = `${x} ${y} ${width} ${height}`; + const count = attempt.strokes.length; + let feedback = revealed + ? "Reference shown. Trace along it, or choose Retry for the faint guide." + : "Ready to trace. Follow the faint guide."; + if (count > 0) + feedback = `${count} ${count === 1 ? "stroke" : "strokes"} drawn. Keep tracing or start again.`; + if (attempt.feedback === "drawing") + feedback = "Drawing… Lift your finger or pen to finish this stroke."; + if (attempt.feedback === "interrupted") + feedback = "Stroke interrupted. Your finished strokes are safe. Try again."; + + if (!ready) feedback = "Preparing the writing area…"; + + function reset(): void { + resetDrawing(); + setRevealed(false); + } + + function reveal(): void { + abortDrawing(); + setRevealed(true); + } + + return ( +
+
+

+ Strokes +

+

+ Development lesson — review pending +

+
+
+
+
+ + {lesson.glyph} + +
+ + {lesson.pronunciation} + +

Hindi letter practice

+
+
+
+
+

+ Practice Hindi handwriting with your finger, pen, or mouse. +

+ + {feedback} + +
+ + + +
+
+ +
+
+ ); +} diff --git a/src/features/practice/domain/lesson.ts b/src/features/practice/domain/lesson.ts index add793e..9ff0eb5 100644 --- a/src/features/practice/domain/lesson.ts +++ b/src/features/practice/domain/lesson.ts @@ -1,44 +1,76 @@ import { Result, Schema } from "effect"; const FINITE = Schema.Number.check(Schema.isFinite()); -const LABEL = Schema.String.check(Schema.makeFilter((value) => value.trim().length > 0)); +const LABEL = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0), +); export const Point = Schema.Struct({ x: FINITE, y: FINITE }); export interface Point extends Schema.Schema.Type {} const REFERENCE_STROKE = Schema.Struct({ - hint: LABEL, - points: Schema.NonEmptyArray(Point).check(Schema.makeFilter((points) => { - const length = points.reduce((total, point, index) => { - const previous = points[index - 1]; - return previous ? total + Math.hypot(point.x - previous.x, point.y - previous.y) : total; - }, 0); - return length > 0 && Number.isFinite(length); - })), + hint: LABEL, + points: Schema.NonEmptyArray(Point).check( + Schema.makeFilter((points) => { + const length = points.reduce((total, point, index) => { + const previous = points[index - 1]; + return previous + ? total + Math.hypot(point.x - previous.x, point.y - previous.y) + : total; + }, 0); + return length > 0 && Number.isFinite(length); + }), + ), }); + const LESSON = Schema.Struct({ - id: LABEL, - glyph: LABEL, - pronunciation: LABEL, - review: Schema.Literal("pending"), - bounds: Schema.Struct({ x: FINITE, y: FINITE, width: FINITE, height: FINITE }), - strokes: Schema.NonEmptyArray(REFERENCE_STROKE), -}).check(Schema.makeFilter((lesson) => { - const { x, y, width, height } = lesson.bounds; - if (width <= 0 || height <= 0 || !Number.isFinite(x + width) || !Number.isFinite(y + height)) return false; - return lesson.strokes.every((stroke) => stroke.points.every((point) => - point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height, - )); -})).pipe(Schema.brand("Lesson")); + id: LABEL, + glyph: LABEL, + pronunciation: LABEL, + review: Schema.Literal("pending"), + bounds: Schema.Struct({ + x: FINITE, + y: FINITE, + width: FINITE, + height: FINITE, + }), + strokes: Schema.NonEmptyArray(REFERENCE_STROKE), +}) + .check( + Schema.makeFilter((lesson) => { + const { x, y, width, height } = lesson.bounds; + if ( + width <= 0 || + height <= 0 || + !Number.isFinite(x + width) || + !Number.isFinite(y + height) + ) + return false; + return lesson.strokes.every((stroke) => + stroke.points.every( + (point) => + point.x >= x && + point.x <= x + width && + point.y >= y && + point.y <= y + height, + ), + ); + }), + ) + .pipe(Schema.brand("Lesson")); + export interface Lesson extends Schema.Schema.Type {} export type LessonContent = - | { readonly kind: "ready"; readonly lesson: Lesson } - | { readonly kind: "unusable"; readonly message: string }; + | { readonly kind: "ready"; readonly lesson: Lesson } + | { readonly kind: "unusable"; readonly message: string }; export function parseLesson(input: unknown): LessonContent { - const result = Schema.decodeUnknownResult(LESSON)(input); - if (Result.isFailure(result)) { - return { kind: "unusable", message: "This lesson cannot be opened. Its content needs repair." }; - } - return { kind: "ready", lesson: result.success }; + const result = Schema.decodeUnknownResult(LESSON)(input); + if (Result.isFailure(result)) { + return { + kind: "unusable", + message: "This lesson cannot be opened. Its content needs repair.", + }; + } + return { kind: "ready", lesson: result.success }; } diff --git a/src/features/practice/browser/use-drawing.ts b/src/features/practice/use-cases/use-drawing.ts similarity index 100% rename from src/features/practice/browser/use-drawing.ts rename to src/features/practice/use-cases/use-drawing.ts diff --git a/src/pages/index.tsx b/src/pages/index.tsx new file mode 100644 index 0000000..3e749f6 --- /dev/null +++ b/src/pages/index.tsx @@ -0,0 +1,35 @@ +import "../styles.css"; +import { DEVELOPMENT_LESSON } from "../features/practice/domain/development-lesson"; +import { + type LessonContent, + parseLesson, +} from "../features/practice/domain/lesson"; +import { TracingPractice } from "../features/practice/components/practice"; + +const CONTENT = parseLesson(DEVELOPMENT_LESSON); + +export default function HomePage() { + return ; +} + +export function Practice({ content }: { readonly content: LessonContent }) { + if (content.kind === "unusable") { + return ( +
+

+ Strokes +

+
+

+ Lesson unavailable +

+

{content.message}

+

+ Your writing has not been checked. Reload the page to try again. +

+
+
+ ); + } + return ; +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..847ee34 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,23 @@ +@import "tailwindcss"; + +@theme { + --font-sans: system-ui, sans-serif; + --color-page: #edf1f6; + --color-ink: #15223b; + --color-muted: #526079; + --color-line: #c9d3e3; + --color-grid: #dce3ee; + --color-guide: #c6d2f6; + --color-accent: #2348d1; + --color-accent-dark: #17349e; + --color-accent-soft: #e3e9ff; + --color-accent-pressed: #d3ddff; + --breakpoint-desktop: 760.01px; +} + +@layer base { + html { + @apply bg-page font-sans text-base leading-normal text-ink; + font-synthesis: none; + } +} diff --git a/tests/content/lesson.test.ts b/tests/content/lesson.test.ts deleted file mode 100644 index 47ac423..0000000 --- a/tests/content/lesson.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { Practice } from "../../src/features/practice/browser/practice"; -import { expect, test } from "vitest"; -import { parseLesson } from "../../src/features/practice/domain/lesson"; -import { DEVELOPMENT_LESSON } from "../../src/features/practice/domain/development-lesson"; - -test("the original development lesson parses with honest review status and ordered strokes", () => { - const result = parseLesson(DEVELOPMENT_LESSON); - expect(result.kind).toBe("ready"); - if (result.kind !== "ready") throw new Error("Development lesson did not parse"); - expect(result.lesson.glyph).toBe("ल"); - expect(result.lesson.pronunciation).toBe("la"); - expect(result.lesson.review).toBe("pending"); - expect(result.lesson.strokes).toHaveLength(3); -}); - -test("unusable content fails before interaction instead of becoming handwriting feedback", () => { - const first = DEVELOPMENT_LESSON.strokes[0]; - if (!first) throw new Error("Fixture needs a stroke"); - const invalid = [ - null, - { ...DEVELOPMENT_LESSON, id: " " }, - { ...DEVELOPMENT_LESSON, pronunciation: "" }, - { ...DEVELOPMENT_LESSON, glyph: "" }, - { ...DEVELOPMENT_LESSON, strokes: [] }, - ...[0, -1, NaN, Infinity].map((width) => ({ ...DEVELOPMENT_LESSON, bounds: { ...DEVELOPMENT_LESSON.bounds, width } })), - ...[[], [{ x: 20, y: 30 }], [{ x: 20, y: 30 }, { x: 20, y: 30 }], [{ x: NaN, y: 30 }, { x: 30, y: 40 }], [{ x: -1, y: 20 }, { x: 40, y: 20 }], [{ x: 20, y: 20 }, { x: 101, y: 20 }]].map((points) => ({ ...DEVELOPMENT_LESSON, strokes: [{ ...first, points }] })), - ]; - for (const input of invalid) { - expect(parseLesson(input).kind).toBe("unusable"); - } -}); - - -test("the content boundary preserves the authored teaching order", () => { - const result = parseLesson({ ...DEVELOPMENT_LESSON, strokes: [ - { hint: "Right to left", points: [{ x: 70, y: 20 }, { x: 40, y: 20 }] }, - { hint: "Down", points: [{ x: 10, y: 30 }, { x: 10, y: 70 }] }, - ] }); - expect(result.kind).toBe("ready"); - if (result.kind !== "ready") throw new Error("Authored sequence did not parse"); - expect(result.lesson.strokes.map((stroke) => stroke.points[0])).toEqual([{ x: 70, y: 20 }, { x: 10, y: 30 }]); -}); - - -test("invalid content renders a distinct failure without learner controls or completion", () => { - const markup = renderToStaticMarkup(createElement(Practice, { content: parseLesson({ ...DEVELOPMENT_LESSON, strokes: [] }) })); - expect(markup).toContain('role="alert"'); - expect(markup).toContain("Lesson unavailable"); - expect(markup).toContain("Its content needs repair."); - expect(markup).not.toContain("Hindi writing practice area"); - expect(markup).not.toContain(" Date: Sat, 24 Oct 2026 18:05:59 +0530 Subject: [PATCH 11/22] refactor: split tracing practice into a feature slice Move the drawing session and practice UI out of the old practice module so the page composes the header, canvas, feedback, and controls. Co-authored-by: Cursor --- .oxfmtrc.json | 21 ++ .vscode/extensions.json | 3 + .vscode/settings.json | 9 + AGENTS.md | 12 +- GLOSSARY.md | 13 + README.md | 132 +------- SPEC.md | 137 --------- docs/adr/0001-assess-the-finished-letter.md | 3 + package.json | 5 +- pnpm-lock.yaml | 228 ++++++++++++++ scripts/oxlint-boundaries.mjs | 19 ++ src/features/practice/components/practice.tsx | 282 ------------------ src/features/practice/domain/lesson.ts | 76 ----- .../practice/use-cases/use-drawing.ts | 127 -------- .../__tests__/browser/fixtures.ts | 0 .../__tests__/browser/practice.spec.ts | 0 .../__tests__/browser/tracing.spec.ts | 0 .../__tests__/content/lesson.test.ts | 15 +- .../__tests__/content/session.test.ts | 97 ++++++ .../__tests__/fence.test.mjs | 4 + .../tracingPractice/domain/constraints.ts | 4 + .../tracingPractice/domain/lessons.ts | 36 +++ .../tracingPractice/domain/strokes.ts | 67 +++++ .../use-cases/tracing/components/Controls.tsx | 42 +++ .../tracing/components/Explainer.tsx | 68 +++++ .../use-cases/tracing/components/Feedback.tsx | 15 + .../use-cases/tracing/components/Header.tsx | 22 ++ .../tracing/components/TracingBox.tsx | 129 ++++++++ .../use-cases/tracing}/development-lesson.ts | 0 .../use-cases/tracing/helpers.ts | 43 +++ .../use-cases/tracing/index.ts | 113 +++++++ .../use-cases/tracing/session.ts | 90 ++++++ src/pages/index.tsx | 96 ++++-- 33 files changed, 1115 insertions(+), 793 deletions(-) create mode 100644 .oxfmtrc.json create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 GLOSSARY.md delete mode 100644 SPEC.md create mode 100644 docs/adr/0001-assess-the-finished-letter.md delete mode 100644 src/features/practice/components/practice.tsx delete mode 100644 src/features/practice/domain/lesson.ts delete mode 100644 src/features/practice/use-cases/use-drawing.ts rename src/features/{practice => tracingPractice}/__tests__/browser/fixtures.ts (100%) rename src/features/{practice => tracingPractice}/__tests__/browser/practice.spec.ts (100%) rename src/features/{practice => tracingPractice}/__tests__/browser/tracing.spec.ts (100%) rename src/features/{practice => tracingPractice}/__tests__/content/lesson.test.ts (84%) create mode 100644 src/features/tracingPractice/__tests__/content/session.test.ts rename src/features/{practice => tracingPractice}/__tests__/fence.test.mjs (95%) create mode 100644 src/features/tracingPractice/domain/constraints.ts create mode 100644 src/features/tracingPractice/domain/lessons.ts create mode 100644 src/features/tracingPractice/domain/strokes.ts create mode 100644 src/features/tracingPractice/use-cases/tracing/components/Controls.tsx create mode 100644 src/features/tracingPractice/use-cases/tracing/components/Explainer.tsx create mode 100644 src/features/tracingPractice/use-cases/tracing/components/Feedback.tsx create mode 100644 src/features/tracingPractice/use-cases/tracing/components/Header.tsx create mode 100644 src/features/tracingPractice/use-cases/tracing/components/TracingBox.tsx rename src/features/{practice/domain => tracingPractice/use-cases/tracing}/development-lesson.ts (100%) create mode 100644 src/features/tracingPractice/use-cases/tracing/helpers.ts create mode 100644 src/features/tracingPractice/use-cases/tracing/index.ts create mode 100644 src/features/tracingPractice/use-cases/tracing/session.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..8d1c98e --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,21 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "useTabs": true, + "ignorePatterns": [ + "node_modules", + ".agents", + ".claude", + ".void", + "dist", + "test-results", + "playwright-report" + ], + "overrides": [ + { + "files": ["*.css"], + "options": { + "useTabs": false + } + } + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..99e2f7d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["oxc.oxc-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9969ccf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "biome.enabled": false, + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "never" + } +} diff --git a/AGENTS.md b/AGENTS.md index 7c0e0b2..1ddd7ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ TypeScript web app using Effect. Follow the installed Effect version and existin ## Feature ownership -- Build self-contained vertical slices. Keep feature-specific domain code, use cases, services, controllers, UI, tests, and supporting code together. +- Build self-contained vertical slices. Keep feature-specific domain code, use cases, services, UI, tests, and supporting code together. - Removing functionality should mean deleting its feature module and removing minimal, explicit registration. Document unavoidable external dependencies or cleanup in the feature README. - Expose a small public API; do not import another feature's internals. Extract shared code only when it is genuinely shared. - Preserve applied database migrations; deleting code must not implicitly delete persisted data or external resources. @@ -22,19 +22,19 @@ src/features/installation/ domain/ # Business types, schemas, rules, and business errors use-cases/ # Business actions; coordinate domain rules and services services/ # Effect service contracts, implementations, and Layers - controller.ts # HTTP input, use-case calls, response/error mapping + src/main.ts # Compose Layers and start the app ``` -- Domain code must not depend on HTTP, databases, controllers, use cases, or service implementations. -- Controllers call use cases; use cases depend on service contracts and domain rules. Keep SQL and external API details in services. +- Domain code must not depend on HTTP, databases, use cases, or service implementations. +- Use cases depend on service contracts and domain rules. Keep SQL and external API details in services. - Keep each service's contract, implementation, and Layer together by default. Acquire external resources through Effect, never as import-time side effects. -- Use these names: `use-cases/`, `services/`, and `controller.ts`. +- Use these names: `use-cases/` and `services/`. ## Grow files incrementally - Aim for roughly 200 lines per file; this is a soft limit. -- Any module—feature, use case, service, controller, or utility—may start as one file. +- Any module—feature, use case, service, or utility—may start as one file. - As it grows, replace the file with a same-named folder containing `index.ts`. Extract the most semantically isolated concept into a descriptively named sibling file; repeat as needed. - Example: `utils.ts` → `utils/index.ts` + `utils/formatCurrency.ts`. - The index may retain cohesive implementation; it need not be only re-exports. Avoid redundant nested folders and arbitrary splits. diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000..dac7862 --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,13 @@ +# Hindi letter practice + +Practice writing a prompted Hindi letter and assess the finished drawing. + +## Language + +**Letter attempt**: +A learner's complete drawing of one prompted letter, potentially made with several strokes. +_Avoid_: Stroke when referring to the whole letter + +**Stroke**: +One uninterrupted drawing gesture, from touching the writing area to lifting the finger or pen. + diff --git a/README.md b/README.md index 9d5457f..a0f0f18 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,3 @@ -# Strokes +# TimTim A small Hindi handwriting practice app for mobile browsers. - -Status: local Void/React tracing preview and automated fence implemented. The original development letter is explicitly unreviewed. Stroke checking, calibration, content review, and real-device verification remain pending; no deployment exists. - -Use React and Void. Use rat-stack as the engineering reference: Effect for external work and typed failures, and compiler, lint, tests, and hooks for enforcement. The owner's explicit choices replace Foldkit with React and Alchemy with Void deployment. - -The implementation specification is [SPEC.md](SPEC.md), published as [GitHub issue #1](https://github.com/joshpitzalis/TimTim/issues/1). The owner confirmed the test seams and four-ticket breakdown on 2026-10-22. Implementation tickets [#2](https://github.com/joshpitzalis/TimTim/issues/2), [#3](https://github.com/joshpitzalis/TimTim/issues/3), [#4](https://github.com/joshpitzalis/TimTim/issues/4), and [#5](https://github.com/joshpitzalis/TimTim/issues/5) are labeled `ready-for-agent` and linked in dependency order; #2 can start immediately. - -The follow-on step 3 specification is [SPEC-LESSONS.md](SPEC-LESSONS.md): a small reviewed lesson catalog, then practice completion saved on this browser. It depends on the checked single-letter exercise and awaits confirmation of its proposed test seams before publication. - -## What the Skritter investigation found - -The open Chrome tab was readable through native accessibility. Chrome did not expose a debugging endpoint, so REA could not attach to the authenticated page. The study session was left unchanged. - -I downloaded the public scripts linked by `https://skritter.com/study` and ran REA 6.7.0 static analysis on `application.js`. REA parsed the selected bundle successfully: 401,723 AST nodes, zero parse failures. Its graph did not recover the bundle's internal modules. The following details come from focused manual inspection of the named `require.register` blocks in those same downloaded bytes, alongside REA's artifact evidence. - -| Question | Finding | Source in the bundle | -| --- | --- | --- | -| How does drawing work? | The widget collects points between pointer-down and pointer-up equivalents, smooths the visible line, and uses CreateJS canvas layers. | `components/prompt/canvas/StudyPromptCanvasComponent.js`, `enableInput`, `onInputMove` | -| Does it recognize any handwritten character? | This code checks input against candidates at the current position in the known character's stroke sequence. | `utils/recognizer.js`, `getResults`; `collections/PromptStrokeCollection.js` | -| What does it compare? | Initial direction, corner count, and distance between bounding-box centers. Parameters can skip individual checks. | `utils/recognizer.js`, `runChecks`, `checkAngle`, `checkCorners`, `checkDistance` | -| How are corners found? | A ShortStraw routine derives corners from the input points. | `models/PromptStrokeModel.js`, `updateCorners`; `utils/shortstraw.js` | -| Why does accepted writing look clean? | A successful attempt can animate a transformed reference shape into its target shape. A raw-input preference has a separate display branch. | `components/prompt/part-rune/StudyPromptPartRuneComponent.js`, `handlePromptCanvasInputUp` | -| Is only one reference sequence possible? | The stroke collection tracks several targets and keeps those most compatible with accepted strokes. | `collections/PromptStrokeCollection.js`, `getExpectedTargets` | - -These are static code findings from the legacy web bundle. They do not prove which branches ran in your session or describe Skritter's native apps. No trained recognizer was needed to explain this particular code path. - -REA evidence: `ev_ac4cc745a3f06625034762dfdcc80b0b49437d7c2367df61f361a94018d9353b`. -The source hashes, selection, and limitations are in [docs/research/rea-summary.json](docs/research/rea-summary.json). Full temporary evidence is at `/private/tmp/skritter-rea-evidence.json`. - -## The smallest useful implementation - -Build a trace-and-retry exercise before automatic grading. The first screen has one letter, a faint guide, a square writing area, Clear, Reveal, and Retry. Add Next when there is a second reviewed letter. - -Use SVG for this version. Store each pen-down-to-pen-up gesture as an ordered list of points, then render it as a polyline or path. A shared `viewBox` keeps the guide, input, and reference geometry aligned as the screen changes size. Canvas is also viable; SVG makes this small exercise easy to inspect. - -The flow is: - -```text -finger / mouse / pen - | - v -points in the writing area's coordinate system - | - v -visible ink -> release -> optional stroke check - | - accept or retry -``` - -Pointer Events support mouse, pen, and touch through one event family. Use pointer capture during a gesture. Apply `touch-action: none` only to the writing area. Handle cancellation and capture loss, accept only the active pointer, and keep page scrolling available outside the pad. Map client coordinates into SVG coordinates rather than assuming CSS pixels equal drawing coordinates. See [Pointer Events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events). - -## Data ownership - -- A checked-in lesson file owns the glyph label, pronunciation label, coordinate bounds, and ordered reference strokes. The content author is its writer. -- A reference stroke is a directed centerline: ordered points from its start to its end. The same points can drive tracing, hints, and checking. -- The React exercise state owns the current attempt, active pointer, and accepted-stroke position. Pointer handlers write it. Rendered paths are derived from that state. -- Reference and learner coordinates share fixed bounds. Preserve position and scale relative to the letter; independently centering each stroke would accept strokes in the wrong place. -- Attempts are temporary in the first slice. There is no database, localStorage history, or progress claim to maintain. - -An ordinary font can supply a visual glyph, but its filled outlines do not provide a teaching sequence or directed pen paths. Author a few original templates, preferably by recording a Hindi teacher drawing each letter. Review pen lifts, direction, the headline, and acceptable variations before calling the app a writing teacher. - -Start with isolated letters. Devanagari vowel signs and conjuncts require more than treating a word as unrelated Unicode code points. See [W3C Devanagari requirements](https://www.w3.org/International/ilreq/devanagari/). - -## Add checking after tracing works - -Check one expected stroke locally after release. Keep the geometry function pure and independent from React, Void, and storage. - -1. Reject empty, non-finite, and degenerate input before scoring. -2. Resample input and reference by distance along each path, so sampling speed has less influence. -3. Compare corresponding positions, start and end locations, and path length. Direction matters; a reversed stroke should produce a retry. -4. Derive tolerances from recorded finger and stylus attempts. Do not copy Skritter's pixel thresholds into a different coordinate system. -5. On acceptance, show the reference stroke and advance. On rejection, retain the same expected stroke and show a useful hint. - -This is a proposed simple matcher, not a claim that Skritter uses this exact algorithm. It is sufficient to experiment with known letters. It does not recognize arbitrary handwriting or grade penmanship. - -Useful checks include equivalent resampling of the same path, resize invariance, reversed paths, misplaced strokes, scribbles, dots, and cancelled gestures. A browser check must prove that drawing does not scroll the page, clearing works, and a cancelled gesture does not advance. Try a real iPhone Safari and Android Chrome before claiming mobile validation. - -## Build order - -1. Scaffold Void with React and pnpm, retaining its generated version pins. Read installed Void and Effect instructions. Install and prove the rat-stack compiler, lint, and hook fence before feature code. -2. Implement the tracing screen with one reviewed letter. Completion means a real phone can draw, clear, reveal, and retry without stuck gestures. -3. Add the pure matcher with meaningful geometry tests. Completion means recorded good and bad attempts have useful outcomes. -4. Expand to a handful of reviewed letters. Completion means each template has an agreed teaching sequence. -5. Add saved progress only when needed. Define its authoritative store and writer first, then follow rat-stack's store and capability patterns. - -The Void [quickstart](https://void.cloud/guide/quickstart) documents `pnpm add -D void` followed by `pnpm void init`. Choose React and skip deployment setup initially. Exact package pins must be established from the actual scaffold and rat-stack reference at implementation time. - -## How rat-stack changes this design - -React owns rendering and temporary interaction state. Keep browser drawing APIs in the browser. Pure geometry stays ordinary deterministic TypeScript. Effect owns external operations, resource lifetimes, schema parsing, and typed failures as those needs arise. Avoid creating an Effect runtime per pointer movement. - -Before adding a meaningful lifecycle with asynchronous work, follow [add-a-lifecycle-machine](https://ratstack.sh/skills/add-a-lifecycle-machine). Before adding server actions, follow [add-a-capability](https://ratstack.sh/skills/add-a-capability): shared Schema contracts and one handler, with thin framework adapters. A retry from the learner is a product outcome; a missing lesson or storage failure is a distinct failure. - -Keep the [fence](https://ratstack.sh/systems/fence): strict type checking with Effect diagnostics, import boundaries, lint, pre-commit checks, and CI. Run the scaffold's equivalent of `pnpm turbo run check test build`; the script does not exist in this folder yet. Use [keep-or-cut](https://ratstack.sh/skills/keep-or-cut) to omit unrelated auth, analytics, MCP, code-mode, and database features. - -### Deployment ownership - -The owner selected Void instead of Alchemy. Void owns resource provisioning and deployment. Follow its [deployment guide](https://void.cloud/guide/deployment) and keep one owner for each cloud resource. - -Start with local development and no cloud data resources. Keep rat-stack's checks and boundary patterns, adapted to the Void project. No deployment has been performed. - -## Intentionally deferred - -Accounts, email, cloud persistence, spaced repetition, whole-word recognition, OCR, model training, and polished stroke morphing. No Skritter code or character dataset is part of the proposed app. - -## Local development and verification - -Use Node 24.21.0 or newer and the package-manager version pinned in the manifest. - -```sh -pnpm install --frozen-lockfile -pnpm exec playwright install chromium -pnpm dev -``` - -The app is the actual Void 1.2.0 React Static Pages starter, without database or deployment setup. Void's page entry renders the browser practice module. Lesson content and pure geometry belong under the practice domain; the tracing UI belongs under `components/` and its browser drawing hook under `use-cases/`. Provider adapters and server routes cannot be imported into either domain or browser code. There is no server mutation or cloud state. - -Tailwind utilities own the practice styles. `src/styles.css` imports Tailwind and defines the shared theme; `oxlint-tailwindcss` rejects unknown, conflicting, duplicate, dynamic, and deprecated classes. - -The generated `dev`, `build`, `preview`, and `postinstall` commands are retained. The enforcement scripts are: - -- `pnpm check`: Void type generation, strict TypeScript with patched Effect diagnostics, and Oxlint including import boundaries. -- `pnpm test`: intentional compiler/import failure probes, lesson-content checks, and complete-page Playwright checks. -- `pnpm build`: Void's client and Worker build, without publishing. - -`prepare` patches the pinned TypeScript 7.0.2 compiler using compatible `@effect/tsgo` 0.56.0 and installs Lefthook. Every commit runs check and test; CI installs from the frozen lockfile, installs Chromium, then runs check, test, and build. Do not bypass hooks. The fence regression test temporarily introduces invalid types, a floating Effect, and forbidden imports, requires each to fail, and removes the probes afterward. - -Oxlint's `jsx-a11y/prefer-tag-over-role` has one line-specific exception because it incorrectly demands that the accessible SVG writing pad become an HTML image; the pad needs SVG geometry and an explicit image role. Other accessibility correctness rules remain enabled. The tracing preview supports local ink, ordered Reveal hints, Clear, and Retry. It provides no automatic grading or completion claims. - -Browser checks cover live ink, separate gestures, cancellation, capture loss, blur, resize, layout changes, secondary pointers, release outside, and repeated controls. Phone and desktop viewport checks prove that the pad stays square. This is emulation, not physical-device validation. No deployment or real-phone verification has been performed. diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index c9fd159..0000000 --- a/SPEC.md +++ /dev/null @@ -1,137 +0,0 @@ -# Strokes: basic Hindi handwriting practice in a mobile browser - -Status: approved specification published as [GitHub issue #1](https://github.com/joshpitzalis/TimTim/issues/1) with `ready-for-agent`. The owner confirmed the four-ticket breakdown and test seams on 2026-10-22: complete-exercise browser checks, pure matcher geometry properties, and lesson-content boundary checks. Implementation and device verification remain outstanding. - -## Problem Statement - -The learner wants to practice writing Hindi characters using a phone browser. Skritter demonstrates the desired interaction: write a character directly on screen, receive guidance, and try again. - -The learner is a TypeScript developer who wants a small, understandable starting point. The first version must make handwriting practice useful without requiring native apps, trained handwriting models, accounts, or a complete language-learning platform. - -## Solution - -Build Strokes as a mobile-friendly web app using Void, React, and TypeScript. The learner opens a lesson, sees a Hindi letter and pronunciation label, and draws in a square writing area with a finger, stylus, or mouse. - -Provide a faint tracing guide, a way to reveal the expected writing, and simple controls to clear and retry. Add forgiving checks of one expected stroke at a time. An accepted stroke advances the exercise; an unsuccessful stroke leaves the learner on the same step with a useful hint. Completing every expected stroke produces a clear completion state and an option to practice again. - -Start with one original, reviewed letter template. Prove drawing and retrying before introducing automatic checking. The full scope of this specification includes that first letter's checked exercise. Further letters can use the same lesson format after content review. - -## User Stories - -1. As a Hindi learner, I want to open practice in my mobile browser, so that I can practice without installing a native app. -2. As a Hindi learner, I want to begin without creating an account, so that I can try the exercise immediately. -3. As a Hindi learner, I want to see which letter I am practicing, so that I understand the exercise. -4. As a Hindi learner, I want a pronunciation label beside the letter, so that I can connect its written form with its sound. -5. As a beginning learner, I want a faint reference in the writing area, so that I know where to place my strokes. -6. As a beginning learner, I want to see the expected stroke sequence, so that I can learn how to construct the letter. -7. As a learner, I want a hint for the next stroke, so that I can continue when I am unsure. -8. As a phone user, I want to draw with my finger, so that I can practice using the device I already have. -9. As a stylus user, I want to draw with my pen, so that I can practice with more precise input. -10. As a desktop user, I want to draw with a mouse, so that I can use the same exercise on my computer. -11. As a learner, I want ink to appear as I move, so that drawing feels responsive. -12. As a learner, I want each pen lift to finish one stroke, so that separate gestures remain separate. -13. As a phone user, I want drawing inside the pad to avoid scrolling the page, so that I can finish a stroke reliably. -14. As a phone user, I want to scroll outside the pad, so that the rest of the page remains usable. -15. As a phone user, I want an accidental second finger to leave my current stroke intact, so that it does not corrupt my attempt. -16. As a learner, I want interrupted input to stop cleanly, so that returning to the exercise does not leave it drawing indefinitely. -17. As a learner, I want the letter and my input to remain aligned when the pad changes size, so that resizing does not change the exercise. -18. As a learner, I want a Clear control, so that I can discard my current attempt and start from the first stroke. -19. As a learner, I want to reveal the expected letter and sequence, so that I can compare it with my attempt. -20. As a learner, I want revealing the answer to remain distinct from completing the exercise, so that I receive honest feedback. -21. As a learner, I want each completed gesture checked against the next expected stroke, so that feedback relates to the step I am practicing. -22. As a learner, I want reasonable variation in finger drawing to be accepted, so that small inaccuracies do not prevent practice. -23. As a learner, I want stroke direction and placement to matter, so that a reversed or misplaced gesture does not teach me the wrong movement. -24. As a learner, I want unsuccessful strokes to leave the exercise on the same step, so that I can retry without restarting the whole letter. -25. As a learner, I want a useful next-stroke hint after an unsuccessful attempt, so that I know what to try next. -26. As a learner, I want accepted strokes to remain visible, so that I can see the letter taking shape. -27. As a learner, I want a clear completion message after the final accepted stroke, so that I know when I have finished. -28. As a learner, I want Retry to reset the exercise, so that I can repeat the same letter. -29. As a learner, I want readable feedback and labeled controls, so that color or unfamiliar icons are not my only instructions. -30. As a keyboard user, I want to operate the surrounding controls with visible focus, so that navigation and retry remain accessible. -31. As a learner, I want a clear message if lesson content cannot be used, so that an app failure is not presented as a mistake in my writing. -32. As a learner, I want my handwriting to stay in the current browser session, so that this basic exercise does not upload or retain my attempts. -33. As a lesson author, I want to define original reference strokes in their teaching order, so that guidance and checking use the same reviewed content. -34. As a lesson author, I want invalid lesson geometry detected before it becomes a usable exercise, so that learners do not encounter broken templates. -35. As the developer, I want geometry separated from rendering and external work, so that I can understand and test the matcher independently. -36. As the developer, I want compiler, lint, and automated checks to enforce the project boundaries, so that future changes follow the chosen architecture. - -## Implementation Decisions - -- **Framework and deployment:** Use Void with React and TypeScript. Void owns deployment and any eventual cloud provisioning. Alchemy and Foldkit are excluded by explicit owner decisions. Follow Void's pnpm quickstart and begin locally without configuring a production deployment. -- **Engineering reference:** Retain rat-stack's source-first workflow, exact dependency pins, strict types, explicit ownership, typed boundaries, and enforceable fence. Adapt these patterns to Void and React rather than copying unrelated reference-app features. -- **Delivery sequence:** Establish the scaffold and fence, prove the single-letter tracing interaction, then add stroke checking. These are implementation milestones within this specification. A tracing-only milestone must not be reported as a completed checked exercise. -- **Lesson content:** A lesson has an identity, glyph label, pronunciation label, fixed coordinate bounds, and a nonempty ordered sequence of reference strokes. Each stroke contains a directed centerline represented by finite ordered points. -- **Content authority:** Checked-in original lesson data is the authoritative teaching sequence. The content author is its writer. The same reference points produce guides, hints, and matcher inputs. Ordinary font outlines are not a substitute for stroke-order data. -- **Content readiness:** Begin with one Hindi letter whose shape, pen lifts, direction, headline treatment, and teaching sequence have been reviewed by a competent Hindi writer or teacher. The specific letter and accepted sequence are not selected in this conversation. Content review is a dependency for claiming pedagogical validity; an unreviewed development fixture must be identified as such. -- **Drawing surface:** Use SVG with one shared coordinate system for reference and learner strokes. Convert browser input coordinates into that system. Keep the visible pad responsive and square. -- **Input ownership:** Track one active pointer. Capture it during a gesture, collect its points, and finish on release. Ignore other pointers during that gesture. Cancellation or lost capture discards the unfinished stroke without advancing or generating a handwriting failure. Releasing outside the pad must terminate the gesture safely; a path leaving the exercise bounds must not be accepted through coordinate clamping. -- **Mobile interaction:** Disable browser touch gestures only inside the drawing surface. Preserve normal page behavior elsewhere. Clear active input safely if layout changes during a stroke, while retaining completed strokes in lesson coordinates. -- **Transient state:** React owns the active pointer, unfinished stroke, accepted-stroke position, visible feedback, reveal state, and completion state. Rendered geometry is derived from that state. Clearing or retrying resets the attempt, feedback, and completion state to the first expected stroke. -- **Presentation:** Show the letter, pronunciation label, faint guide, writing pad, and labeled Clear, Reveal, and Retry controls. Revealing exposes the reference and ordered stroke hints without accepting strokes or marking completion. Hint presentation can be static; animation is not required. -- **Matching boundary:** A pure local geometry operation receives the completed input stroke and the next reference stroke. It returns an explicit accepted-or-retry outcome. It has no dependency on React, Void, storage, or network access. -- **Matching method:** Resample both paths by distance along each path. Compare corresponding positions, start and end locations, and path length. Preserve placement and scale relative to the whole letter. Do not independently recenter or resize individual strokes. Reject invalid and degenerate input before scoring; the first lesson must not require dot-like strokes. -- **Calibration:** Establish tolerances using recorded finger and stylus attempts. Thresholds are hypotheses until calibrated. Do not reuse Skritter's numeric thresholds or invent a recognition-accuracy claim. Preserve representative good and bad attempts as original fixtures. -- **Feedback:** Acceptance commits the reference stroke and advances exactly once. A retry does not advance or erase previously accepted strokes; remove the rejected transient ink and show a next-stroke hint. A clear textual completion state appears only after all reference strokes have been accepted. -- **Failure semantics:** An ordinary mismatch is a learner retry outcome. An unusable lesson or application failure is a separate failure state and cannot count as either successful writing or a handwriting mistake. -- **Effect boundary:** Keep interactive input and deterministic geometry local. Use Effect for external work, schema parsing at trust boundaries, typed failures, and resource lifetimes where required. Avoid a runtime invocation for each movement event. Follow the installed Effect version's instructions before implementation. -- **Server boundaries:** The first exercise needs no server mutation, database, or grading endpoint. If external lesson loading becomes necessary, keep transport in an adapter and expose failures explicitly. Any future server action follows rat-stack's shared-contract and handler pattern rather than duplicating domain behavior in Void routes. -- **Persistence and privacy:** Attempts exist only in memory for the current exercise. Refreshing or leaving may discard them. There is no attempt upload, durable progress, analytics stream, or hidden second store. -- **Accessibility:** Use semantic buttons, visible keyboard focus, readable labels, and text feedback in addition to color. The drawing interaction requires a pointing device in this version; keyboard handwriting input is outside scope. Label the pad and provide adjacent instructions for assistive technology. -- **Fence:** Establish exact pins, strict TypeScript checks, Effect diagnostics where applicable, import-boundary lint, meaningful tests, commit hooks, and CI before feature work. Enforce browser/server separation and keep provider adapters out of domain geometry. Preserve failing checks and report architectural violations explicitly. - -## Testing Decisions - -- **Seam confirmation:** Owner confirmed browser tests at the complete exercise boundary, focused geometry properties at the pure matcher boundary, and lesson-content boundary checks on 2026-10-22. No existing application test seam exists in this documentation-only project. -- **Primary seam:** Exercise the visible React page through browser pointer input and controls. Assert rendered strokes, feedback, progression, and completion. Avoid assertions about component internals, hook calls, event-handler names, or SVG implementation structure. -- **End-to-end behavior:** Prove a valid stroke advances once, a retry preserves the current step, revealing does not complete the exercise, and the final accepted stroke produces completion. Clear and Retry must return the exercise to its initial usable state. -- **Input robustness:** Cover interrupted input, capture loss, release outside the pad, a second pointer, resize during drawing, and repeated controls. After cancellation, the next valid gesture must work without refreshing. -- **Geometry seam:** Test matcher behavior using generated paths and original recorded examples. Check sampling-density invariance, consistent coordinate mapping across display sizes, wrong direction, misplaced strokes, excessive path length, degenerate input, and non-finite coordinates. Do not assert the implementation's chosen intermediate values or duplicate its scoring formula in tests. -- **Content boundary:** Check that unusable lesson content is rejected before interaction and that the learner sees a content failure rather than handwriting feedback. Validate the actual initial lesson, including its stroke order and coordinate bounds. -- **Meaningful properties:** Use rat-stack's preference for property and model tests where they fit. Demonstrate that a relevant intentional defect makes a new property fail before trusting it. Add example tests at browser and content seams where a concrete case communicates the behavior better. -- **Prior art:** There are no existing Strokes application tests to extend. Rat-stack's fence, Effect test conventions, and behavior-testing guidance are references, not evidence that Strokes already conforms. Select exact tooling from the scaffold and retain its version compatibility. -- **Physical devices:** Verify finger drawing, scroll behavior, pad sizing, control usability, and interruption recovery on an actual iPhone with Safari and Android phone with Chrome. Browser emulation supports development but does not establish real-device success. -- **Completion evidence:** Report automated checks, device checks, and any deployed smoke test separately. A successful build does not prove calibrated matching or sound Hindi instruction. Production publication is not required to finish the local exercise implementation. - -## Out of Scope - -- Accounts, authentication, email, subscriptions, and payment. -- Saved progress, cross-device sync, databases, and attempt history. -- Spaced repetition, due-item scheduling, streaks, leaderboards, and analytics. -- Full Hindi alphabet coverage, a curriculum, and a required fixed-size lesson catalog. -- Whole words, conjuncts, vowel-sign composition exercises, and arbitrary handwriting recognition. -- OCR, machine-learning training, AI grading, and penmanship scores. -- Multiple acceptable stroke sequences for each letter in the initial lesson. -- Dot-like reference strokes in the first lesson; these need a separate matching policy. -- Audio playback, speech synthesis, pronunciation assessment, and example sentences. -- Native mobile apps, offline installation, and service-worker caching. -- Polished stroke morphing, elaborate animations, and reproduction of Skritter's branding or layout. -- Reusing Skritter's implementation or proprietary character data in the product. -- Foldkit, Alchemy, MCP interfaces, code-mode interfaces, and unrelated rat-stack sample features. -- Creating a production cloud account or deploying the app as part of this specification-writing task. - -## Further Notes - -The Skritter investigation supports the known-character, next-stroke approach. REA statically parsed its public legacy web bundle, and focused manual inspection identified point collection, candidate checks, and reference-shape rendering. The live authenticated recognition path was not exercised. These observations inform this design; they do not validate the proposed matcher. - -The original investigation's REA evidence ID is `ev_ac4cc745a3f06625034762dfdcc80b0b49437d7c2367df61f361a94018d9353b`. The project retains its source hashes and coverage limitations in the research summary. - -Implementation references: - -- [Void quickstart](https://void.cloud/guide/quickstart) -- [Void deployment](https://void.cloud/guide/deployment) -- [Rat-stack agent guide](https://ratstack.sh/llms.txt) -- [Rat-stack fence](https://ratstack.sh/systems/fence) -- [Rat-stack keep-or-cut guidance](https://ratstack.sh/skills/keep-or-cut) -- [Pointer Events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) -- [Devanagari layout requirements](https://www.w3.org/International/ilreq/devanagari/) - -Delivery is the approved sequence: local practice shell and fence; tracing one reviewed letter; checked strokes and completion; calibration and real-phone verification. Apply `ready-for-agent` to the specification and implementation tickets. Work only on tickets whose blockers are complete. Lesson review, calibration, and device verification remain explicit completion gates. - -## Published implementation tickets - -All four tickets carry `ready-for-agent` and are sub-issues of the specification. Native GitHub blocking relationships enforce this sequence: - -1. [#2: Run the local practice shell with enforced boundaries](https://github.com/joshpitzalis/TimTim/issues/2) — no blockers; can start immediately. -2. [#3: Trace, reveal, and retry one reviewed Hindi letter](https://github.com/joshpitzalis/TimTim/issues/3) — blocked by #2. -3. [#4: Check each stroke and complete the letter](https://github.com/joshpitzalis/TimTim/issues/4) — blocked by #3. -4. [#5: Calibrate checking and verify practice on real phones](https://github.com/joshpitzalis/TimTim/issues/5) — blocked by #4. diff --git a/docs/adr/0001-assess-the-finished-letter.md b/docs/adr/0001-assess-the-finished-letter.md new file mode 100644 index 0000000..4d82867 --- /dev/null +++ b/docs/adr/0001-assess-the-finished-letter.md @@ -0,0 +1,3 @@ +# Assess the finished letter + +Assess the learner's finished letter attempt rather than compliance with a prescribed stroke sequence. The owner's goal is writing the correct letter within an acceptable margin of error; stroke order, drawing direction, and number of pen lifts are not assessment criteria. This replaces the next-stroke assessment direction in the existing specification; the matching policy, submission interaction, and acceptable variation still require decisions before implementation. diff --git a/package.json b/package.json index 1ce74fc..64f36f1 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "check": "pnpm typecheck && pnpm lint", "typecheck": "void prepare && tsc --noEmit", "lint": "oxlint --deny-warnings", + "format": "oxfmt", + "format:check": "oxfmt --check", "test": "pnpm test:fence && pnpm test:content && pnpm test:browser", "test:content": "vitest run", "test:browser": "playwright test", "prepare": "effect-tsgo patch && lefthook install", - "test:fence": "node --test src/features/practice/__tests__/fence.test.mjs" + "test:fence": "node --test src/features/tracingPractice/__tests__/fence.test.mjs" }, "dependencies": { "effect": "4.0.5", @@ -33,6 +35,7 @@ "fast-check": "4.10.2", "lefthook": "2.2.2", "oxc-transform-react": "0.152.0", + "oxfmt": "0.74.0", "oxlint": "1.89.0", "oxlint-tailwindcss": "1.16.0", "tailwindcss": "4.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e09814..577ff94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: oxc-transform-react: specifier: 0.152.0 version: 0.152.0 + oxfmt: + specifier: 0.74.0 + version: 0.74.0 oxlint: specifier: 1.89.0 version: 1.89.0 @@ -1329,6 +1332,128 @@ packages: cpu: [x64] os: [win32] + '@oxfmt/binding-android-arm-eabi@0.74.0': + resolution: {integrity: sha512-xxQwrf5EPI5zxvuCTW8r6tzCTjPQ+h6+HqJ0iRKRy2zS1yBllt/xLVjoIyUc+FnR7g0a5g1SZv2yyDYhTItLbA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.74.0': + resolution: {integrity: sha512-FJW3sATVvrWbV64al8xofCPZj4500Z/1W0ad7Tg1rcCUyh0EMbuSrsf3jsZS7CEbwQ5fQ0mRhmkc129GFgofSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.74.0': + resolution: {integrity: sha512-TfmNKBD30dGyMu9GCLiaw2rt/z7fms9LS+5cKcNOyWst0QgAs8oZs6wPxMbVaaDVE0/PCdCosgWM1vbu6f5NUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.74.0': + resolution: {integrity: sha512-5Usf8+DfC4Q3o/MD8FkN47GrI6y0KbhQ4KhZ2HGzMZcXjcW1AeldwbUn51aKih+1TVuJFF9qkNm6X1nzm6NYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.74.0': + resolution: {integrity: sha512-eL+1O3gykw1XihbZh2MRAciQvU2988LByrnjhyo/dmNG74X4oZ7XzigRoWPRFLfJCp8hzRkp/LbEhwe7uuT2eA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.74.0': + resolution: {integrity: sha512-Xt0QtO2hJ4stnul8IL4LJGVLyp0xnc6ccN9fZ30yuubv8FWSRYxWwd1Za62KsEqAfmPSazoS/cLSUUJpja6npA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.74.0': + resolution: {integrity: sha512-/4wYQ0gZl4vUR0QoBTGaay73FmQ1GAjUa9z9101TR2xbGNrg4a+grazlR0yzEOe3/E+PJr5BGfj66MMzm/54vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.74.0': + resolution: {integrity: sha512-846bKzF+HJd+JkOFh3rxzmYvSq4Cm+GMT+p+mW7S13ZYo6GAAGv8bL8cAOi/XcdeMqxEJDCIMTVQLVBXHMCdig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.74.0': + resolution: {integrity: sha512-nI5FJwZ4Flo6CBdscpwxymZXxO3m3IErQJX55ZS6h+ObfGXu8FostL85L/RWPFaCLnIVE4/WfLw+2q8XvVEeUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.74.0': + resolution: {integrity: sha512-BoQYKuqUdsKAFAkK6DLBvkdv4+t0hppSm79sB0wIcE2o/eOwNj+6Z3OlKY4q3Eta/tlssP65FaS9vHn8TeF7vQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.74.0': + resolution: {integrity: sha512-FSroRrM12+jCdSQklF9aOxsKpI6wFs4ZCo/1cSYG80RWZS6z4LV+l9tXRMT8lDGu+yohIZBwi9M76FzGPFDE2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.74.0': + resolution: {integrity: sha512-0VLQD0dhGBLY9Ik/w+otMvD81D0YXi4VDwql65macV+O9UzmSWmUr6CKf+dMtysDeC0PnG3y6kBkRO08BJx52g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.74.0': + resolution: {integrity: sha512-6X7BbZNz34+OHM6Oqjf6TRcUyRn6aRcB4VtIDZAF/uQRyeVhg+G2hv6MlMvmnRekVSyTJ/8rG/u8W2dRFW6glg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.74.0': + resolution: {integrity: sha512-4kYIQK53yJXrxwxSzx8H2bojlf6/KezLGYLRXzV9QCCkoiCsvBTCid9Bg83dUPTLPa3g9K0730f6MKDbck1+OA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.74.0': + resolution: {integrity: sha512-MfWz4wDREjp/JQDmuxtKieix4FHGa1z5rBvB3betdcPwZvf5fqeQrlCm1mmoyuUVQOh9Pt3M61HwN94kJCxw7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.74.0': + resolution: {integrity: sha512-6rmAApkq5s1kbDjvNI6Nl5A+JqQkLORb2sR941pf5HWsrd01WGKyagK/B4vX9+kh1FfvRkNgEu59FHvVk4H8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.74.0': + resolution: {integrity: sha512-+5SpRUnZNGeXTyumHnpHa791tQtt04qZs076WT8eTcGF2OJ631VLnf7S0kQSPuDG+PtJM0phadEB2WAr5n1w9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.74.0': + resolution: {integrity: sha512-1Fg+wYBO3Dsti1QX+/6EYHjeQzLC84tLOAqPu/r7ah3UJlUs9C/DB703Ay9Vcg+8VMzqZA2P5VGhzJraVUD4OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.74.0': + resolution: {integrity: sha512-y9jXUgPBm2TZIWbQhMYRRYAQM57+r4fbykgc+j24ICIIZl+pcuoNhnq1CRGm3oFiHnEUnZO00qOtSPGQdJ2ziQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint/binding-android-arm-eabi@1.89.0': resolution: {integrity: sha512-Pflpba5F3gRAQ12sWglRxvWTHEdAusNDvmi4Y5N1T5SY1AGkM7AixOnET8qkrnLJ/F32qAlNokO6eCknT8tmnw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2497,6 +2622,22 @@ packages: resolution: {integrity: sha512-CAx+SKDU4uSOmQnyn7daMj/v/h8tF3fnaUMi4yImKAARPnXV0BieHe/qw1LTcK4KlnIuAhpfUJfSfNKIBh8HqQ==} engines: {node: ^20.19.0 || >=22.12.0} + oxfmt@0.74.0: + resolution: {integrity: sha512-G4OP7FgwGYZR5ttRpCOOfJdyRsSyvtn34vSXnjDq6Oqed5Dufg5mB44mank+51nAdHOKuTJ0lj0mksY7L+N61A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@astrojs/compiler-rs': ^0.4.0 || ^0.5.0 + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + '@astrojs/compiler-rs': + optional: true + svelte: + optional: true + vite-plus: + optional: true + oxlint-tailwindcss@1.16.0: resolution: {integrity: sha512-qwkw3gahMiLOVkZAInULbvnFaFceanEnk+GxpMyNZU4bq2nRmZd3xD4En0EhIakEBiIt15RiGwQ7AuhrllNoNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2708,6 +2849,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3578,6 +3723,63 @@ snapshots: '@oxc-transform-react/binding-win32-x64-msvc@0.152.0': optional: true + '@oxfmt/binding-android-arm-eabi@0.74.0': + optional: true + + '@oxfmt/binding-android-arm64@0.74.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.74.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.74.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.74.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.74.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.74.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.74.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.74.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.74.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.74.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.74.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.74.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.74.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.74.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.74.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.74.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.74.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.74.0': + optional: true + '@oxlint/binding-android-arm-eabi@1.89.0': optional: true @@ -4428,6 +4630,30 @@ snapshots: '@oxc-transform-react/binding-win32-ia32-msvc': 0.152.0 '@oxc-transform-react/binding-win32-x64-msvc': 0.152.0 + oxfmt@0.74.0: + dependencies: + tinypool: 2.2.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.74.0 + '@oxfmt/binding-android-arm64': 0.74.0 + '@oxfmt/binding-darwin-arm64': 0.74.0 + '@oxfmt/binding-darwin-x64': 0.74.0 + '@oxfmt/binding-freebsd-x64': 0.74.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.74.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.74.0 + '@oxfmt/binding-linux-arm64-gnu': 0.74.0 + '@oxfmt/binding-linux-arm64-musl': 0.74.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.74.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.74.0 + '@oxfmt/binding-linux-riscv64-musl': 0.74.0 + '@oxfmt/binding-linux-s390x-gnu': 0.74.0 + '@oxfmt/binding-linux-x64-gnu': 0.74.0 + '@oxfmt/binding-linux-x64-musl': 0.74.0 + '@oxfmt/binding-openharmony-arm64': 0.74.0 + '@oxfmt/binding-win32-arm64-msvc': 0.74.0 + '@oxfmt/binding-win32-ia32-msvc': 0.74.0 + '@oxfmt/binding-win32-x64-msvc': 0.74.0 + oxlint-tailwindcss@1.16.0: dependencies: '@tailwindcss/node': 4.3.3 @@ -4649,6 +4875,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinypool@2.2.0: {} + tslib@2.8.1: optional: true diff --git a/scripts/oxlint-boundaries.mjs b/scripts/oxlint-boundaries.mjs index 40a1600..3c28e94 100644 --- a/scripts/oxlint-boundaries.mjs +++ b/scripts/oxlint-boundaries.mjs @@ -21,7 +21,26 @@ export default { const domain = filename.includes("/domain/"); const browser = isBrowserModule(filename); const page = filename.includes("/pages/") && !filename.endsWith(".server.ts"); + function isTypeOnly(node) { + if (node.type === "ImportDeclaration") { + return node.importKind === "type" || ( + node.specifiers?.length > 0 && + node.specifiers.every((specifier) => specifier.importKind === "type") + ); + } + if (node.type === "ExportAllDeclaration") return node.exportKind === "type"; + if (node.type === "ExportNamedDeclaration") { + const specifiers = node.specifiers ?? []; + return node.exportKind === "type" || ( + node.declaration == null && + specifiers.length > 0 && + specifiers.every((specifier) => specifier.exportKind === "type") + ); + } + return false; + } function check(node) { + if (isTypeOnly(node)) return; const source = node.source; if (!source) return; const name = source.value; diff --git a/src/features/practice/components/practice.tsx b/src/features/practice/components/practice.tsx deleted file mode 100644 index a32852e..0000000 --- a/src/features/practice/components/practice.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { useId, useState } from "react"; -import type { Lesson, Point } from "../domain/lesson"; -import { useDrawing } from "../use-cases/use-drawing"; - -function pointsAttribute(points: readonly Point[]): string { - return points.map((point) => `${point.x},${point.y}`).join(" "); -} - -export function TracingPractice({ lesson }: { readonly lesson: Lesson }) { - const { - padRef, - ready, - attempt, - reset: resetDrawing, - abort: abortDrawing, - onPointerDown, - onPointerMove, - onPointerUp, - onPointerCancel, - onLostPointerCapture, - } = useDrawing(); - const [revealed, setRevealed] = useState(false); - const arrowId = useId(); - const { x, y, width, height } = lesson.bounds; - const viewBox = `${x} ${y} ${width} ${height}`; - const count = attempt.strokes.length; - let feedback = revealed - ? "Reference shown. Trace along it, or choose Retry for the faint guide." - : "Ready to trace. Follow the faint guide."; - if (count > 0) - feedback = `${count} ${count === 1 ? "stroke" : "strokes"} drawn. Keep tracing or start again.`; - if (attempt.feedback === "drawing") - feedback = "Drawing… Lift your finger or pen to finish this stroke."; - if (attempt.feedback === "interrupted") - feedback = "Stroke interrupted. Your finished strokes are safe. Try again."; - - if (!ready) feedback = "Preparing the writing area…"; - - function reset(): void { - resetDrawing(); - setRevealed(false); - } - - function reveal(): void { - abortDrawing(); - setRevealed(true); - } - - return ( -
-
-

- Strokes -

-

- Development lesson — review pending -

-
-
-
-
- - {lesson.glyph} - -
- - {lesson.pronunciation} - -

Hindi letter practice

-
-
-
-
-

- Practice Hindi handwriting with your finger, pen, or mouse. -

- - {feedback} - -
- - - -
-
- -
-
- ); -} diff --git a/src/features/practice/domain/lesson.ts b/src/features/practice/domain/lesson.ts deleted file mode 100644 index 9ff0eb5..0000000 --- a/src/features/practice/domain/lesson.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Result, Schema } from "effect"; - -const FINITE = Schema.Number.check(Schema.isFinite()); -const LABEL = Schema.String.check( - Schema.makeFilter((value) => value.trim().length > 0), -); -export const Point = Schema.Struct({ x: FINITE, y: FINITE }); -export interface Point extends Schema.Schema.Type {} - -const REFERENCE_STROKE = Schema.Struct({ - hint: LABEL, - points: Schema.NonEmptyArray(Point).check( - Schema.makeFilter((points) => { - const length = points.reduce((total, point, index) => { - const previous = points[index - 1]; - return previous - ? total + Math.hypot(point.x - previous.x, point.y - previous.y) - : total; - }, 0); - return length > 0 && Number.isFinite(length); - }), - ), -}); - -const LESSON = Schema.Struct({ - id: LABEL, - glyph: LABEL, - pronunciation: LABEL, - review: Schema.Literal("pending"), - bounds: Schema.Struct({ - x: FINITE, - y: FINITE, - width: FINITE, - height: FINITE, - }), - strokes: Schema.NonEmptyArray(REFERENCE_STROKE), -}) - .check( - Schema.makeFilter((lesson) => { - const { x, y, width, height } = lesson.bounds; - if ( - width <= 0 || - height <= 0 || - !Number.isFinite(x + width) || - !Number.isFinite(y + height) - ) - return false; - return lesson.strokes.every((stroke) => - stroke.points.every( - (point) => - point.x >= x && - point.x <= x + width && - point.y >= y && - point.y <= y + height, - ), - ); - }), - ) - .pipe(Schema.brand("Lesson")); - -export interface Lesson extends Schema.Schema.Type {} - -export type LessonContent = - | { readonly kind: "ready"; readonly lesson: Lesson } - | { readonly kind: "unusable"; readonly message: string }; - -export function parseLesson(input: unknown): LessonContent { - const result = Schema.decodeUnknownResult(LESSON)(input); - if (Result.isFailure(result)) { - return { - kind: "unusable", - message: "This lesson cannot be opened. Its content needs repair.", - }; - } - return { kind: "ready", lesson: result.success }; -} diff --git a/src/features/practice/use-cases/use-drawing.ts b/src/features/practice/use-cases/use-drawing.ts deleted file mode 100644 index 25dc2d5..0000000 --- a/src/features/practice/use-cases/use-drawing.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { PointerEvent, RefObject } from "react"; -import type { Point } from "../domain/lesson"; - -interface ActiveStroke { - readonly pointerId: number; - readonly points: readonly Point[]; -} -interface Attempt { - readonly strokes: readonly (readonly Point[])[]; - readonly active: ActiveStroke | null; - readonly feedback: "ready" | "drawing" | "interrupted"; -} -interface Capture { - readonly pointerId: number; - readonly matrix: DOMMatrix; -} -interface Drawing { - readonly padRef: RefObject; - readonly ready: boolean; - readonly attempt: Attempt; - readonly reset: () => void; - readonly abort: () => void; - readonly onPointerDown: (event: PointerEvent) => void; - readonly onPointerMove: (event: PointerEvent) => void; - readonly onPointerUp: (event: PointerEvent) => void; - readonly onPointerCancel: (event: PointerEvent) => void; - readonly onLostPointerCapture: (event: PointerEvent) => void; -} -const EMPTY: Attempt = { strokes: [], active: null, feedback: "ready" }; - -function sameMatrix(left: DOMMatrix, right: DOMMatrix): boolean { - return left.a === right.a && left.b === right.b && left.c === right.c && left.d === right.d && left.e === right.e && left.f === right.f; -} -function toPoint(svg: SVGSVGElement, x: number, y: number): { readonly point: Point; readonly matrix: DOMMatrix } | null { - const matrix = svg.getScreenCTM(); - if (!matrix || matrix.a * matrix.d - matrix.b * matrix.c === 0) return null; - const point = new DOMPoint(x, y).matrixTransform(matrix.inverse()); - if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null; - return { point: { x: point.x, y: point.y }, matrix }; -} - -export function useDrawing(): Drawing { - const padRef = useRef(null); - const captureRef = useRef(null); - const [ready, setReady] = useState(false); - const [attempt, setAttempt] = useState(EMPTY); - - const release = useCallback((): void => { - const capture = captureRef.current; - captureRef.current = null; - const pad = padRef.current; - if (capture && pad?.hasPointerCapture(capture.pointerId)) pad.releasePointerCapture(capture.pointerId); - }, []); - - const abort = useCallback((): void => { - if (!captureRef.current) return; - release(); - setAttempt((previous) => ({ ...previous, active: null, feedback: "interrupted" })); - }, [release]); - - useEffect(() => { - const pad = padRef.current; - if (!pad) return; - const observer = new ResizeObserver(() => { - const capture = captureRef.current; - const matrix = pad.getScreenCTM(); - if (capture && (!matrix || !sameMatrix(capture.matrix, matrix))) abort(); - }); - observer.observe(pad); - setReady(true); - const onVisibilityChange = (): void => { if (document.hidden) abort(); }; - window.addEventListener("blur", abort); - document.addEventListener("visibilitychange", onVisibilityChange); - window.addEventListener("resize", abort); - window.addEventListener("scroll", abort, true); - window.visualViewport?.addEventListener("resize", abort); - window.visualViewport?.addEventListener("scroll", abort); - return () => { - observer.disconnect(); - window.removeEventListener("blur", abort); - document.removeEventListener("visibilitychange", onVisibilityChange); - window.removeEventListener("resize", abort); - window.removeEventListener("scroll", abort, true); - window.visualViewport?.removeEventListener("resize", abort); - window.visualViewport?.removeEventListener("scroll", abort); - release(); - }; - }, [abort, release]); - - function onPointerDown(event: PointerEvent): void { - if (!ready || captureRef.current || event.button !== 0) return; - const mapped = toPoint(event.currentTarget, event.clientX, event.clientY); - if (!mapped) return; - event.currentTarget.setPointerCapture(event.pointerId); - captureRef.current = { pointerId: event.pointerId, matrix: mapped.matrix }; - setAttempt((previous) => ({ ...previous, active: { pointerId: event.pointerId, points: [mapped.point] }, feedback: "drawing" })); - } - - function onPointerMove(event: PointerEvent): void { - const capture = captureRef.current; - if (!capture || event.pointerId !== capture.pointerId) return; - const mapped = toPoint(event.currentTarget, event.clientX, event.clientY); - if (!mapped || !sameMatrix(capture.matrix, mapped.matrix)) return abort(); - setAttempt((previous) => previous.active ? { ...previous, active: { ...previous.active, points: [...previous.active.points, mapped.point] } } : previous); - } - - function onPointerUp(event: PointerEvent): void { - const capture = captureRef.current; - if (!capture || event.pointerId !== capture.pointerId) return; - const mapped = toPoint(event.currentTarget, event.clientX, event.clientY); - if (!mapped || !sameMatrix(capture.matrix, mapped.matrix)) return abort(); - release(); - setAttempt((previous) => previous.active ? { strokes: [...previous.strokes, [...previous.active.points, mapped.point]], active: null, feedback: "ready" } : previous); - } - - function onInterrupted(event: PointerEvent): void { - if (event.pointerId === captureRef.current?.pointerId) abort(); - } - - function reset(): void { - release(); - setAttempt(EMPTY); - } - - return { padRef, ready, attempt, reset, abort, onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onInterrupted, onLostPointerCapture: onInterrupted }; -} diff --git a/src/features/practice/__tests__/browser/fixtures.ts b/src/features/tracingPractice/__tests__/browser/fixtures.ts similarity index 100% rename from src/features/practice/__tests__/browser/fixtures.ts rename to src/features/tracingPractice/__tests__/browser/fixtures.ts diff --git a/src/features/practice/__tests__/browser/practice.spec.ts b/src/features/tracingPractice/__tests__/browser/practice.spec.ts similarity index 100% rename from src/features/practice/__tests__/browser/practice.spec.ts rename to src/features/tracingPractice/__tests__/browser/practice.spec.ts diff --git a/src/features/practice/__tests__/browser/tracing.spec.ts b/src/features/tracingPractice/__tests__/browser/tracing.spec.ts similarity index 100% rename from src/features/practice/__tests__/browser/tracing.spec.ts rename to src/features/tracingPractice/__tests__/browser/tracing.spec.ts diff --git a/src/features/practice/__tests__/content/lesson.test.ts b/src/features/tracingPractice/__tests__/content/lesson.test.ts similarity index 84% rename from src/features/practice/__tests__/content/lesson.test.ts rename to src/features/tracingPractice/__tests__/content/lesson.test.ts index 360e339..e18380c 100644 --- a/src/features/practice/__tests__/content/lesson.test.ts +++ b/src/features/tracingPractice/__tests__/content/lesson.test.ts @@ -1,9 +1,9 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { Practice } from "../../../../pages/index"; import { expect, test } from "vitest"; -import { parseLesson } from "../../domain/lesson"; -import { DEVELOPMENT_LESSON } from "../../domain/development-lesson"; +import { ErrorBox } from "../../../../pages/index"; +import { DEVELOPMENT_LESSON } from "../../use-cases/tracing/development-lesson"; +import { parseLesson } from "../../use-cases/tracing/helpers"; test("the original development lesson parses with honest review status and ordered strokes", () => { const result = parseLesson(DEVELOPMENT_LESSON); @@ -88,11 +88,10 @@ test("the content boundary preserves the authored teaching order", () => { }); test("invalid content renders a distinct failure without learner controls or completion", () => { - const markup = renderToStaticMarkup( - createElement(Practice, { - content: parseLesson({ ...DEVELOPMENT_LESSON, strokes: [] }), - }), - ); + const content = parseLesson({ ...DEVELOPMENT_LESSON, strokes: [] }); + expect(content.kind).toBe("unusable"); + if (content.kind !== "unusable") throw new Error("Invalid lesson did not fail"); + const markup = renderToStaticMarkup(createElement(ErrorBox, { message: content.message })); expect(markup).toContain('role="alert"'); expect(markup).toContain("Lesson unavailable"); expect(markup).toContain("Its content needs repair."); diff --git a/src/features/tracingPractice/__tests__/content/session.test.ts b/src/features/tracingPractice/__tests__/content/session.test.ts new file mode 100644 index 0000000..ca25d18 --- /dev/null +++ b/src/features/tracingPractice/__tests__/content/session.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "vitest"; +import { EMPTY } from "../../use-cases/tracing/helpers"; +import { + type Session, + type SessionAction, + INITIAL_SESSION, + feedbackMessage, + reduceSession, +} from "../../use-cases/tracing/session"; + +const a = { x: 10, y: 20 }; +const b = { x: 30, y: 40 }; +const c = { x: 50, y: 60 }; + +function apply(actions: readonly SessionAction[], session: Session = INITIAL_SESSION): Session { + return actions.reduce(reduceSession, session); +} + +test("a stroke starts, extends, and commits without clearing the reference", () => { + const revealed = reduceSession(INITIAL_SESSION, { type: "reveal", interrupt: false }); + const committed = apply( + [ + { type: "start", pointerId: 1, point: a }, + { type: "extend", point: b }, + { type: "commit", point: c }, + ], + revealed, + ); + + expect(committed.revealed).toBe(true); + expect(committed.attempt).toEqual({ + strokes: [[a, b, c]], + active: null, + feedback: "ready", + }); +}); + +test("extend and commit leave a session unchanged when no stroke is active", () => { + expect(reduceSession(INITIAL_SESSION, { type: "extend", point: a })).toBe(INITIAL_SESSION); + expect(reduceSession(INITIAL_SESSION, { type: "commit", point: a })).toBe(INITIAL_SESSION); +}); + +test("reveal keeps finished strokes and only interrupts an active stroke", () => { + const drawing = apply([ + { type: "start", pointerId: 1, point: a }, + { type: "commit", point: b }, + { type: "start", pointerId: 2, point: c }, + ]); + const shown = reduceSession(drawing, { type: "reveal", interrupt: true }); + + expect(shown.revealed).toBe(true); + expect(shown.attempt.strokes).toEqual([[a, b]]); + expect(shown.attempt.active).toBeNull(); + expect(shown.attempt.feedback).toBe("interrupted"); +}); + +test("reset clears the attempt and the reference together", () => { + const session = apply([ + { type: "reveal", interrupt: false }, + { type: "start", pointerId: 1, point: a }, + { type: "commit", point: b }, + { type: "reset" }, + ]); + + expect(session).toBe(INITIAL_SESSION); + expect(session.attempt).toBe(EMPTY); +}); + +test("feedback follows readiness, then the stroke, then how much is drawn", () => { + const idle = INITIAL_SESSION; + const revealed = reduceSession(idle, { type: "reveal", interrupt: false }); + const drawn = apply( + [ + { type: "start", pointerId: 1, point: a }, + { type: "commit", point: b }, + ], + revealed, + ); + const drawing = reduceSession(drawn, { type: "start", pointerId: 3, point: c }); + const interrupted = reduceSession(drawing, { type: "interrupt" }); + + expect(feedbackMessage({ ...idle, ready: false })).toBe("Preparing the writing area…"); + expect(feedbackMessage({ ...idle, ready: true })).toBe("Ready to trace. Follow the faint guide."); + expect(feedbackMessage({ ...revealed, ready: true })).toBe( + "Reference shown. Trace along it, or choose Retry for the faint guide.", + ); + expect(feedbackMessage({ ...drawn, ready: true })).toBe( + "1 stroke drawn. Keep tracing or start again.", + ); + expect(feedbackMessage({ ...drawing, ready: true })).toBe( + "Drawing… Lift your finger or pen to finish this stroke.", + ); + expect(feedbackMessage({ ...interrupted, ready: true })).toBe( + "Stroke interrupted. Your finished strokes are safe. Try again.", + ); + expect(feedbackMessage({ ...interrupted, ready: false })).toBe("Preparing the writing area…"); +}); diff --git a/src/features/practice/__tests__/fence.test.mjs b/src/features/tracingPractice/__tests__/fence.test.mjs similarity index 95% rename from src/features/practice/__tests__/fence.test.mjs rename to src/features/tracingPractice/__tests__/fence.test.mjs index 1831fde..55f4968 100644 --- a/src/features/practice/__tests__/fence.test.mjs +++ b/src/features/tracingPractice/__tests__/fence.test.mjs @@ -56,6 +56,7 @@ test("the fence rejects invalid types, floating Effects and cross-boundary impor 'export { adapter } from "../use-cases/adapter";', 'export const adapter = import("../components/adapter");', 'import { readFile } from "node:fs"; export const adapter = readFile;', + 'import { createElement } from "react"; export const adapter = createElement;', ]) { writeFileSync(probe, source); result = lint(); @@ -94,6 +95,9 @@ test("the fence rejects invalid types, floating Effects and cross-boundary impor assert.match(result.stdout + result.stderr, /no-platform-in-domain/); } } + writeFileSync(probe, 'import type { PointerEvent } from "react"; export type Handler = (event: PointerEvent) => void;\n'); + result = lint(); + assert.equal(result.status, 0, result.stdout + result.stderr); writeFileSync(probe, 'import { Effect } from "effect"; export const value = Effect.succeed(1);'); result = lint(); assert.equal(result.status, 0, result.stdout + result.stderr); diff --git a/src/features/tracingPractice/domain/constraints.ts b/src/features/tracingPractice/domain/constraints.ts new file mode 100644 index 0000000..403763a --- /dev/null +++ b/src/features/tracingPractice/domain/constraints.ts @@ -0,0 +1,4 @@ +import { Schema } from "effect"; + +export const FINITE = Schema.Number.check(Schema.isFinite()); +export const LABEL = Schema.String.check(Schema.makeFilter((value) => value.trim().length > 0)); diff --git a/src/features/tracingPractice/domain/lessons.ts b/src/features/tracingPractice/domain/lessons.ts new file mode 100644 index 0000000..c3a837f --- /dev/null +++ b/src/features/tracingPractice/domain/lessons.ts @@ -0,0 +1,36 @@ +import { Schema } from "effect"; +import { FINITE, LABEL } from "./constraints"; +import { REFERENCE_STROKE } from "./strokes"; + +export const LESSON = Schema.Struct({ + id: LABEL, + glyph: LABEL, + pronunciation: LABEL, + review: Schema.Literal("pending"), + bounds: Schema.Struct({ + x: FINITE, + y: FINITE, + width: FINITE, + height: FINITE, + }), + strokes: Schema.NonEmptyArray(REFERENCE_STROKE), +}) + .check( + Schema.makeFilter((lesson) => { + const { x, y, width, height } = lesson.bounds; + if (width <= 0 || height <= 0 || !Number.isFinite(x + width) || !Number.isFinite(y + height)) + return false; + return lesson.strokes.every((stroke) => + stroke.points.every( + (point) => point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height, + ), + ); + }), + ) + .pipe(Schema.brand("Lesson")); + +export interface Lesson extends Schema.Schema.Type {} + +export type LessonContent = + | { readonly kind: "ready"; readonly lesson: Lesson } + | { readonly kind: "unusable"; readonly message: string }; diff --git a/src/features/tracingPractice/domain/strokes.ts b/src/features/tracingPractice/domain/strokes.ts new file mode 100644 index 0000000..a629bf3 --- /dev/null +++ b/src/features/tracingPractice/domain/strokes.ts @@ -0,0 +1,67 @@ +import { Schema } from "effect"; +import type { PointerEvent, RefObject } from "react"; +import { FINITE, LABEL } from "./constraints"; + +export const Point = Schema.Struct({ x: FINITE, y: FINITE }); +export interface Point extends Schema.Schema.Type {} + +export const REFERENCE_STROKE = Schema.Struct({ + hint: LABEL, + points: Schema.NonEmptyArray(Point).check( + Schema.makeFilter((points) => { + const length = points.reduce((total, point, index) => { + const previous = points[index - 1]; + return previous ? total + Math.hypot(point.x - previous.x, point.y - previous.y) : total; + }, 0); + return length > 0 && Number.isFinite(length); + }), + ), +}); + +export const ActiveStroke = Schema.Struct({ + pointerId: Schema.Number, + points: Schema.Array(Point), +}); +export interface ActiveStroke extends Schema.Schema.Type {} + +export const Attempt = Schema.Struct({ + strokes: Schema.Array(Schema.Array(Point)), + active: Schema.NullOr(ActiveStroke), + feedback: Schema.Literals(["ready", "drawing", "interrupted"]), +}); +export interface Attempt extends Schema.Schema.Type {} + +const ScreenMatrix = Schema.declare( + (value): value is DOMMatrix => typeof DOMMatrix !== "undefined" && value instanceof DOMMatrix, +); + +export const Capture = Schema.Struct({ + pointerId: Schema.Number, + matrix: ScreenMatrix, +}); +export interface Capture extends Schema.Schema.Type {} + +const PadRef = Schema.declare( + (value): value is RefObject => + typeof value === "object" && value !== null && "current" in value, +); +const Action = Schema.declare((value): value is () => void => typeof value === "function"); +const PointerHandler = Schema.declare( + (value): value is (event: PointerEvent) => void => typeof value === "function", +); + +export const Drawing = Schema.Struct({ + padRef: PadRef, + ready: Schema.Boolean, + attempt: Attempt, + revealed: Schema.Boolean, + reset: Action, + reveal: Action, + abort: Action, + onPointerDown: PointerHandler, + onPointerMove: PointerHandler, + onPointerUp: PointerHandler, + onPointerCancel: PointerHandler, + onLostPointerCapture: PointerHandler, +}); +export interface Drawing extends Schema.Schema.Type {} diff --git a/src/features/tracingPractice/use-cases/tracing/components/Controls.tsx b/src/features/tracingPractice/use-cases/tracing/components/Controls.tsx new file mode 100644 index 0000000..7168045 --- /dev/null +++ b/src/features/tracingPractice/use-cases/tracing/components/Controls.tsx @@ -0,0 +1,42 @@ +export function Controls({ + ready, + revealed, + onReset, + onReveal, +}: { + readonly ready: boolean; + readonly revealed: boolean; + readonly onReset: () => void; + readonly onReveal: () => void; +}) { + return ( +
+ + + +
+ ); +} diff --git a/src/features/tracingPractice/use-cases/tracing/components/Explainer.tsx b/src/features/tracingPractice/use-cases/tracing/components/Explainer.tsx new file mode 100644 index 0000000..fad627f --- /dev/null +++ b/src/features/tracingPractice/use-cases/tracing/components/Explainer.tsx @@ -0,0 +1,68 @@ +import type { Lesson } from "../../../domain/lessons"; +import { pointsAttribute } from "../helpers"; + +export function Explainer({ + lesson, + revealed, +}: { + readonly lesson: Lesson; + readonly revealed: boolean; +}) { + const { x, y, width, height } = lesson.bounds; + const viewBox = `${x} ${y} ${width} ${height}`; + + return ( + + ); +} diff --git a/src/features/tracingPractice/use-cases/tracing/components/Feedback.tsx b/src/features/tracingPractice/use-cases/tracing/components/Feedback.tsx new file mode 100644 index 0000000..6407e12 --- /dev/null +++ b/src/features/tracingPractice/use-cases/tracing/components/Feedback.tsx @@ -0,0 +1,15 @@ +export function Feedback({ message }: { readonly message: string }) { + return ( + <> +

+ Practice Hindi handwriting with your finger, pen, or mouse. +

+ + {message} + + + ); +} diff --git a/src/features/tracingPractice/use-cases/tracing/components/Header.tsx b/src/features/tracingPractice/use-cases/tracing/components/Header.tsx new file mode 100644 index 0000000..9b8e172 --- /dev/null +++ b/src/features/tracingPractice/use-cases/tracing/components/Header.tsx @@ -0,0 +1,22 @@ +export function Header({ + glyph, + pronunciation, +}: { + readonly glyph: string; + readonly pronunciation: string; +}) { + return ( +
+ + {glyph} + +
+ {pronunciation} +

Hindi letter practice

+
+
+ ); +} diff --git a/src/features/tracingPractice/use-cases/tracing/components/TracingBox.tsx b/src/features/tracingPractice/use-cases/tracing/components/TracingBox.tsx new file mode 100644 index 0000000..135f3be --- /dev/null +++ b/src/features/tracingPractice/use-cases/tracing/components/TracingBox.tsx @@ -0,0 +1,129 @@ +import { useId } from "react"; +import type { PointerEvent, RefObject } from "react"; +import type { Lesson } from "../../../domain/lessons"; +import type { Attempt } from "../../../domain/strokes"; +import { pointsAttribute } from "../helpers"; + +export function TracingBox({ + lesson, + attempt, + padRef, + ready, + revealed, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onLostPointerCapture, +}: { + readonly lesson: Lesson; + readonly attempt: Attempt; + readonly padRef: RefObject; + readonly ready: boolean; + readonly revealed: boolean; + readonly onPointerDown: (event: PointerEvent) => void; + readonly onPointerMove: (event: PointerEvent) => void; + readonly onPointerUp: (event: PointerEvent) => void; + readonly onPointerCancel: (event: PointerEvent) => void; + readonly onLostPointerCapture: (event: PointerEvent) => void; +}) { + const arrowId = useId(); + const { x, y, width, height } = lesson.bounds; + const viewBox = `${x} ${y} ${width} ${height}`; + + return ( +
+
+ ); +} diff --git a/src/features/practice/domain/development-lesson.ts b/src/features/tracingPractice/use-cases/tracing/development-lesson.ts similarity index 100% rename from src/features/practice/domain/development-lesson.ts rename to src/features/tracingPractice/use-cases/tracing/development-lesson.ts diff --git a/src/features/tracingPractice/use-cases/tracing/helpers.ts b/src/features/tracingPractice/use-cases/tracing/helpers.ts new file mode 100644 index 0000000..5919f12 --- /dev/null +++ b/src/features/tracingPractice/use-cases/tracing/helpers.ts @@ -0,0 +1,43 @@ +import { Result, Schema } from "effect"; +import { type LessonContent, LESSON } from "../../domain/lessons"; +import type { Attempt, Point } from "../../domain/strokes"; + +export const EMPTY: Attempt = { strokes: [], active: null, feedback: "ready" }; + +export function sameMatrix(left: DOMMatrix, right: DOMMatrix): boolean { + return ( + left.a === right.a && + left.b === right.b && + left.c === right.c && + left.d === right.d && + left.e === right.e && + left.f === right.f + ); +} + +export function toPoint( + svg: SVGSVGElement, + x: number, + y: number, +): { readonly point: Point; readonly matrix: DOMMatrix } | null { + const matrix = svg.getScreenCTM(); + if (!matrix || matrix.a * matrix.d - matrix.b * matrix.c === 0) return null; + const point = new DOMPoint(x, y).matrixTransform(matrix.inverse()); + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null; + return { point: { x: point.x, y: point.y }, matrix }; +} + +export function parseLesson(input: unknown): LessonContent { + const result = Schema.decodeUnknownResult(LESSON)(input); + if (Result.isFailure(result)) { + return { + kind: "unusable", + message: "This lesson cannot be opened. Its content needs repair.", + }; + } + return { kind: "ready", lesson: result.success }; +} + +export function pointsAttribute(points: readonly Point[]): string { + return points.map((point) => `${point.x},${point.y}`).join(" "); +} diff --git a/src/features/tracingPractice/use-cases/tracing/index.ts b/src/features/tracingPractice/use-cases/tracing/index.ts new file mode 100644 index 0000000..1d9d9ab --- /dev/null +++ b/src/features/tracingPractice/use-cases/tracing/index.ts @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import type { PointerEvent } from "react"; +import type { Capture, Drawing } from "../../domain/strokes"; +import { sameMatrix, toPoint } from "./helpers"; +import { INITIAL_SESSION, reduceSession } from "./session"; + +export function useDrawing(): Drawing { + const padRef = useRef(null); + const captureRef = useRef(null); + const [ready, setReady] = useState(false); + const [session, dispatch] = useReducer(reduceSession, INITIAL_SESSION); + + const release = useCallback((): void => { + const capture = captureRef.current; + captureRef.current = null; + const pad = padRef.current; + if (capture && pad?.hasPointerCapture(capture.pointerId)) + pad.releasePointerCapture(capture.pointerId); + }, []); + + const abort = useCallback((): void => { + if (!captureRef.current) return; + release(); + dispatch({ type: "interrupt" }); + }, [release]); + + useEffect(() => { + const pad = padRef.current; + if (!pad) return; + const observer = new ResizeObserver(() => { + const capture = captureRef.current; + const matrix = pad.getScreenCTM(); + if (capture && (!matrix || !sameMatrix(capture.matrix, matrix))) abort(); + }); + observer.observe(pad); + setReady(true); + const onVisibilityChange = (): void => { + if (document.hidden) abort(); + }; + window.addEventListener("blur", abort); + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("resize", abort); + window.addEventListener("scroll", abort, true); + window.visualViewport?.addEventListener("resize", abort); + window.visualViewport?.addEventListener("scroll", abort); + return () => { + observer.disconnect(); + window.removeEventListener("blur", abort); + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("resize", abort); + window.removeEventListener("scroll", abort, true); + window.visualViewport?.removeEventListener("resize", abort); + window.visualViewport?.removeEventListener("scroll", abort); + release(); + }; + }, [abort, release]); + + function onPointerDown(event: PointerEvent): void { + if (!ready || captureRef.current || event.button !== 0) return; + const mapped = toPoint(event.currentTarget, event.clientX, event.clientY); + if (!mapped) return; + event.currentTarget.setPointerCapture(event.pointerId); + captureRef.current = { pointerId: event.pointerId, matrix: mapped.matrix }; + dispatch({ type: "start", pointerId: event.pointerId, point: mapped.point }); + } + + function onPointerMove(event: PointerEvent): void { + const capture = captureRef.current; + if (!capture || event.pointerId !== capture.pointerId) return; + const mapped = toPoint(event.currentTarget, event.clientX, event.clientY); + if (!mapped || !sameMatrix(capture.matrix, mapped.matrix)) return abort(); + dispatch({ type: "extend", point: mapped.point }); + } + + function onPointerUp(event: PointerEvent): void { + const capture = captureRef.current; + if (!capture || event.pointerId !== capture.pointerId) return; + const mapped = toPoint(event.currentTarget, event.clientX, event.clientY); + if (!mapped || !sameMatrix(capture.matrix, mapped.matrix)) return abort(); + release(); + dispatch({ type: "commit", point: mapped.point }); + } + + function onInterrupted(event: PointerEvent): void { + if (event.pointerId === captureRef.current?.pointerId) abort(); + } + + function reset(): void { + release(); + dispatch({ type: "reset" }); + } + + function reveal(): void { + const interrupt = captureRef.current !== null; + if (interrupt) release(); + dispatch({ type: "reveal", interrupt }); + } + + return { + padRef, + ready, + attempt: session.attempt, + revealed: session.revealed, + reset, + reveal, + abort, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel: onInterrupted, + onLostPointerCapture: onInterrupted, + }; +} diff --git a/src/features/tracingPractice/use-cases/tracing/session.ts b/src/features/tracingPractice/use-cases/tracing/session.ts new file mode 100644 index 0000000..8083c65 --- /dev/null +++ b/src/features/tracingPractice/use-cases/tracing/session.ts @@ -0,0 +1,90 @@ +import type { Attempt, Point } from "../../domain/strokes"; +import { EMPTY } from "./helpers"; + +export interface Session { + readonly attempt: Attempt; + readonly revealed: boolean; +} + +export const INITIAL_SESSION: Session = { attempt: EMPTY, revealed: false }; + +export type SessionAction = + | { readonly type: "start"; readonly pointerId: number; readonly point: Point } + | { readonly type: "extend"; readonly point: Point } + | { readonly type: "commit"; readonly point: Point } + | { readonly type: "interrupt" } + | { readonly type: "reset" } + | { readonly type: "reveal"; readonly interrupt: boolean }; + +export function reduceSession(session: Session, action: SessionAction): Session { + switch (action.type) { + case "start": + return { + ...session, + attempt: { + ...session.attempt, + active: { pointerId: action.pointerId, points: [action.point] }, + feedback: "drawing", + }, + }; + case "extend": + return session.attempt.active + ? { + ...session, + attempt: { + ...session.attempt, + active: { + ...session.attempt.active, + points: [...session.attempt.active.points, action.point], + }, + }, + } + : session; + case "commit": + return session.attempt.active + ? { + ...session, + attempt: { + strokes: [ + ...session.attempt.strokes, + [...session.attempt.active.points, action.point], + ], + active: null, + feedback: "ready", + }, + } + : session; + case "interrupt": + return { + ...session, + attempt: { ...session.attempt, active: null, feedback: "interrupted" }, + }; + case "reset": + return INITIAL_SESSION; + case "reveal": + if (!action.interrupt) return session.revealed ? session : { ...session, revealed: true }; + return { + revealed: true, + attempt: { ...session.attempt, active: null, feedback: "interrupted" }, + }; + } +} + +export function feedbackMessage(input: { + readonly attempt: Attempt; + readonly revealed: boolean; + readonly ready: boolean; +}): string { + const count = input.attempt.strokes.length; + let feedback = input.revealed + ? "Reference shown. Trace along it, or choose Retry for the faint guide." + : "Ready to trace. Follow the faint guide."; + if (count > 0) + feedback = `${count} ${count === 1 ? "stroke" : "strokes"} drawn. Keep tracing or start again.`; + if (input.attempt.feedback === "drawing") + feedback = "Drawing… Lift your finger or pen to finish this stroke."; + if (input.attempt.feedback === "interrupted") + feedback = "Stroke interrupted. Your finished strokes are safe. Try again."; + if (!input.ready) feedback = "Preparing the writing area…"; + return feedback; +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 3e749f6..7bfd372 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,35 +1,81 @@ import "../styles.css"; -import { DEVELOPMENT_LESSON } from "../features/practice/domain/development-lesson"; -import { - type LessonContent, - parseLesson, -} from "../features/practice/domain/lesson"; -import { TracingPractice } from "../features/practice/components/practice"; - -const CONTENT = parseLesson(DEVELOPMENT_LESSON); +import { DEVELOPMENT_LESSON } from "../features/tracingPractice/use-cases/tracing/development-lesson"; +import { parseLesson } from "../features/tracingPractice/use-cases/tracing/helpers"; +import type { Lesson } from "../features/tracingPractice/domain/lessons"; +import { useDrawing } from "../features/tracingPractice/use-cases/tracing"; +import { feedbackMessage } from "../features/tracingPractice/use-cases/tracing/session"; +import { Controls } from "../features/tracingPractice/use-cases/tracing/components/Controls"; +import { Explainer } from "../features/tracingPractice/use-cases/tracing/components/Explainer"; +import { Feedback } from "../features/tracingPractice/use-cases/tracing/components/Feedback"; +import { Header } from "../features/tracingPractice/use-cases/tracing/components/Header"; +import { TracingBox } from "../features/tracingPractice/use-cases/tracing/components/TracingBox"; export default function HomePage() { - return ; + const content = parseLesson(DEVELOPMENT_LESSON); + if (content.kind === "unusable") { + return ; + } + return ; } -export function Practice({ content }: { readonly content: LessonContent }) { - if (content.kind === "unusable") { - return ( -
+export function PracticeArea({ lesson }: { readonly lesson: Lesson }) { + const drawing = useDrawing(); + const feedback = feedbackMessage({ + attempt: drawing.attempt, + revealed: drawing.revealed, + ready: drawing.ready, + }); + + return ( +
+

Strokes

-
-

- Lesson unavailable -

-

{content.message}

-

- Your writing has not been checked. Reload the page to try again. -

+

+ Development lesson — review pending +

+
+
+
+
+ + +
-
- ); - } - return ; + + +
+ ); +} + +export function ErrorBox({ message }: { readonly message: string }) { + return ( +
+

+ Strokes +

+
+

Lesson unavailable

+

{message}

+

Your writing has not been checked. Reload the page to try again.

+
+
+ ); } From 714f92e70adcc4de78d3201d3b7a0f5650ec5348 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 18:06:45 +0530 Subject: [PATCH 12/22] docs: define the reference trace for letter assessment Record that a finished letter is checked against an authored example, and that a different letter form needs its own trace. Co-authored-by: Cursor --- GLOSSARY.md | 4 ++++ docs/adr/0001-assess-the-finished-letter.md | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/GLOSSARY.md b/GLOSSARY.md index dac7862..c059b45 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -4,6 +4,10 @@ Practice writing a prompted Hindi letter and assess the finished drawing. ## Language +**Reference trace**: +The example drawing that defines the target letter form for an exercise. A different acceptable letter form requires its own reference trace. +_Avoid_: Stroke sequence when describing the assessment target + **Letter attempt**: A learner's complete drawing of one prompted letter, potentially made with several strokes. _Avoid_: Stroke when referring to the whole letter diff --git a/docs/adr/0001-assess-the-finished-letter.md b/docs/adr/0001-assess-the-finished-letter.md index 4d82867..fb06d0d 100644 --- a/docs/adr/0001-assess-the-finished-letter.md +++ b/docs/adr/0001-assess-the-finished-letter.md @@ -1,3 +1,5 @@ # Assess the finished letter -Assess the learner's finished letter attempt rather than compliance with a prescribed stroke sequence. The owner's goal is writing the correct letter within an acceptable margin of error; stroke order, drawing direction, and number of pen lifts are not assessment criteria. This replaces the next-stroke assessment direction in the existing specification; the matching policy, submission interaction, and acceptable variation still require decisions before implementation. +Assess the learner's finished letter attempt against a reference trace rather than compliance with a prescribed stroke sequence. Each reference trace defines a target letter form; accepting a different letter form requires authoring its own trace. The goal is a forgiving recreation of that example, with stroke order, drawing direction, and number of pen lifts excluded from assessment. + +The learner writes over a faint guide and explicitly presses Check to submit the whole letter attempt. This replaces the next-stroke assessment direction in the existing specification. Acceptable geometric variation and feedback still require decisions before implementation. From 979d2a2a34d14137261e14e2440eae2a107a4cf8 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 18:55:52 +0530 Subject: [PATCH 13/22] refactor: place tracing inside the writing feature The practice slice now uses a singular use-case folder, with domain utils and tracing constants, and the assessment note records the confirmed letter check. Co-authored-by: Cursor --- AGENTS.md | 4 +- docs/adr/0001-assess-the-finished-letter.md | 14 ++++- docs/research/rea-summary.json | 58 ------------------- package.json | 3 +- scripts/oxlint-boundaries.mjs | 2 +- .../use-cases/tracing/development-lesson.ts | 26 --------- .../__tests__/browser/fixtures.ts | 0 .../__tests__/browser/practice.spec.ts | 0 .../__tests__/browser/tracing.spec.ts | 0 .../__tests__/content/lesson.test.ts | 10 ++-- .../__tests__/content/session.test.ts | 4 +- .../__tests__/fence.test.mjs | 6 +- .../domain/lessons.ts | 2 +- .../domain/strokes.ts | 6 +- .../domain/utils}/constraints.ts | 2 +- .../use-case}/tracing/components/Controls.tsx | 0 .../tracing/components/Explainer.tsx | 0 .../use-case}/tracing/components/Feedback.tsx | 0 .../use-case}/tracing/components/Header.tsx | 0 .../tracing/components/TracingBox.tsx | 0 .../tracing/constants/development-lesson.ts | 40 +++++++++++++ .../use-case/tracing/helpers/index.ts} | 4 +- .../use-case/tracing/helpers}/session.ts | 4 +- .../use-case/tracing/hooks/use-drawing.ts} | 6 +- .../writing/use-case/tracing/index.ts | 9 +++ src/pages/index.tsx | 22 +++---- 26 files changed, 100 insertions(+), 122 deletions(-) delete mode 100644 docs/research/rea-summary.json delete mode 100644 src/features/tracingPractice/use-cases/tracing/development-lesson.ts rename src/features/{tracingPractice => writing}/__tests__/browser/fixtures.ts (100%) rename src/features/{tracingPractice => writing}/__tests__/browser/practice.spec.ts (100%) rename src/features/{tracingPractice => writing}/__tests__/browser/tracing.spec.ts (100%) rename src/features/{tracingPractice => writing}/__tests__/content/lesson.test.ts (89%) rename src/features/{tracingPractice => writing}/__tests__/content/session.test.ts (96%) rename src/features/{tracingPractice => writing}/__tests__/fence.test.mjs (97%) rename src/features/{tracingPractice => writing}/domain/lessons.ts (94%) rename src/features/{tracingPractice => writing}/domain/strokes.ts (95%) rename src/features/{tracingPractice/domain => writing/domain/utils}/constraints.ts (67%) rename src/features/{tracingPractice/use-cases => writing/use-case}/tracing/components/Controls.tsx (100%) rename src/features/{tracingPractice/use-cases => writing/use-case}/tracing/components/Explainer.tsx (100%) rename src/features/{tracingPractice/use-cases => writing/use-case}/tracing/components/Feedback.tsx (100%) rename src/features/{tracingPractice/use-cases => writing/use-case}/tracing/components/Header.tsx (100%) rename src/features/{tracingPractice/use-cases => writing/use-case}/tracing/components/TracingBox.tsx (100%) create mode 100644 src/features/writing/use-case/tracing/constants/development-lesson.ts rename src/features/{tracingPractice/use-cases/tracing/helpers.ts => writing/use-case/tracing/helpers/index.ts} (90%) rename src/features/{tracingPractice/use-cases/tracing => writing/use-case/tracing/helpers}/session.ts (96%) rename src/features/{tracingPractice/use-cases/tracing/index.ts => writing/use-case/tracing/hooks/use-drawing.ts} (95%) create mode 100644 src/features/writing/use-case/tracing/index.ts diff --git a/AGENTS.md b/AGENTS.md index 1ddd7ea..ac0b181 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Keep named features under `src/features/`. Start small: a whole feature can begi src/features/installation/ index.ts # Public API and feature wiring domain/ # Business types, schemas, rules, and business errors - use-cases/ # Business actions; coordinate domain rules and services + use-case/ # Business actions; coordinate domain rules and services services/ # Effect service contracts, implementations, and Layers src/main.ts # Compose Layers and start the app @@ -29,7 +29,7 @@ src/main.ts # Compose Layers and start the app - Domain code must not depend on HTTP, databases, use cases, or service implementations. - Use cases depend on service contracts and domain rules. Keep SQL and external API details in services. - Keep each service's contract, implementation, and Layer together by default. Acquire external resources through Effect, never as import-time side effects. -- Use these names: `use-cases/` and `services/`. +- Use these names: `use-case/` and `services/`. ## Grow files incrementally diff --git a/docs/adr/0001-assess-the-finished-letter.md b/docs/adr/0001-assess-the-finished-letter.md index fb06d0d..93a8c04 100644 --- a/docs/adr/0001-assess-the-finished-letter.md +++ b/docs/adr/0001-assess-the-finished-letter.md @@ -2,4 +2,16 @@ Assess the learner's finished letter attempt against a reference trace rather than compliance with a prescribed stroke sequence. Each reference trace defines a target letter form; accepting a different letter form requires authoring its own trace. The goal is a forgiving recreation of that example, with stroke order, drawing direction, and number of pen lifts excluded from assessment. -The learner writes over a faint guide and explicitly presses Check to submit the whole letter attempt. This replaces the next-stroke assessment direction in the existing specification. Acceptable geometric variation and feedback still require decisions before implementation. +The learner writes over a faint guide and explicitly presses Check to submit the whole letter attempt. Allow small deviations while retaining the guide's approximate size and position; do not automatically recenter or resize the attempt. Repeated passes over the same correct line must not count against the learner merely because the ink becomes darker or thicker. + +Require both coverage of the whole reference trace and sufficiently little ink away from it. Substantial missing sections and substantial extra lines fail; tiny gaps and accidental tails may pass. Measuring stray distance alone is insufficient because an incomplete drawing could lie entirely on the guide. The comparison method remains open; distance from the trace is the owner's suggested starting point. + +After a failed check, retain the drawing so the learner can add missing parts and check again. Clear discards the attempt and starts again; removing unwanted lines initially requires restarting. + +Give a simple textual reason for failure, distinguishing missing parts from drawing too far from the guide. Highlighting individual regions is outside the initial scope. On success, show a success message and retain the drawing until the learner chooses Retry; do not reset or advance automatically. The first version uses one reference trace. + +Any new drawing clears the previous assessment result. The learner must press Check again to assess the changed attempt. + +Lean forgiving on borderline attempts: allow wobble, small gaps, and minor tails while rejecting substantial missing parts and extra lines. Calibration against judged handwriting examples is explicitly deferred. Initial numerical tolerances remain provisional implementation choices, not validated handwriting standards. + +This replaces the next-stroke assessment direction in the existing specification. The owner confirmed the resulting specification and its proposed test seams on 2026-10-24. The specification is published as [issue #7](https://github.com/joshpitzalis/TimTim/issues/7) with the ready-for-agent label. diff --git a/docs/research/rea-summary.json b/docs/research/rea-summary.json deleted file mode 100644 index c1aae83..0000000 --- a/docs/research/rea-summary.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "evidence_id": "ev_ac4cc745a3f06625034762dfdcc80b0b49437d7c2367df61f361a94018d9353b", - "operation": "analyze_javascript_application", - "provider": { - "id": "rea-javascript-application", - "name": "REA JavaScript application analyzer", - "version": "1" - }, - "parameters": { - "format": "auto", - "integrity_policy": "fail", - "source_paths": [ - "application.js" - ], - "analysis_timeout_ms": 90000 - }, - "subject": { - "name": "skritter-inspection", - "digest": { - "sha256": "b8f588d69659347aefcf9072d4d02f1df9da27534bcea957a57f1a84a95e246b" - }, - "local_path": "/private/tmp/skritter-inspection", - "format": "directory", - "architecture": null - }, - "statistics": { - "relevant_files": 3, - "nested_asar_containers": 0, - "text_bytes_read": 1920522, - "invalid_utf8_files": 0, - "parsed_javascript_files": 1, - "skipped_javascript_files": 0, - "visited_ast_nodes": 401723, - "findings": 1271, - "modules": 0, - "parse_failures": 0 - }, - "limitations": [ - "Caller-selected source analysis leaves 2 inventoried files outside analysis scope. Inventory identity covers the complete artifact; relationships through unselected sources remain unknown.", - "CommonJS and ESM binding relationships were recovered from inert syntax and resolved only within the inventoried artifact container.", - "Graph coverage is incomplete; unavailable facts remain explicit and must not be read as absence.", - "JavaScript and HTML were parsed as inert text; bundle bootstrap code was never executed.", - "Static imports, entrypoints, workers, endpoints, and storage relationships do not prove runtime execution.", - "Static paths and relationships may remain unresolved when expressions are dynamic or obfuscated.", - "Webpack/Rspack factories were recovered from AST literals; REA did not invoke push handlers or bundle bootstrap code." - ], - "source_urls": { - "application.js": "https://skritter.com/js/application.js", - "libraries.js": "https://skritter.com/js/libraries.js", - "startup.js": "https://skritter.com/js/startup.js" - }, - "downloaded_sha256": { - "libraries.js": "19b262cf3be1a7f3a1be199532262eab3580377f4834cd4a5c47e391525aaf55", - "startup.js": "c982d435012dda8c6a8e990cd85aac6a74b388f1e37c06c578c3f4bfb61f08f5", - "application.js": "8933badb04686e9d3aef97b1ef8b22bf030021868617403241b08aeda2c730e5" - }, - "full_evidence_path": "/private/tmp/skritter-rea-evidence.json" -} diff --git a/package.json b/package.json index 64f36f1..877793b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "postinstall": "void prepare", "check": "pnpm typecheck && pnpm lint", "typecheck": "void prepare && tsc --noEmit", + "ts": "void prepare && tsc --noEmit --watch", "lint": "oxlint --deny-warnings", "format": "oxfmt", "format:check": "oxfmt --check", @@ -16,7 +17,7 @@ "test:content": "vitest run", "test:browser": "playwright test", "prepare": "effect-tsgo patch && lefthook install", - "test:fence": "node --test src/features/tracingPractice/__tests__/fence.test.mjs" + "test:fence": "node --test src/features/writing/__tests__/fence.test.mjs" }, "dependencies": { "effect": "4.0.5", diff --git a/scripts/oxlint-boundaries.mjs b/scripts/oxlint-boundaries.mjs index 3c28e94..1fcbc63 100644 --- a/scripts/oxlint-boundaries.mjs +++ b/scripts/oxlint-boundaries.mjs @@ -8,7 +8,7 @@ const browserVoidImports = new Set([ ]); function isBrowserModule(filename) { - return ["/browser/", "/components/", "/use-cases/"].some((folder) => filename.includes(folder)); + return ["/browser/", "/components/", "/use-case/"].some((folder) => filename.includes(folder)); } export default { diff --git a/src/features/tracingPractice/use-cases/tracing/development-lesson.ts b/src/features/tracingPractice/use-cases/tracing/development-lesson.ts deleted file mode 100644 index 93f3fb0..0000000 --- a/src/features/tracingPractice/use-cases/tracing/development-lesson.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const DEVELOPMENT_LESSON = { - id: "la-development-1", - glyph: "ल", - pronunciation: "la", - review: "pending", - bounds: { x: 0, y: 0, width: 100, height: 100 }, - strokes: [ - { - hint: "Start low on the left. Curve up and around to meet the right stem.", - points: [ - { x: 38, y: 73 }, { x: 30, y: 66 }, { x: 25, y: 57 }, - { x: 25, y: 48 }, { x: 29, y: 41 }, { x: 35, y: 38 }, - { x: 41, y: 41 }, { x: 45, y: 48 }, { x: 47, y: 57 }, - { x: 51, y: 49 }, { x: 58, y: 44 }, { x: 67, y: 44 }, - ], - }, - { - hint: "Draw the right stem from top to bottom.", - points: [{ x: 67, y: 25 }, { x: 67, y: 76 }], - }, - { - hint: "Draw the headline from left to right.", - points: [{ x: 22, y: 25 }, { x: 77, y: 25 }], - }, - ], -}; diff --git a/src/features/tracingPractice/__tests__/browser/fixtures.ts b/src/features/writing/__tests__/browser/fixtures.ts similarity index 100% rename from src/features/tracingPractice/__tests__/browser/fixtures.ts rename to src/features/writing/__tests__/browser/fixtures.ts diff --git a/src/features/tracingPractice/__tests__/browser/practice.spec.ts b/src/features/writing/__tests__/browser/practice.spec.ts similarity index 100% rename from src/features/tracingPractice/__tests__/browser/practice.spec.ts rename to src/features/writing/__tests__/browser/practice.spec.ts diff --git a/src/features/tracingPractice/__tests__/browser/tracing.spec.ts b/src/features/writing/__tests__/browser/tracing.spec.ts similarity index 100% rename from src/features/tracingPractice/__tests__/browser/tracing.spec.ts rename to src/features/writing/__tests__/browser/tracing.spec.ts diff --git a/src/features/tracingPractice/__tests__/content/lesson.test.ts b/src/features/writing/__tests__/content/lesson.test.ts similarity index 89% rename from src/features/tracingPractice/__tests__/content/lesson.test.ts rename to src/features/writing/__tests__/content/lesson.test.ts index e18380c..402a92a 100644 --- a/src/features/tracingPractice/__tests__/content/lesson.test.ts +++ b/src/features/writing/__tests__/content/lesson.test.ts @@ -2,14 +2,13 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { expect, test } from "vitest"; import { ErrorBox } from "../../../../pages/index"; -import { DEVELOPMENT_LESSON } from "../../use-cases/tracing/development-lesson"; -import { parseLesson } from "../../use-cases/tracing/helpers"; +import { DEVELOPMENT_LESSON } from "../../use-case/tracing/constants/development-lesson"; +import { parseLesson } from "../../use-case/tracing/helpers"; test("the original development lesson parses with honest review status and ordered strokes", () => { const result = parseLesson(DEVELOPMENT_LESSON); expect(result.kind).toBe("ready"); - if (result.kind !== "ready") - throw new Error("Development lesson did not parse"); + if (result.kind !== "ready") throw new Error("Development lesson did not parse"); expect(result.lesson.glyph).toBe("ल"); expect(result.lesson.pronunciation).toBe("la"); expect(result.lesson.review).toBe("pending"); @@ -79,8 +78,7 @@ test("the content boundary preserves the authored teaching order", () => { ], }); expect(result.kind).toBe("ready"); - if (result.kind !== "ready") - throw new Error("Authored sequence did not parse"); + if (result.kind !== "ready") throw new Error("Authored sequence did not parse"); expect(result.lesson.strokes.map((stroke) => stroke.points[0])).toEqual([ { x: 70, y: 20 }, { x: 10, y: 30 }, diff --git a/src/features/tracingPractice/__tests__/content/session.test.ts b/src/features/writing/__tests__/content/session.test.ts similarity index 96% rename from src/features/tracingPractice/__tests__/content/session.test.ts rename to src/features/writing/__tests__/content/session.test.ts index ca25d18..dbafe0d 100644 --- a/src/features/tracingPractice/__tests__/content/session.test.ts +++ b/src/features/writing/__tests__/content/session.test.ts @@ -1,12 +1,12 @@ import { expect, test } from "vitest"; -import { EMPTY } from "../../use-cases/tracing/helpers"; +import { EMPTY } from "../../use-case/tracing/helpers"; import { type Session, type SessionAction, INITIAL_SESSION, feedbackMessage, reduceSession, -} from "../../use-cases/tracing/session"; +} from "../../use-case/tracing/helpers/session"; const a = { x: 10, y: 20 }; const b = { x: 30, y: 40 }; diff --git a/src/features/tracingPractice/__tests__/fence.test.mjs b/src/features/writing/__tests__/fence.test.mjs similarity index 97% rename from src/features/tracingPractice/__tests__/fence.test.mjs rename to src/features/writing/__tests__/fence.test.mjs index 55f4968..024a458 100644 --- a/src/features/tracingPractice/__tests__/fence.test.mjs +++ b/src/features/writing/__tests__/fence.test.mjs @@ -53,7 +53,7 @@ test("the fence rejects invalid types, floating Effects and cross-boundary impor 'export { adapter } from "../browser/adapter";', 'export const adapter = import("../browser/adapter");', 'import { adapter } from "../components/adapter"; export const value = adapter;', - 'export { adapter } from "../use-cases/adapter";', + 'export { adapter } from "../use-case/adapter";', 'export const adapter = import("../components/adapter");', 'import { readFile } from "node:fs"; export const adapter = readFile;', 'import { createElement } from "react"; export const adapter = createElement;', @@ -70,7 +70,7 @@ test("the fence rejects invalid types, floating Effects and cross-boundary impor assert.match(result.stdout + result.stderr, /no-restricted-globals/); } mkdirSync(join(directory, "pages")); - for (const folder of ["browser", "components", "use-cases", "pages"]) { + for (const folder of ["browser", "components", "use-case", "pages"]) { mkdirSync(join(directory, folder), { recursive: true }); const browserProbe = join(directory, folder, "probe.ts"); for (const name of ["void", "fs", "fs/promises", "path", "child_process", "node:fs", "void/handler", "void/kv", "void/storage", "@void/react/pages-server", "@void/react/plugin"]) { @@ -87,7 +87,7 @@ test("the fence rejects invalid types, floating Effects and cross-boundary impor } for (const folder of ["routes", "providers"]) { mkdirSync(join(directory, folder)); - for (const target of ["components", "use-cases"]) { + for (const target of ["components", "use-case"]) { const serverProbe = join(directory, folder, "probe.ts"); writeFileSync(serverProbe, `export { adapter } from "../${target}/adapter";`); result = spawnSync("./node_modules/.bin/oxlint", ["--deny-warnings", serverProbe], { encoding: "utf8" }); diff --git a/src/features/tracingPractice/domain/lessons.ts b/src/features/writing/domain/lessons.ts similarity index 94% rename from src/features/tracingPractice/domain/lessons.ts rename to src/features/writing/domain/lessons.ts index c3a837f..3e439da 100644 --- a/src/features/tracingPractice/domain/lessons.ts +++ b/src/features/writing/domain/lessons.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { FINITE, LABEL } from "./constraints"; +import { FINITE, LABEL } from "./utils/constraints"; import { REFERENCE_STROKE } from "./strokes"; export const LESSON = Schema.Struct({ diff --git a/src/features/tracingPractice/domain/strokes.ts b/src/features/writing/domain/strokes.ts similarity index 95% rename from src/features/tracingPractice/domain/strokes.ts rename to src/features/writing/domain/strokes.ts index a629bf3..3e78727 100644 --- a/src/features/tracingPractice/domain/strokes.ts +++ b/src/features/writing/domain/strokes.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; import type { PointerEvent, RefObject } from "react"; -import { FINITE, LABEL } from "./constraints"; +import { FINITE, LABEL } from "./utils/constraints"; export const Point = Schema.Struct({ x: FINITE, y: FINITE }); export interface Point extends Schema.Schema.Type {} @@ -19,7 +19,7 @@ export const REFERENCE_STROKE = Schema.Struct({ }); export const ActiveStroke = Schema.Struct({ - pointerId: Schema.Number, + pointerId: FINITE, points: Schema.Array(Point), }); export interface ActiveStroke extends Schema.Schema.Type {} @@ -36,7 +36,7 @@ const ScreenMatrix = Schema.declare( ); export const Capture = Schema.Struct({ - pointerId: Schema.Number, + pointerId: FINITE, matrix: ScreenMatrix, }); export interface Capture extends Schema.Schema.Type {} diff --git a/src/features/tracingPractice/domain/constraints.ts b/src/features/writing/domain/utils/constraints.ts similarity index 67% rename from src/features/tracingPractice/domain/constraints.ts rename to src/features/writing/domain/utils/constraints.ts index 403763a..287708e 100644 --- a/src/features/tracingPractice/domain/constraints.ts +++ b/src/features/writing/domain/utils/constraints.ts @@ -1,4 +1,4 @@ import { Schema } from "effect"; -export const FINITE = Schema.Number.check(Schema.isFinite()); +export const FINITE = Schema.Finite; export const LABEL = Schema.String.check(Schema.makeFilter((value) => value.trim().length > 0)); diff --git a/src/features/tracingPractice/use-cases/tracing/components/Controls.tsx b/src/features/writing/use-case/tracing/components/Controls.tsx similarity index 100% rename from src/features/tracingPractice/use-cases/tracing/components/Controls.tsx rename to src/features/writing/use-case/tracing/components/Controls.tsx diff --git a/src/features/tracingPractice/use-cases/tracing/components/Explainer.tsx b/src/features/writing/use-case/tracing/components/Explainer.tsx similarity index 100% rename from src/features/tracingPractice/use-cases/tracing/components/Explainer.tsx rename to src/features/writing/use-case/tracing/components/Explainer.tsx diff --git a/src/features/tracingPractice/use-cases/tracing/components/Feedback.tsx b/src/features/writing/use-case/tracing/components/Feedback.tsx similarity index 100% rename from src/features/tracingPractice/use-cases/tracing/components/Feedback.tsx rename to src/features/writing/use-case/tracing/components/Feedback.tsx diff --git a/src/features/tracingPractice/use-cases/tracing/components/Header.tsx b/src/features/writing/use-case/tracing/components/Header.tsx similarity index 100% rename from src/features/tracingPractice/use-cases/tracing/components/Header.tsx rename to src/features/writing/use-case/tracing/components/Header.tsx diff --git a/src/features/tracingPractice/use-cases/tracing/components/TracingBox.tsx b/src/features/writing/use-case/tracing/components/TracingBox.tsx similarity index 100% rename from src/features/tracingPractice/use-cases/tracing/components/TracingBox.tsx rename to src/features/writing/use-case/tracing/components/TracingBox.tsx diff --git a/src/features/writing/use-case/tracing/constants/development-lesson.ts b/src/features/writing/use-case/tracing/constants/development-lesson.ts new file mode 100644 index 0000000..874f0bb --- /dev/null +++ b/src/features/writing/use-case/tracing/constants/development-lesson.ts @@ -0,0 +1,40 @@ +export const DEVELOPMENT_LESSON = { + id: "la-development-1", + glyph: "ल", + pronunciation: "la", + review: "pending", + bounds: { x: 0, y: 0, width: 100, height: 100 }, + strokes: [ + { + hint: "Start low on the left. Curve up and around to meet the right stem.", + points: [ + { x: 38, y: 73 }, + { x: 30, y: 66 }, + { x: 25, y: 57 }, + { x: 25, y: 48 }, + { x: 29, y: 41 }, + { x: 35, y: 38 }, + { x: 41, y: 41 }, + { x: 45, y: 48 }, + { x: 47, y: 57 }, + { x: 51, y: 49 }, + { x: 58, y: 44 }, + { x: 67, y: 44 }, + ], + }, + { + hint: "Draw the right stem from top to bottom.", + points: [ + { x: 67, y: 25 }, + { x: 67, y: 76 }, + ], + }, + { + hint: "Draw the headline from left to right.", + points: [ + { x: 22, y: 25 }, + { x: 77, y: 25 }, + ], + }, + ], +}; diff --git a/src/features/tracingPractice/use-cases/tracing/helpers.ts b/src/features/writing/use-case/tracing/helpers/index.ts similarity index 90% rename from src/features/tracingPractice/use-cases/tracing/helpers.ts rename to src/features/writing/use-case/tracing/helpers/index.ts index 5919f12..1487bcf 100644 --- a/src/features/tracingPractice/use-cases/tracing/helpers.ts +++ b/src/features/writing/use-case/tracing/helpers/index.ts @@ -1,6 +1,6 @@ import { Result, Schema } from "effect"; -import { type LessonContent, LESSON } from "../../domain/lessons"; -import type { Attempt, Point } from "../../domain/strokes"; +import { type LessonContent, LESSON } from "../../../domain/lessons"; +import type { Attempt, Point } from "../../../domain/strokes"; export const EMPTY: Attempt = { strokes: [], active: null, feedback: "ready" }; diff --git a/src/features/tracingPractice/use-cases/tracing/session.ts b/src/features/writing/use-case/tracing/helpers/session.ts similarity index 96% rename from src/features/tracingPractice/use-cases/tracing/session.ts rename to src/features/writing/use-case/tracing/helpers/session.ts index 8083c65..7eb993a 100644 --- a/src/features/tracingPractice/use-cases/tracing/session.ts +++ b/src/features/writing/use-case/tracing/helpers/session.ts @@ -1,5 +1,5 @@ -import type { Attempt, Point } from "../../domain/strokes"; -import { EMPTY } from "./helpers"; +import type { Attempt, Point } from "../../../domain/strokes"; +import { EMPTY } from "./index"; export interface Session { readonly attempt: Attempt; diff --git a/src/features/tracingPractice/use-cases/tracing/index.ts b/src/features/writing/use-case/tracing/hooks/use-drawing.ts similarity index 95% rename from src/features/tracingPractice/use-cases/tracing/index.ts rename to src/features/writing/use-case/tracing/hooks/use-drawing.ts index 1d9d9ab..5f533f3 100644 --- a/src/features/tracingPractice/use-cases/tracing/index.ts +++ b/src/features/writing/use-case/tracing/hooks/use-drawing.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import type { PointerEvent } from "react"; -import type { Capture, Drawing } from "../../domain/strokes"; -import { sameMatrix, toPoint } from "./helpers"; -import { INITIAL_SESSION, reduceSession } from "./session"; +import type { Capture, Drawing } from "../../../domain/strokes"; +import { sameMatrix, toPoint } from "../helpers"; +import { INITIAL_SESSION, reduceSession } from "../helpers/session"; export function useDrawing(): Drawing { const padRef = useRef(null); diff --git a/src/features/writing/use-case/tracing/index.ts b/src/features/writing/use-case/tracing/index.ts new file mode 100644 index 0000000..8b6385e --- /dev/null +++ b/src/features/writing/use-case/tracing/index.ts @@ -0,0 +1,9 @@ +export { Controls } from "./components/Controls"; +export { Explainer } from "./components/Explainer"; +export { Feedback } from "./components/Feedback"; +export { Header } from "./components/Header"; +export { TracingBox } from "./components/TracingBox"; +export { DEVELOPMENT_LESSON } from "./constants/development-lesson"; +export { parseLesson } from "./helpers"; +export { feedbackMessage } from "./helpers/session"; +export { useDrawing } from "./hooks/use-drawing"; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 7bfd372..9f54b03 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,14 +1,16 @@ import "../styles.css"; -import { DEVELOPMENT_LESSON } from "../features/tracingPractice/use-cases/tracing/development-lesson"; -import { parseLesson } from "../features/tracingPractice/use-cases/tracing/helpers"; -import type { Lesson } from "../features/tracingPractice/domain/lessons"; -import { useDrawing } from "../features/tracingPractice/use-cases/tracing"; -import { feedbackMessage } from "../features/tracingPractice/use-cases/tracing/session"; -import { Controls } from "../features/tracingPractice/use-cases/tracing/components/Controls"; -import { Explainer } from "../features/tracingPractice/use-cases/tracing/components/Explainer"; -import { Feedback } from "../features/tracingPractice/use-cases/tracing/components/Feedback"; -import { Header } from "../features/tracingPractice/use-cases/tracing/components/Header"; -import { TracingBox } from "../features/tracingPractice/use-cases/tracing/components/TracingBox"; +import type { Lesson } from "../features/writing/domain/lessons"; +import { + Controls, + DEVELOPMENT_LESSON, + Explainer, + Feedback, + Header, + TracingBox, + feedbackMessage, + parseLesson, + useDrawing, +} from "../features/writing/use-case/tracing"; export default function HomePage() { const content = parseLesson(DEVELOPMENT_LESSON); From d389cf5944a4399c75067a5a5c39b826059b54e0 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 20:06:03 +0530 Subject: [PATCH 14/22] feat: check whole characters with visual feedback --- AGENTS.md | 9 +- DESIGN.md | 6 +- PRODUCT.md | 10 +- .../__tests__/browser/checking.spec.ts | 140 ++++++++++++++++++ .../__tests__/browser/practice.spec.ts | 4 +- .../writing/__tests__/browser/tracing.spec.ts | 91 +++++++++--- .../__tests__/content/check-character.test.ts | 54 +++++++ .../writing/__tests__/content/lesson.test.ts | 8 +- .../writing/__tests__/content/session.test.ts | 67 ++++----- src/features/writing/domain/lessons.ts | 14 +- src/features/writing/domain/strokes.ts | 8 +- .../domain/utils/strokes-within-bounds.ts | 24 +++ .../use-case/tracing/components/Controls.tsx | 28 ++-- .../use-case/tracing/components/Explainer.tsx | 69 +-------- .../use-case/tracing/components/Feedback.tsx | 42 +++++- .../tracing/components/TracingBox.tsx | 86 ++++------- .../use-case/tracing/components/feedback.css | 104 +++++++++++++ .../tracing/constants/development-lesson.ts | 3 - .../tracing/helpers/check-character.ts | 54 +++++++ .../use-case/tracing/helpers/session.ts | 32 ++-- .../use-case/tracing/hooks/use-drawing.ts | 14 +- .../writing/use-case/tracing/index.ts | 11 +- src/pages/index.tsx | 12 +- src/styles.css | 4 + 24 files changed, 626 insertions(+), 268 deletions(-) create mode 100644 src/features/writing/__tests__/browser/checking.spec.ts create mode 100644 src/features/writing/__tests__/content/check-character.test.ts create mode 100644 src/features/writing/domain/utils/strokes-within-bounds.ts create mode 100644 src/features/writing/use-case/tracing/components/feedback.css create mode 100644 src/features/writing/use-case/tracing/helpers/check-character.ts diff --git a/AGENTS.md b/AGENTS.md index ac0b181..9cb2831 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,15 +19,16 @@ Keep named features under `src/features/`. Start small: a whole feature can begi ```text src/features/installation/ index.ts # Public API and feature wiring - domain/ # Business types, schemas, rules, and business errors + domain/ # Entities, value objects, schemas, and their invariants use-case/ # Business actions; coordinate domain rules and services services/ # Effect service contracts, implementations, and Layers src/main.ts # Compose Layers and start the app ``` -- Domain code must not depend on HTTP, databases, use cases, or service implementations. -- Use cases depend on service contracts and domain rules. Keep SQL and external API details in services. +- `domain/` owns entities, value objects, their schemas, invariants, and business errors. Keep rules intrinsic to those models here; purity alone does not make a function domain code. Domain code must not depend on HTTP, databases, use cases, or service implementations. +- `use-case/` owns application actions and their supporting algorithms. Put exercise-specific operations such as character matching and its tolerance policy in the owning use case's `helpers/`, even when they are pure functions. +- Use cases depend on service contracts and domain rules. Keep SQL and external API details in services. Expose intended public operations through the owning use case's `index.ts`; external callers use that API, while internal files may import sibling helpers directly. - Keep each service's contract, implementation, and Layer together by default. Acquire external resources through Effect, never as import-time side effects. - Use these names: `use-case/` and `services/`. @@ -42,4 +43,4 @@ src/main.ts # Compose Layers and start the app - Use cases and services may each own `helpers/` for named supporting operations and `lib/` for files such as `errors.ts`, `utils.ts`, and `constants.ts`. These files follow the owning module's dependency boundaries. - Create only needed files and folders. Keep supporting code local; do not scaffold empty directories or speculative abstractions. -Verify changed behavior with appropriate checks and keep tests near their owner. \ No newline at end of file +Verify changed behavior with appropriate checks and keep tests near their owner. diff --git a/DESIGN.md b/DESIGN.md index 8fd9f4d..741f80b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -76,7 +76,7 @@ components: **Creative North Star: "Printmaker's proof"** -A cool white working field, cobalt ink, registration corners, and explicit directed-stroke proofs. The user delegated the visual choice on 2026-10-22. The reference metaphor explains aligned drawing and repeated attempts; it introduces no printing terminology into learner instructions. +A cool white working field, cobalt ink, registration corners, and a faint whole-letter reference. The user delegated the visual choice on 2026-10-22. The reference metaphor explains aligned drawing and repeated attempts; it introduces no printing terminology into learner instructions. ## Colors @@ -84,7 +84,7 @@ White owns the writing surface; a cool neutral ground separates the workspace fr ## Typography -Use system sans for controls and instructions, with readable fixed rem sizes. Pronunciation uses the title size on phones. The lesson glyph and reference paths carry the visual identity. Use the platform Devanagari fallback for the glyph label; the actual teaching paths are authored SVG geometry, never a font outline. Numbered SVG hints use four lesson-coordinate units, scaling with the drawing frame. +Use system sans for controls and instructions, with readable fixed rem sizes. Pronunciation uses the title size on phones. The lesson glyph and reference paths carry the visual identity. Use the platform Devanagari fallback for the glyph label; the actual teaching paths are authored SVG geometry, never a font outline. ## Layout @@ -100,7 +100,7 @@ Keep the pad geometrically square with modest corner treatment. Controls have co ## Components -The pad is a real SVG with a shared lesson coordinate system. Reference geometry is faint; learner ink is cobalt. Reveal exposes numbered directed reference strokes without marking a stroke accepted or completing practice; the learner may trace over the revealed reference. Buttons use text labels, visible focus, hover and pressed states. They have a minimum touch height of 48px; preparation disables them visibly. Feedback uses readable text with a stable position to avoid layout movement during drawing. +The pad is a real SVG with a shared lesson coordinate system. Reference geometry is faint; learner ink is cobalt. The whole-letter guide stays faint throughout practice, with no numbered strokes or direction arrows. Buttons use text labels, visible focus, hover and pressed states. They have a minimum touch height of 48px; preparation disables them visibly. Feedback uses readable text with a stable position to avoid layout movement during drawing. ## Do's and Don'ts diff --git a/PRODUCT.md b/PRODUCT.md index 1ffc2d0..d14751a 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -12,7 +12,7 @@ A Hindi learner practicing isolated letters in a mobile browser, using a finger ## Product Purpose -Strokes makes one Hindi letter available for tracing, revealing, retrying, and eventually checking one directed stroke at a time. Success means reliable local interaction and honest feedback against original, reviewed lesson content. +Strokes makes one Hindi letter available for tracing, retrying, and checking the whole character. Success means reliable local interaction and honest feedback against original, reviewed lesson content. ## Operating Context @@ -21,10 +21,10 @@ The writing surface must work on a phone, allow page scrolling outside the pad, ## Capabilities and Constraints - Void and React own the app; deterministic geometry stays independent. Effect owns parsing boundaries, external work, and typed failures when needed. -- The checked-in lesson owns the teaching sequence. React owns the temporary attempt. There are no accounts, uploads, durable progress, analytics, or database. -- One original letter comes first. The specific letter and reviewed sequence are open; any development fixture must carry an explicit unreviewed label. -- Clear and Retry reset the exercise. Reveal shows ordered reference strokes without accepting input or completing practice. -- Stroke direction, position, and scale matter. Tolerances remain provisional until original finger and stylus recordings support calibration. +- The checked-in lesson owns the reference geometry. React owns the temporary attempt. There are no accounts, uploads, durable progress, analytics, or database. +- One original letter comes first. The specific letter and reviewed shape are open; any development fixture must carry an explicit unreviewed label. +- Check compares the whole character to its guide; Retry resets the exercise. The faint whole-letter guide stays visible throughout practice. +- The overall character shape matters; stroke order and direction are unrestricted. Tolerances remain provisional until original finger and stylus recordings support calibration. ## Brand Commitments diff --git a/src/features/writing/__tests__/browser/checking.spec.ts b/src/features/writing/__tests__/browser/checking.spec.ts new file mode 100644 index 0000000..06940ef --- /dev/null +++ b/src/features/writing/__tests__/browser/checking.spec.ts @@ -0,0 +1,140 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "./fixtures"; + +type Stroke = readonly (readonly [number, number])[]; + +// Independent learner attempts in the pad's 0–100 coordinate space, with small deviations. +const curve: Stroke = [ + [39, 72], + [31, 65], + [26, 56], + [26, 49], + [30, 42], + [35, 39], + [40, 42], + [44, 49], + [48, 56], + [52, 50], + [59, 45], + [66, 45], +]; +const stem: Stroke = [ + [68, 26], + [68, 50], + [66, 75], +]; +const headline: Stroke = [ + [23, 26], + [49, 24], + [76, 26], +]; +const letter = [curve, stem, headline]; +const extraInk: Stroke = [ + [8, 8], + [90, 90], +]; +const accepted = "That’s it! You matched the letter."; +const rejected = "Not quite yet. Follow the whole shape and try again."; + +async function draw(page: Page, strokes: readonly Stroke[]): Promise { + const pad = page.getByRole("img", { name: "Hindi writing practice area" }); + await pad.scrollIntoViewIfNeeded(); + const bounds = await pad.boundingBox(); + if (!bounds) throw new Error("Writing pad has no bounds"); + for (const stroke of strokes) { + for (const [index, [x, y]] of stroke.entries()) { + await page.mouse.move( + bounds.x + (bounds.width * x) / 100, + bounds.y + (bounds.height * y) / 100, + { steps: index === 0 ? 1 : 3 }, + ); + if (index === 0) await page.mouse.down(); + } + await page.mouse.up(); + } +} + +const attempts: readonly { + readonly name: string; + readonly strokes: readonly Stroke[]; + readonly result: string; +}[] = [ + { + name: "accepts a complete character with small drawing deviations", + strokes: letter, + result: accepted, + }, + { + name: "accepts reversed direction and a different stroke order", + strokes: [...letter].reverse().map((stroke) => [...stroke].reverse()), + result: accepted, + }, + { name: "rejects a missing headline", strokes: [curve, stem], result: rejected }, + { name: "rejects a single dot", strokes: [[[68, 26]]], result: rejected }, + { + name: "rejects a misplaced character", + strokes: letter.map((stroke) => stroke.map(([x, y]) => [x + 18, y])), + result: rejected, + }, + { + name: "rejects extra ink even with a complete character", + strokes: [...letter, extraInk], + result: rejected, + }, +]; + +for (const viewport of [ + { name: "desktop", width: 1200, height: 1000 }, + { name: "phone", width: 390, height: 844 }, +]) { + test.describe(viewport.name, () => { + test.use({ viewport: { width: viewport.width, height: viewport.height } }); + + for (const attempt of attempts) { + test(attempt.name, async ({ page }) => { + await page.goto("/"); + const status = page.getByRole("status"); + const check = page.getByRole("button", { name: "Check", exact: true }); + await expect(status).toContainText("Ready to trace"); + await expect(check).toBeDisabled(); + await draw(page, attempt.strokes); + await expect(check).toBeEnabled(); + await check.click(); + await expect(status).toContainText(attempt.result); + await check.click(); + await expect(status).toContainText(attempt.result); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(status).toContainText("Ready to trace"); + await expect(check).toBeDisabled(); + }); + } + + test("a rejected character can be completed without resetting", async ({ page }) => { + await page.goto("/"); + const status = page.getByRole("status"); + const check = page.getByRole("button", { name: "Check", exact: true }); + await expect(status).toContainText("Ready to trace"); + await draw(page, [curve, stem]); + await check.click(); + await expect(status).toContainText(rejected); + await draw(page, [headline]); + await expect(status).toContainText("3 strokes drawn"); + await check.click(); + await expect(status).toContainText(accepted); + }); + + test("new ink clears success and is included in the next check", async ({ page }) => { + await page.goto("/"); + const status = page.getByRole("status"); + const check = page.getByRole("button", { name: "Check", exact: true }); + await expect(status).toContainText("Ready to trace"); + await draw(page, letter); + await check.click(); + await expect(status).toContainText(accepted); + await draw(page, [extraInk]); + await expect(status).toContainText("4 strokes drawn"); + await check.click(); + await expect(status).toContainText(rejected); + }); + }); +} diff --git a/src/features/writing/__tests__/browser/practice.spec.ts b/src/features/writing/__tests__/browser/practice.spec.ts index f43b717..ea99216 100644 --- a/src/features/writing/__tests__/browser/practice.spec.ts +++ b/src/features/writing/__tests__/browser/practice.spec.ts @@ -5,7 +5,9 @@ test("opens a readable practice page with a square pad on phone and desktop", as await page.setViewportSize(viewport); await page.goto("/"); await expect(page.getByRole("heading", { name: "Strokes", exact: true })).toBeVisible(); - await expect(page.getByText("Practice Hindi handwriting with your finger, pen, or mouse.")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Focus on the whole letter" })).toBeVisible(); + await expect(page.getByText(/Take your time with the curves and lines/)).toHaveCount(1); + await expect(page.getByText(/Your ink stays on this page/)).toHaveCount(0); const pad = page.getByRole("img", { name: "Hindi writing practice area" }); await expect(pad).toBeVisible(); const bounds = await pad.boundingBox(); diff --git a/src/features/writing/__tests__/browser/tracing.spec.ts b/src/features/writing/__tests__/browser/tracing.spec.ts index 426e50a..fc699c6 100644 --- a/src/features/writing/__tests__/browser/tracing.spec.ts +++ b/src/features/writing/__tests__/browser/tracing.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures"; +import { DEVELOPMENT_LESSON } from "../../use-case/tracing/constants/development-lesson"; import type { Locator } from "@playwright/test"; async function inkScreenshot(pad: Locator): Promise<{ readonly count: number; readonly bounds: { readonly left: number; readonly top: number; readonly right: number; readonly bottom: number } }> { @@ -57,28 +58,34 @@ test("shows live local ink, separate gestures, and a clear tracing-only reset", await page.mouse.move(bounds.x + bounds.width * 0.65, bounds.y + bounds.height * 0.7, { steps: 6 }); await page.mouse.up(); await expect(page.getByRole("status")).toContainText("2 strokes drawn"); - await page.getByRole("button", { name: "Clear", exact: true }).click(); + await page.getByRole("button", { name: "Retry", exact: true }).click(); await expect(page.getByRole("status")).toContainText("Ready to trace"); expect(await inkPixels(pad)).toBe(0); }); -test("Reveal exposes ordered direction hints without checking or completing, and repeated Retry resets it", async ({ page }) => { +test("whole-letter guidance has no reveal or ordered hints, and Retry resets ink", async ({ page }) => { await page.goto("/"); - const reveal = page.getByRole("button", { name: "Reveal", exact: true }); - await reveal.click(); - await expect(reveal).toHaveAttribute("aria-pressed", "true"); - await expect(page.getByRole("listitem")).toHaveCount(3); - await expect(page.getByText("Tracing only. Your writing is not checked or scored.", { exact: true })).toBeVisible(); - await expect(page.getByRole("status")).toHaveText("Reference shown. Trace along it, or choose Retry for the faint guide."); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + await expect(page.getByRole("button", { name: "Reveal", exact: true })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Focus on the whole letter" })).toBeVisible(); + await expect(page.getByText(/in any stroke order/)).toBeVisible(); + await expect(page.getByRole("listitem")).toHaveCount(0); + const pad = page.getByRole("img", { name: "Hindi writing practice area" }); + const bounds = await pad.boundingBox(); + if (!bounds) throw new Error("No writing pad"); + await page.mouse.move(bounds.x + 40, bounds.y + 40); + await page.mouse.down(); + await page.mouse.move(bounds.x + 100, bounds.y + 100, { steps: 5 }); + await page.mouse.up(); + await expect(page.getByRole("status")).toContainText("1 stroke drawn"); for (let repeat = 0; repeat < 2; repeat++) { await page.getByRole("button", { name: "Retry", exact: true }).click(); - await expect(reveal).toHaveAttribute("aria-pressed", "false"); await expect(page.getByRole("status")).toContainText("Ready to trace"); - await reveal.click(); + expect(await inkPixels(pad)).toBe(0); } }); -for (const interruption of ["cancel", "capture loss", "blur", "resize", "layout shift", "Clear", "Retry"] as const) { +for (const interruption of ["cancel", "capture loss", "blur", "resize", "layout shift", "Retry"] as const) { test(`${interruption} discards unfinished ink and permits the next gesture`, async ({ page }) => { await page.goto("/"); await expect(page.getByRole("status")).toContainText("Ready to trace"); @@ -113,11 +120,11 @@ for (const interruption of ["cancel", "capture loss", "blur", "resize", "layout await pad.evaluate((element) => { element.style.transform = "translateY(20px)"; }); await page.mouse.move(start.x + 180, start.y + 60); } - if (interruption === "Clear" || interruption === "Retry") { + if (interruption === "Retry") { await page.getByRole("button", { name: interruption, exact: true }).focus(); await page.keyboard.press("Enter"); } - await expect(page.getByRole("status")).toContainText(interruption === "Clear" || interruption === "Retry" ? "Ready to trace" : "Stroke interrupted"); + await expect(page.getByRole("status")).toContainText(interruption === "Retry" ? "Ready to trace" : "Stroke interrupted"); await page.mouse.up(); if (interruption === "cancel" || interruption === "capture loss" || interruption === "blur") { // Pixel coverage tolerates subpixel rasterization while detecting leftover unfinished ink. @@ -137,7 +144,7 @@ for (const interruption of ["cancel", "capture loss", "blur", "resize", "layout await page.mouse.down(); await page.mouse.move(nextBounds.x + 80, nextBounds.y + 80, { steps: 4 }); await page.mouse.up(); - await expect(page.getByRole("status")).toContainText(interruption === "Clear" || interruption === "Retry" ? "1 stroke drawn" : "2 strokes drawn"); + await expect(page.getByRole("status")).toContainText(interruption === "Retry" ? "1 stroke drawn" : "2 strokes drawn"); }); } @@ -179,14 +186,60 @@ test("phone layout supports drawing, reset, and visible keyboard focus", async ( await expect(page.getByRole("status")).toContainText("1 stroke drawn"); expect(await inkPixels(pad)).toBeGreaterThan(100); await page.keyboard.press("Tab"); - const clear = page.getByRole("button", { name: "Clear", exact: true }); + const clear = page.getByRole("button", { name: "Check", exact: true }); await expect(clear).toBeFocused(); expect(await clear.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("solid"); + await page.keyboard.press("Tab"); + const retry = page.getByRole("button", { name: "Retry", exact: true }); + await expect(retry).toBeFocused(); await page.keyboard.press("Enter"); await expect(page.getByRole("status")).toContainText("Ready to trace"); expect(await inkPixels(pad)).toBe(0); - await page.keyboard.press("Tab"); - await expect(page.getByRole("button", { name: "Reveal", exact: true })).toBeFocused(); - await page.keyboard.press("Tab"); - await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeFocused(); }); + +for (const reducedMotion of ["no-preference", "reduce"] as const) { + test(`Check gives whole-character feedback and Retry resets it (${reducedMotion})`, async ({ page }) => { + await page.emulateMedia({ reducedMotion }); + if (reducedMotion === "reduce") await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + const check = page.getByRole("button", { name: "Check", exact: true }); + const retry = page.getByRole("button", { name: "Retry", exact: true }); + await expect(check).toBeDisabled(); + await expect(page.getByRole("button", { name: "Clear", exact: true })).toHaveCount(0); + const pad = page.getByRole("img", { name: "Hindi writing practice area" }); + const bounds = await pad.boundingBox(); + if (!bounds) throw new Error("No pad"); + await page.mouse.move(bounds.x + 20, bounds.y + 20); + await page.mouse.down(); + await expect(check).toBeDisabled(); + await page.mouse.move(bounds.x + 80, bounds.y + 20, { steps: 6 }); + await page.mouse.up(); + await check.click(); + await expect(page.getByRole("status")).toContainText("Not quite yet"); + await expect.poll(() => pad.evaluate((element) => getComputedStyle(element.parentElement ?? element).backgroundColor)).toBe("rgb(252, 234, 234)"); + if (reducedMotion === "reduce") expect(await pad.evaluate((element) => element.parentElement?.getAnimations({ subtree: true }).length)).toBe(0); + await retry.click(); + for (const stroke of [...DEVELOPMENT_LESSON.strokes].reverse()) { + const points = [...stroke.points].reverse(); + for (const [index, point] of points.entries()) { + await page.mouse.move(bounds.x + point.x / 100 * bounds.width, bounds.y + point.y / 100 * bounds.height); + if (index === 0) await page.mouse.down(); + } + await page.mouse.up(); + } + await check.click(); + await expect(page.getByRole("status")).toContainText("That’s it! You matched the letter."); + await expect.poll(() => pad.evaluate((element) => getComputedStyle(element.parentElement ?? element).backgroundColor)).toBe("rgb(229, 245, 233)"); + expect(await inkPixels(pad)).toBeGreaterThan(100); + if (reducedMotion === "reduce") { + expect(await pad.evaluate((element) => element.parentElement?.getAnimations({ subtree: true }).length)).toBe(0); + expect(await page.getByRole("status").evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0); + } + await retry.click(); + await expect.poll(() => pad.evaluate((element) => getComputedStyle(element.parentElement ?? element).backgroundColor)).toBe("rgb(255, 255, 255)"); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + await expect(check).toBeDisabled(); + expect(await inkPixels(pad)).toBe(0); + }); +} diff --git a/src/features/writing/__tests__/content/check-character.test.ts b/src/features/writing/__tests__/content/check-character.test.ts new file mode 100644 index 0000000..89b67fb --- /dev/null +++ b/src/features/writing/__tests__/content/check-character.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "vitest"; +import { checkCharacter } from "../../use-case/tracing"; +import { DEVELOPMENT_LESSON } from "../../use-case/tracing/constants/development-lesson"; +import { parseLesson } from "../../use-case/tracing/helpers"; + +const content = parseLesson(DEVELOPMENT_LESSON); +if (content.kind !== "ready") throw new Error("Invalid development lesson"); +const lesson = content.lesson; +const reference = lesson.strokes.map((stroke) => stroke.points); + +test("accepts the whole shape with any stroke order, direction, or segmentation", () => { + expect(checkCharacter(lesson, reference)).toBe(true); + expect( + checkCharacter( + lesson, + [...reference].reverse().map((stroke) => [...stroke].reverse()), + ), + ).toBe(true); + expect( + checkCharacter( + lesson, + reference.flatMap((stroke) => + stroke.slice(1).map((end, index) => [stroke[index] ?? end, end]), + ), + ), + ).toBe(true); + expect( + checkCharacter( + lesson, + reference.map((stroke) => stroke.map((point) => ({ x: point.x + 2, y: point.y - 2 }))), + ), + ).toBe(true); +}); + +test("rejects empty, incomplete, misplaced, and extra ink", () => { + expect(checkCharacter(lesson, [])).toBe(false); + expect(checkCharacter(lesson, [[{ x: 67, y: 25 }]])).toBe(false); + expect(checkCharacter(lesson, reference.slice(1))).toBe(false); + expect( + checkCharacter( + lesson, + reference.map((stroke) => stroke.map((point) => ({ x: point.x + 18, y: point.y }))), + ), + ).toBe(false); + expect( + checkCharacter(lesson, [ + ...reference, + [ + { x: 0, y: 0 }, + { x: 100, y: 100 }, + ], + ]), + ).toBe(false); +}); diff --git a/src/features/writing/__tests__/content/lesson.test.ts b/src/features/writing/__tests__/content/lesson.test.ts index 402a92a..194815b 100644 --- a/src/features/writing/__tests__/content/lesson.test.ts +++ b/src/features/writing/__tests__/content/lesson.test.ts @@ -5,7 +5,7 @@ import { ErrorBox } from "../../../../pages/index"; import { DEVELOPMENT_LESSON } from "../../use-case/tracing/constants/development-lesson"; import { parseLesson } from "../../use-case/tracing/helpers"; -test("the original development lesson parses with honest review status and ordered strokes", () => { +test("the original development lesson parses with honest review status and reference geometry", () => { const result = parseLesson(DEVELOPMENT_LESSON); expect(result.kind).toBe("ready"); if (result.kind !== "ready") throw new Error("Development lesson did not parse"); @@ -57,19 +57,17 @@ test("unusable content fails before interaction instead of becoming handwriting } }); -test("the content boundary preserves the authored teaching order", () => { +test("the content boundary preserves the reference geometry", () => { const result = parseLesson({ ...DEVELOPMENT_LESSON, strokes: [ { - hint: "Right to left", points: [ { x: 70, y: 20 }, { x: 40, y: 20 }, ], }, { - hint: "Down", points: [ { x: 10, y: 30 }, { x: 10, y: 70 }, @@ -78,7 +76,7 @@ test("the content boundary preserves the authored teaching order", () => { ], }); expect(result.kind).toBe("ready"); - if (result.kind !== "ready") throw new Error("Authored sequence did not parse"); + if (result.kind !== "ready") throw new Error("Reference geometry did not parse"); expect(result.lesson.strokes.map((stroke) => stroke.points[0])).toEqual([ { x: 70, y: 20 }, { x: 10, y: 30 }, diff --git a/src/features/writing/__tests__/content/session.test.ts b/src/features/writing/__tests__/content/session.test.ts index dbafe0d..03534ce 100644 --- a/src/features/writing/__tests__/content/session.test.ts +++ b/src/features/writing/__tests__/content/session.test.ts @@ -1,4 +1,6 @@ import { expect, test } from "vitest"; +import { DEVELOPMENT_LESSON } from "../../use-case/tracing/constants/development-lesson"; +import { parseLesson } from "../../use-case/tracing/helpers"; import { EMPTY } from "../../use-case/tracing/helpers"; import { type Session, @@ -16,18 +18,13 @@ function apply(actions: readonly SessionAction[], session: Session = INITIAL_SES return actions.reduce(reduceSession, session); } -test("a stroke starts, extends, and commits without clearing the reference", () => { - const revealed = reduceSession(INITIAL_SESSION, { type: "reveal", interrupt: false }); - const committed = apply( - [ - { type: "start", pointerId: 1, point: a }, - { type: "extend", point: b }, - { type: "commit", point: c }, - ], - revealed, - ); +test("a stroke starts, extends, and commits", () => { + const committed = apply([ + { type: "start", pointerId: 1, point: a }, + { type: "extend", point: b }, + { type: "commit", point: c }, + ]); - expect(committed.revealed).toBe(true); expect(committed.attempt).toEqual({ strokes: [[a, b, c]], active: null, @@ -40,23 +37,8 @@ test("extend and commit leave a session unchanged when no stroke is active", () expect(reduceSession(INITIAL_SESSION, { type: "commit", point: a })).toBe(INITIAL_SESSION); }); -test("reveal keeps finished strokes and only interrupts an active stroke", () => { - const drawing = apply([ - { type: "start", pointerId: 1, point: a }, - { type: "commit", point: b }, - { type: "start", pointerId: 2, point: c }, - ]); - const shown = reduceSession(drawing, { type: "reveal", interrupt: true }); - - expect(shown.revealed).toBe(true); - expect(shown.attempt.strokes).toEqual([[a, b]]); - expect(shown.attempt.active).toBeNull(); - expect(shown.attempt.feedback).toBe("interrupted"); -}); - -test("reset clears the attempt and the reference together", () => { +test("reset clears the attempt", () => { const session = apply([ - { type: "reveal", interrupt: false }, { type: "start", pointerId: 1, point: a }, { type: "commit", point: b }, { type: "reset" }, @@ -68,22 +50,15 @@ test("reset clears the attempt and the reference together", () => { test("feedback follows readiness, then the stroke, then how much is drawn", () => { const idle = INITIAL_SESSION; - const revealed = reduceSession(idle, { type: "reveal", interrupt: false }); - const drawn = apply( - [ - { type: "start", pointerId: 1, point: a }, - { type: "commit", point: b }, - ], - revealed, - ); + const drawn = apply([ + { type: "start", pointerId: 1, point: a }, + { type: "commit", point: b }, + ]); const drawing = reduceSession(drawn, { type: "start", pointerId: 3, point: c }); const interrupted = reduceSession(drawing, { type: "interrupt" }); expect(feedbackMessage({ ...idle, ready: false })).toBe("Preparing the writing area…"); expect(feedbackMessage({ ...idle, ready: true })).toBe("Ready to trace. Follow the faint guide."); - expect(feedbackMessage({ ...revealed, ready: true })).toBe( - "Reference shown. Trace along it, or choose Retry for the faint guide.", - ); expect(feedbackMessage({ ...drawn, ready: true })).toBe( "1 stroke drawn. Keep tracing or start again.", ); @@ -95,3 +70,19 @@ test("feedback follows readiness, then the stroke, then how much is drawn", () = ); expect(feedbackMessage({ ...interrupted, ready: false })).toBe("Preparing the writing area…"); }); + +test("checking ignores empty and active attempts, and drawing or retry clears the result", () => { + const content = parseLesson(DEVELOPMENT_LESSON); + if (content.kind !== "ready") throw new Error("Invalid lesson"); + const action: SessionAction = { type: "check", lesson: content.lesson }; + expect(reduceSession(INITIAL_SESSION, action)).toBe(INITIAL_SESSION); + const drawing = reduceSession(INITIAL_SESSION, { type: "start", pointerId: 1, point: a }); + expect(reduceSession(drawing, action)).toBe(drawing); + const committed = reduceSession(drawing, { type: "commit", point: b }); + const checked = reduceSession(committed, action); + expect(checked.attempt.feedback).toBe("incorrect"); + expect(reduceSession(checked, { type: "start", pointerId: 2, point: c }).attempt.feedback).toBe( + "drawing", + ); + expect(reduceSession(checked, { type: "reset" })).toBe(INITIAL_SESSION); +}); diff --git a/src/features/writing/domain/lessons.ts b/src/features/writing/domain/lessons.ts index 3e439da..211c00c 100644 --- a/src/features/writing/domain/lessons.ts +++ b/src/features/writing/domain/lessons.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { FINITE, LABEL } from "./utils/constraints"; +import { STROKES_WITHIN_BOUNDS } from "./utils/strokes-within-bounds"; import { REFERENCE_STROKE } from "./strokes"; export const LESSON = Schema.Struct({ @@ -15,18 +16,7 @@ export const LESSON = Schema.Struct({ }), strokes: Schema.NonEmptyArray(REFERENCE_STROKE), }) - .check( - Schema.makeFilter((lesson) => { - const { x, y, width, height } = lesson.bounds; - if (width <= 0 || height <= 0 || !Number.isFinite(x + width) || !Number.isFinite(y + height)) - return false; - return lesson.strokes.every((stroke) => - stroke.points.every( - (point) => point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height, - ), - ); - }), - ) + .check(STROKES_WITHIN_BOUNDS) .pipe(Schema.brand("Lesson")); export interface Lesson extends Schema.Schema.Type {} diff --git a/src/features/writing/domain/strokes.ts b/src/features/writing/domain/strokes.ts index 3e78727..a64e45e 100644 --- a/src/features/writing/domain/strokes.ts +++ b/src/features/writing/domain/strokes.ts @@ -1,12 +1,11 @@ import { Schema } from "effect"; import type { PointerEvent, RefObject } from "react"; -import { FINITE, LABEL } from "./utils/constraints"; +import { FINITE } from "./utils/constraints"; export const Point = Schema.Struct({ x: FINITE, y: FINITE }); export interface Point extends Schema.Schema.Type {} export const REFERENCE_STROKE = Schema.Struct({ - hint: LABEL, points: Schema.NonEmptyArray(Point).check( Schema.makeFilter((points) => { const length = points.reduce((total, point, index) => { @@ -27,7 +26,7 @@ export interface ActiveStroke extends Schema.Schema.Type {} export const Attempt = Schema.Struct({ strokes: Schema.Array(Schema.Array(Point)), active: Schema.NullOr(ActiveStroke), - feedback: Schema.Literals(["ready", "drawing", "interrupted"]), + feedback: Schema.Literals(["ready", "drawing", "interrupted", "correct", "incorrect"]), }); export interface Attempt extends Schema.Schema.Type {} @@ -54,9 +53,8 @@ export const Drawing = Schema.Struct({ padRef: PadRef, ready: Schema.Boolean, attempt: Attempt, - revealed: Schema.Boolean, reset: Action, - reveal: Action, + check: Action, abort: Action, onPointerDown: PointerHandler, onPointerMove: PointerHandler, diff --git a/src/features/writing/domain/utils/strokes-within-bounds.ts b/src/features/writing/domain/utils/strokes-within-bounds.ts new file mode 100644 index 0000000..e811e41 --- /dev/null +++ b/src/features/writing/domain/utils/strokes-within-bounds.ts @@ -0,0 +1,24 @@ +import { Schema } from "effect"; + +type BoundedStrokes = { + readonly bounds: { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + }; + readonly strokes: ReadonlyArray<{ + readonly points: ReadonlyArray<{ readonly x: number; readonly y: number }>; + }>; +}; + +export const STROKES_WITHIN_BOUNDS = Schema.makeFilter((lesson: BoundedStrokes) => { + const { x, y, width, height } = lesson.bounds; + if (width <= 0 || height <= 0 || !Number.isFinite(x + width) || !Number.isFinite(y + height)) + return false; + return lesson.strokes.every((stroke) => + stroke.points.every( + (point) => point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height, + ), + ); +}); diff --git a/src/features/writing/use-case/tracing/components/Controls.tsx b/src/features/writing/use-case/tracing/components/Controls.tsx index 7168045..e794989 100644 --- a/src/features/writing/use-case/tracing/components/Controls.tsx +++ b/src/features/writing/use-case/tracing/components/Controls.tsx @@ -1,33 +1,23 @@ export function Controls({ ready, - revealed, + canCheck, + onCheck, onReset, - onReveal, }: { readonly ready: boolean; - readonly revealed: boolean; + readonly canCheck: boolean; + readonly onCheck: () => void; readonly onReset: () => void; - readonly onReveal: () => void; }) { return ( -
- +
); diff --git a/src/styles.css b/src/styles.css index 847ee34..c5090d7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -12,6 +12,10 @@ --color-accent-dark: #17349e; --color-accent-soft: #e3e9ff; --color-accent-pressed: #d3ddff; + --color-success: #166534; + --color-success-soft: #e5f5e9; + --color-retry: #a52626; + --color-retry-soft: #fceaea; --breakpoint-desktop: 760.01px; } From 9ce5c795de1292399e69dcdef1d56d8ef4b7c6a6 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 21:23:06 +0530 Subject: [PATCH 15/22] fix: use the device viewport on mobile Test touch layouts with mobile browser emulation. Run browser tests against the production preview to avoid dev-module startup timeouts. --- playwright.config.ts | 2 +- .../writing/__tests__/browser/mobile.spec.ts | 53 +++++++++++++++++++ void.config.ts | 13 +++-- 3 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 src/features/writing/__tests__/browser/mobile.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index 65d792b..b7fa7f8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ use: { baseURL: "http://127.0.0.1:4173", trace: "retain-on-failure" }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], webServer: { - command: "pnpm dev --host 127.0.0.1 --port 4173 --strictPort", + command: "pnpm build && pnpm preview --host 127.0.0.1 --port 4173 --strictPort", url: "http://127.0.0.1:4173", reuseExistingServer: false, timeout: 60_000, diff --git a/src/features/writing/__tests__/browser/mobile.spec.ts b/src/features/writing/__tests__/browser/mobile.spec.ts new file mode 100644 index 0000000..530cc00 --- /dev/null +++ b/src/features/writing/__tests__/browser/mobile.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from "./fixtures"; + +for (const viewport of [ + { width: 320, height: 568 }, + { width: 390, height: 844 }, + { width: 430, height: 932 }, +]) { + test.describe(`mobile browser ${viewport.width}px`, () => { + test.use({ viewport, isMobile: true, hasTouch: true, deviceScaleFactor: 3 }); + + test("uses device width and stacks practice above guidance", async ({ page }, testInfo) => { + await page.goto("/"); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(viewport.width); + const pad = page.getByRole("img", { name: "Hindi writing practice area" }); + const retry = page.getByRole("button", { name: "Retry", exact: true }); + const guidance = page.getByRole("complementary", { name: "Practice guidance" }); + const bounds = await pad.boundingBox(); + const retryBounds = await retry.boundingBox(); + const guidanceBounds = await guidance.boundingBox(); + if (!bounds || !retryBounds || !guidanceBounds) throw new Error("Missing practice layout"); + expect(bounds.width).toBeGreaterThanOrEqual(viewport.width - 48); + expect(Math.abs(bounds.width - bounds.height)).toBeLessThan(1); + expect(retryBounds.height).toBeGreaterThanOrEqual(48); + expect(guidanceBounds.y).toBeGreaterThanOrEqual(retryBounds.y + retryBounds.height); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(viewport.width); + const touch = await page.context().newCDPSession(page); + await touch.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: bounds.x + bounds.width * 0.3, y: bounds.y + bounds.height * 0.4 }], + }); + await expect(page.getByRole("status")).toContainText("Drawing…"); + for (let step = 1; step <= 5; step++) { + await touch.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [ + { + x: bounds.x + bounds.width * (0.3 + step * 0.05), + y: bounds.y + bounds.height * (0.4 + step * 0.03), + }, + ], + }); + } + await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + await touch.detach(); + await expect(page.getByRole("status")).toContainText("1 stroke drawn"); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + await retry.tap(); + await expect(page.getByRole("status")).toContainText("Ready to trace"); + await page.screenshot({ path: testInfo.outputPath("mobile-practice.png"), fullPage: true }); + }); + }); +} diff --git a/void.config.ts b/void.config.ts index 77829b9..2caea64 100644 --- a/void.config.ts +++ b/void.config.ts @@ -1,9 +1,12 @@ // Generated by void init. -import { defineConfig } from 'void/config'; +import { defineConfig } from "void/config"; export default defineConfig({ - sourceDir: 'src', - "worker": { - "compatibility_date": "2026-02-24" - } + sourceDir: "src", + head: { + meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }], + }, + worker: { + compatibility_date: "2026-02-24", + }, }); From c8643699b90d5637f84bd8bf2bf8a1e1b0b6ebbd Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 21:35:18 +0530 Subject: [PATCH 16/22] test: retain touch session through mobile reset Let the browser context dispose of the touch session after the test instead of detaching between drawing and the Retry tap. --- src/features/writing/__tests__/browser/mobile.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/features/writing/__tests__/browser/mobile.spec.ts b/src/features/writing/__tests__/browser/mobile.spec.ts index 530cc00..33571d6 100644 --- a/src/features/writing/__tests__/browser/mobile.spec.ts +++ b/src/features/writing/__tests__/browser/mobile.spec.ts @@ -42,7 +42,6 @@ for (const viewport of [ }); } await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); - await touch.detach(); await expect(page.getByRole("status")).toContainText("1 stroke drawn"); expect(await page.evaluate(() => window.scrollY)).toBe(0); await retry.tap(); From 78158a71f0984353d6d5a9de7d264fa39185ba2e Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 21:42:16 +0530 Subject: [PATCH 17/22] test: use one touch stream for drawing and reset --- src/features/writing/__tests__/browser/mobile.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/features/writing/__tests__/browser/mobile.spec.ts b/src/features/writing/__tests__/browser/mobile.spec.ts index 33571d6..2a0c559 100644 --- a/src/features/writing/__tests__/browser/mobile.spec.ts +++ b/src/features/writing/__tests__/browser/mobile.spec.ts @@ -44,7 +44,14 @@ for (const viewport of [ await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await expect(page.getByRole("status")).toContainText("1 stroke drawn"); expect(await page.evaluate(() => window.scrollY)).toBe(0); - await retry.tap(); + await retry.scrollIntoViewIfNeeded(); + const target = await retry.boundingBox(); + if (!target) throw new Error("Retry has no bounds"); + await touch.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: target.x + target.width / 2, y: target.y + target.height / 2 }], + }); + await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await expect(page.getByRole("status")).toContainText("Ready to trace"); await page.screenshot({ path: testInfo.outputPath("mobile-practice.png"), fullPage: true }); }); From ef52e1404994eed02333145e190f904c1c71cab9 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 21:45:13 +0530 Subject: [PATCH 18/22] test: trace Linux mobile input failure --- .../writing/__tests__/browser/mobile.spec.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/features/writing/__tests__/browser/mobile.spec.ts b/src/features/writing/__tests__/browser/mobile.spec.ts index 2a0c559..13f98cb 100644 --- a/src/features/writing/__tests__/browser/mobile.spec.ts +++ b/src/features/writing/__tests__/browser/mobile.spec.ts @@ -9,6 +9,43 @@ for (const viewport of [ test.use({ viewport, isMobile: true, hasTouch: true, deviceScaleFactor: 3 }); test("uses device width and stacks practice above guidance", async ({ page }, testInfo) => { + page.on("console", (message) => { + if (message.text().startsWith("[DEBUG-touch]")) console.log(message.text()); + }); + await page.addInitScript(() => { + for (const name of [ + "touchstart", + "touchend", + "pointerdown", + "pointerup", + "pointercancel", + "mousedown", + "mouseup", + "click", + ]) { + document.addEventListener( + name, + (event) => { + queueMicrotask(() => + console.log( + "[DEBUG-touch]", + JSON.stringify({ + type: event.type, + target: + event.target instanceof Element + ? event.target.tagName + ":" + (event.target.textContent ?? "").slice(0, 25) + : "unknown", + prevented: event.defaultPrevented, + x: event instanceof MouseEvent ? event.clientX : null, + y: event instanceof MouseEvent ? event.clientY : null, + }), + ), + ); + }, + true, + ); + } + }); await page.goto("/"); await expect(page.getByRole("status")).toContainText("Ready to trace"); await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(viewport.width); @@ -47,6 +84,16 @@ for (const viewport of [ await retry.scrollIntoViewIfNeeded(); const target = await retry.boundingBox(); if (!target) throw new Error("Retry has no bounds"); + console.log( + "[DEBUG-touch] target", + target, + await page.evaluate(() => ({ + width: innerWidth, + scale: visualViewport?.scale, + x: visualViewport?.offsetLeft, + y: visualViewport?.offsetTop, + })), + ); await touch.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: target.x + target.width / 2, y: target.y + target.height / 2 }], From d049540bacd93788fb674e0cd310719d2af3397f Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 21:49:21 +0530 Subject: [PATCH 19/22] test: synthesize complete mobile tap gestures Linux delivered raw pointer and touch events to Retry without generating its click. Use Chromium's full touch gesture for button activation and remove temporary event diagnostics. --- .../writing/__tests__/browser/mobile.spec.ts | 56 ++----------------- 1 file changed, 5 insertions(+), 51 deletions(-) diff --git a/src/features/writing/__tests__/browser/mobile.spec.ts b/src/features/writing/__tests__/browser/mobile.spec.ts index 13f98cb..e18c3bc 100644 --- a/src/features/writing/__tests__/browser/mobile.spec.ts +++ b/src/features/writing/__tests__/browser/mobile.spec.ts @@ -9,43 +9,6 @@ for (const viewport of [ test.use({ viewport, isMobile: true, hasTouch: true, deviceScaleFactor: 3 }); test("uses device width and stacks practice above guidance", async ({ page }, testInfo) => { - page.on("console", (message) => { - if (message.text().startsWith("[DEBUG-touch]")) console.log(message.text()); - }); - await page.addInitScript(() => { - for (const name of [ - "touchstart", - "touchend", - "pointerdown", - "pointerup", - "pointercancel", - "mousedown", - "mouseup", - "click", - ]) { - document.addEventListener( - name, - (event) => { - queueMicrotask(() => - console.log( - "[DEBUG-touch]", - JSON.stringify({ - type: event.type, - target: - event.target instanceof Element - ? event.target.tagName + ":" + (event.target.textContent ?? "").slice(0, 25) - : "unknown", - prevented: event.defaultPrevented, - x: event instanceof MouseEvent ? event.clientX : null, - y: event instanceof MouseEvent ? event.clientY : null, - }), - ), - ); - }, - true, - ); - } - }); await page.goto("/"); await expect(page.getByRole("status")).toContainText("Ready to trace"); await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(viewport.width); @@ -84,21 +47,12 @@ for (const viewport of [ await retry.scrollIntoViewIfNeeded(); const target = await retry.boundingBox(); if (!target) throw new Error("Retry has no bounds"); - console.log( - "[DEBUG-touch] target", - target, - await page.evaluate(() => ({ - width: innerWidth, - scale: visualViewport?.scale, - x: visualViewport?.offsetLeft, - y: visualViewport?.offsetTop, - })), - ); - await touch.send("Input.dispatchTouchEvent", { - type: "touchStart", - touchPoints: [{ x: target.x + target.width / 2, y: target.y + target.height / 2 }], + // Raw touch events did not generate button clicks in Linux Chromium. + await touch.send("Input.synthesizeTapGesture", { + x: target.x + target.width / 2, + y: target.y + target.height / 2, + gestureSourceType: "touch", }); - await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await expect(page.getByRole("status")).toContainText("Ready to trace"); await page.screenshot({ path: testInfo.outputPath("mobile-practice.png"), fullPage: true }); }); From fa2f8bff78d228fbaa81dd5870d509a1cd30dcb2 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 21:54:33 +0530 Subject: [PATCH 20/22] test: run touch checks with full Chromium Use the documented Chromium channel instead of the separate headless shell, keeping the standard Retry tap and original reset assertion. --- playwright.config.ts | 2 +- src/features/writing/__tests__/browser/mobile.spec.ts | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index b7fa7f8..80f3f59 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ forbidOnly: Boolean(process.env.CI), retries: 0, use: { baseURL: "http://127.0.0.1:4173", trace: "retain-on-failure" }, - projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"], channel: "chromium" } }], webServer: { command: "pnpm build && pnpm preview --host 127.0.0.1 --port 4173 --strictPort", url: "http://127.0.0.1:4173", diff --git a/src/features/writing/__tests__/browser/mobile.spec.ts b/src/features/writing/__tests__/browser/mobile.spec.ts index e18c3bc..33571d6 100644 --- a/src/features/writing/__tests__/browser/mobile.spec.ts +++ b/src/features/writing/__tests__/browser/mobile.spec.ts @@ -44,15 +44,7 @@ for (const viewport of [ await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await expect(page.getByRole("status")).toContainText("1 stroke drawn"); expect(await page.evaluate(() => window.scrollY)).toBe(0); - await retry.scrollIntoViewIfNeeded(); - const target = await retry.boundingBox(); - if (!target) throw new Error("Retry has no bounds"); - // Raw touch events did not generate button clicks in Linux Chromium. - await touch.send("Input.synthesizeTapGesture", { - x: target.x + target.width / 2, - y: target.y + target.height / 2, - gestureSourceType: "touch", - }); + await retry.tap(); await expect(page.getByRole("status")).toContainText("Ready to trace"); await page.screenshot({ path: testInfo.outputPath("mobile-practice.png"), fullPage: true }); }); From c87b9c142a6e2f01baaee7210dce5e0341595e29 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 22:19:21 +0530 Subject: [PATCH 21/22] test: use native touch gestures on mobile --- playwright.config.ts | 2 +- .../writing/__tests__/browser/mobile.spec.ts | 24 +++++++------------ 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 80f3f59..b7fa7f8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ forbidOnly: Boolean(process.env.CI), retries: 0, use: { baseURL: "http://127.0.0.1:4173", trace: "retain-on-failure" }, - projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"], channel: "chromium" } }], + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], webServer: { command: "pnpm build && pnpm preview --host 127.0.0.1 --port 4173 --strictPort", url: "http://127.0.0.1:4173", diff --git a/src/features/writing/__tests__/browser/mobile.spec.ts b/src/features/writing/__tests__/browser/mobile.spec.ts index 33571d6..e43a131 100644 --- a/src/features/writing/__tests__/browser/mobile.spec.ts +++ b/src/features/writing/__tests__/browser/mobile.spec.ts @@ -25,23 +25,15 @@ for (const viewport of [ expect(guidanceBounds.y).toBeGreaterThanOrEqual(retryBounds.y + retryBounds.height); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(viewport.width); const touch = await page.context().newCDPSession(page); - await touch.send("Input.dispatchTouchEvent", { - type: "touchStart", - touchPoints: [{ x: bounds.x + bounds.width * 0.3, y: bounds.y + bounds.height * 0.4 }], + // Native gestures avoid Linux Chromium swallowing the next tap after raw touch injection. + await touch.send("Input.synthesizeScrollGesture", { + x: bounds.x + bounds.width * 0.3, + y: bounds.y + bounds.height * 0.4, + xDistance: -bounds.width * 0.25, + yDistance: -bounds.height * 0.15, + gestureSourceType: "touch", + preventFling: true, }); - await expect(page.getByRole("status")).toContainText("Drawing…"); - for (let step = 1; step <= 5; step++) { - await touch.send("Input.dispatchTouchEvent", { - type: "touchMove", - touchPoints: [ - { - x: bounds.x + bounds.width * (0.3 + step * 0.05), - y: bounds.y + bounds.height * (0.4 + step * 0.03), - }, - ], - }); - } - await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await expect(page.getByRole("status")).toContainText("1 stroke drawn"); expect(await page.evaluate(() => window.scrollY)).toBe(0); await retry.tap(); From 5086c810d50e125fddcd818b012b6b1945e23fde Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 24 Oct 2026 22:30:30 +0530 Subject: [PATCH 22/22] ci: add local Linux browser reproduction --- .github/workflows/ci.yml | 4 +++ AGENTS.md | 2 ++ docs/ci-debugging.md | 29 +++++++++++++++++++ package.json | 4 ++- scripts/__tests__/test-browser-linux.test.mjs | 29 +++++++++++++++++++ scripts/test-browser-linux.sh | 25 ++++++++++++++++ 6 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 docs/ci-debugging.md create mode 100644 scripts/__tests__/test-browser-linux.test.mjs create mode 100644 scripts/test-browser-linux.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcf310f..1adf7c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,11 @@ name: CI on: push: + branches: [main] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true permissions: contents: read jobs: diff --git a/AGENTS.md b/AGENTS.md index 9cb2831..6401abc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ TypeScript web app using Effect. Follow the installed Effect version and existing project conventions. +When local checks pass but CI fails, follow [the CI debugging guide](docs/ci-debugging.md). + ## Feature ownership - Build self-contained vertical slices. Keep feature-specific domain code, use cases, services, UI, tests, and supporting code together. diff --git a/docs/ci-debugging.md b/docs/ci-debugging.md new file mode 100644 index 0000000..3bd4d50 --- /dev/null +++ b/docs/ci-debugging.md @@ -0,0 +1,29 @@ +# Debugging CI-only failures + +## Reproduce before pushing + +1. Identify the failing run, commit, test, and assertion. Inspect its log and retained Playwright trace from the `browser-results` artifact. +2. Reproduce the failing test in Linux with the command below. Observe the same failure before changing code. If it does not reproduce, compare the browser, architecture, dependencies, and test inputs with CI. +3. Change one suspected cause. Require the original assertion to pass in the reproducing environment; preserve coverage and keep retries disabled. +4. Run the affected suite, then the normal commit checks. Push the verified fix and watch the workflow for that exact commit. + +## Linux browser tests + +Start Docker and install local dependencies with `pnpm install --frozen-lockfile`, then run: + +```sh +pnpm test:browser:linux mobile.spec.ts +pnpm test:browser:linux +``` + +The command forwards Playwright arguments, copies tracked files from the working tree (including uncommitted edits), and installs Linux dependencies in a disposable container. Stage new files with `git add` so they are included. Local dependencies and Git metadata stay outside the container. Test artifacts are saved in `test-results/linux/`; package downloads are cached in the Docker volume `timtim-linux-test-cache`. The first run downloads the image and Linux dependencies; later runs reuse those downloads. + +The [official Playwright image](https://playwright.dev/docs/docker) matches the installed Playwright version. It uses the host's CPU architecture and the image's Node version, so it reproduces Linux browser behavior without promising an identical GitHub runner. For architecture-specific failures on Apple Silicon, compare against CI's x86 runner explicitly. + +For mobile input failures, test the **first tap after drawing**. Rapid raw CDP touch injection previously caused Linux Chromium to consume that tap. The native gesture in `src/features/writing/__tests__/browser/mobile.spec.ts` covers drawing followed by one ordinary Retry tap. Check the gesture producer before changing application event handlers. + +## Watch one run + +Find the run for the pushed SHA with `gh run list --commit `, then use `gh run watch --interval 30 --exit-status`. Report status transitions or new evidence; repeated pending results need no extra inspection. Confirm the run's SHA and conclusion before reporting CI success. Deployment and real-device verification are separate outcomes. + +CI runs for pull requests and pushes to `main`. New commits cancel superseded runs for the same PR or branch. diff --git a/package.json b/package.json index 877793b..2c703ad 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "lint": "oxlint --deny-warnings", "format": "oxfmt", "format:check": "oxfmt --check", - "test": "pnpm test:fence && pnpm test:content && pnpm test:browser", + "test": "pnpm test:tooling && pnpm test:fence && pnpm test:content && pnpm test:browser", + "test:tooling": "node --test scripts/__tests__/*.test.mjs", "test:content": "vitest run", "test:browser": "playwright test", + "test:browser:linux": "bash scripts/test-browser-linux.sh", "prepare": "effect-tsgo patch && lefthook install", "test:fence": "node --test src/features/writing/__tests__/fence.test.mjs" }, diff --git a/scripts/__tests__/test-browser-linux.test.mjs b/scripts/__tests__/test-browser-linux.test.mjs new file mode 100644 index 0000000..1e15cee --- /dev/null +++ b/scripts/__tests__/test-browser-linux.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +test("Linux runner preserves filters, omits Mac metadata, and propagates Docker failure", (t) => { + const directory = mkdtempSync(join(tmpdir(), "timtim-linux-runner-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + writeFileSync(join(directory, "docker"), `#!/bin/sh +if [ "$1" = info ]; then exit 0; fi +cat > "$LINUX_RUNNER_TEST_DIR/source.tar" +printf '%s\\n' "$@" > "$LINUX_RUNNER_TEST_DIR/arguments" +exit 17 +`, { mode: 0o755 }); + + const result = spawnSync("bash", ["scripts/test-browser-linux.sh", "mobile.spec.ts", "--grep", "first tap"], { + env: { ...process.env, PATH: `${directory}:${process.env.PATH}`, LINUX_RUNNER_TEST_DIR: directory }, + encoding: "utf8", + }); + assert.equal(result.status, 17, result.stderr); + const args = readFileSync(join(directory, "arguments"), "utf8").trimEnd().split("\n"); + assert.deepEqual(args.slice(-3), ["mobile.spec.ts", "--grep", "first tap"]); + const archive = spawnSync("tar", ["-tf", join(directory, "source.tar")], { encoding: "utf8" }); + assert.equal(archive.status, 0, archive.stderr); + assert.match(archive.stdout, /^package\.json$/m); + assert.doesNotMatch(archive.stdout, /(^|\/)\._/m); +}); diff --git a/scripts/test-browser-linux.sh b/scripts/test-browser-linux.sh new file mode 100644 index 0000000..20eb99c --- /dev/null +++ b/scripts/test-browser-linux.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" +playwright_version=$(node -p 'require("@playwright/test/package.json").version') +package_manager=$(node -p 'require("./package.json").packageManager') +docker info >/dev/null +mkdir -p test-results/linux + +# Copy tracked working-tree files, including staged additions, without host dependencies or untracked files. +git ls-files -z | COPYFILE_DISABLE=1 tar --no-xattrs --null -T - -cf - | docker run --rm -i --init --ipc=host \ + --mount "type=bind,src=$PWD/test-results/linux,dst=/results" \ + --mount type=volume,src=timtim-linux-test-cache,dst=/cache \ + --env npm_config_cache=/cache/npm \ + "mcr.microsoft.com/playwright:v${playwright_version}-noble" \ + bash -euc ' + mkdir /app + tar -xf - -C /app + cd /app + git init -q + npm install --global "$1" + shift + pnpm install --frozen-lockfile --store-dir /cache/pnpm + pnpm test:browser "$@" --output=/results + ' -- "$package_manager" "$@"