diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 2f0f0b928..dbcb08379 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -5,10 +5,14 @@ on: branches: [main] paths: - "web/**" + # The page's copy of the result schema is tested against the server's. + - "server/schemas/**" - ".github/workflows/web.yml" pull_request: paths: - "web/**" + # The page's copy of the result schema is tested against the server's. + - "server/schemas/**" - ".github/workflows/web.yml" permissions: diff --git a/web/README.md b/web/README.md index 9ede33fb9..e51491864 100644 --- a/web/README.md +++ b/web/README.md @@ -1,32 +1,40 @@ # web -Browser app for zero-install capture experiments. Right now it holds a placeholder page and `src/lib/units.ts`, which converts meters (what ARKit reports) to feet and inches for display. +The placement review page: pick a sample or drop a scan, and see where the Base Power battery goes and why. It calls the placement server's `POST /v1/placements` (see `server/README.md`) and shows the decision, the spot and cable run, the site plan, every check with its reason, and the views still missing. The site plan comes from `POST /v1/placements/site-plan.svg`, asked for only after an answer arrives, so a scene the server refuses is sent once. -## Setup +## Run -Requires Node 24 or later (`.node-version`) and the pnpm version pinned in `package.json` `packageManager`. Corepack and pnpm's own version switching both pick up that pin. +Requires Node 24 or later (`.node-version`) and the pnpm pinned in `package.json`. ```sh pnpm install +pnpm dev # http://localhost:5173 ``` -## Commands +The page talks to the server at `VITE_PLACEMENT_API`, default `/api`. In `pnpm dev` and `pnpm preview`, `/api` is proxied to `PLACEMENT_SERVER` (default `http://localhost:8000`), so start the server first: `cd server && uv run uvicorn api:app --port 8000`. A reviewer can point the page at another server with **Change**; the choice is kept in the browser. `?sample=` opens a sample directly, so a link shares an answer. + +Vercel previews of this page are built with `VITE_PLACEMENT_API=https://house-scanning-server.vercel.app`, the hosted server (public rules only; see `server/README.md`). Its host refuses request bodies over 4.5 MB without the headers a browser needs to read the refusal, so the page refuses larger files itself; send `scene.json` or a zip without the photos. A page served over https can't call an http server; the page says so when one is entered. + +Without a reachable server, the bundled samples can still show the answer the server gave them when it was recorded; the page says so ("Saved answer"). | Command | What it does | | ---------------- | ------------------------------------------------------ | | `pnpm dev` | Vite dev server | | `pnpm build` | `tsc -b`, then `vite build` into `dist/` | -| `pnpm typecheck` | `tsc -b` (no emit) | -| `pnpm lint` | `biome ci .`: lint and format check, no writes | -| `pnpm format` | `biome format --write .` | -| `pnpm test` | `vitest run` | -| `pnpm check` | lint, typecheck, test, build; stops at first failure | +| `pnpm preview` | Serve `dist/` with the same `/api` proxy | +| `pnpm check` | lint, typecheck, test, build; what CI runs | +| `pnpm contract` | Regenerate the result types from the server's schema | + +## The result contract + +`src/contract/result.schema.json` is a copy of `server/schemas/result.schema.json`, and `src/contract/result.ts` is generated from it by `pnpm contract`. Answers are validated against the schema at runtime, so a server that breaks the contract shows a specific error instead of a broken page. `scripts/contract.test.ts` fails when the generated files don't match the copy or the copy doesn't match the server's schema; CI runs it when either side changes. After the server's schema changes, run `pnpm contract` and commit the results. + +The page accepts answers with fields it doesn't know, as long as `schema_version` has the same major version (`1.x`), so a server that adds a field doesn't break a deployed page. The copy stays identical to the server's; only the generated types and validator are loosened (`tolerantSchema` in `scripts/contract-lib.ts`). -CI (`.github/workflows/web.yml`, job "Web checks") runs `pnpm install --frozen-lockfile` and then `pnpm run check`. +The samples in `src/samples/` are synthetic. `scripts/record_samples.py` builds their scenes and records the server's answers under the public rules, as the hosted server gives them (`cd server && PYTHONPATH=. uv run python ../web/scripts/record_samples.py`); rerun it when the solver changes. Generated and recorded files are excluded from Biome so they stay byte-identical to what produced them. ## Conventions -- Lengths are stored in meters. Convert to feet and inches only for display, using `src/lib/units.ts`. -- TypeScript runs in strict mode with `noUncheckedIndexedAccess`. -- Tests live next to their source files as `*.test.ts`. +- The server reports feet; `src/lib/format.ts` says them the way people do ("2 ft 9 in right of the meter"). `src/lib/units.ts` converts ARKit's meters. +- TypeScript runs in strict mode with `noUncheckedIndexedAccess`. Tests sit next to their source as `*.test.ts`. - Use synthetic data only. The repository is public, so never commit real home photos, addresses or meter numbers. diff --git a/web/biome.json b/web/biome.json index 71d70060e..aa4489921 100644 --- a/web/biome.json +++ b/web/biome.json @@ -6,7 +6,8 @@ "useIgnoreFile": true }, "files": { - "ignoreUnknown": false + "ignoreUnknown": false, + "includes": ["**", "!src/contract", "!src/samples"] }, "formatter": { "enabled": true, diff --git a/web/index.html b/web/index.html index a1db43110..68b57a6df 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,14 @@ - House Scan: web capture + Placement review · House Scan + + + +
diff --git a/web/package.json b/web/package.json index c4dc2e3e1..6f7ca7252 100644 --- a/web/package.json +++ b/web/package.json @@ -14,9 +14,14 @@ "lint": "biome ci .", "format": "biome format --write .", "test": "vitest run", - "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build" + "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build", + "contract": "node scripts/contract.ts", + "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/public-sans": "^5.3.0", + "@fontsource/barlow-semi-condensed": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -26,6 +31,8 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", + "ajv": "^8.20.0", + "json-schema-to-typescript": "^16.0.0", "typescript": "~6.0.3", "vite": "^8.3.1", "vitest": "^5.0.0" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 6b2078929..ad5b93b23 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@fontsource-variable/public-sans': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/barlow-semi-condensed': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/ibm-plex-mono': + specifier: ^5.3.0 + version: 5.3.0 react: specifier: ^19.3.0 version: 19.3.0 @@ -30,6 +39,12 @@ importers: '@vitejs/plugin-react': specifier: ^6.1.1 version: 6.1.1(vite@8.3.1(@types/node@24.13.6)) + ajv: + specifier: ^8.20.0 + version: 8.20.0 + json-schema-to-typescript: + specifier: ^16.0.0 + version: 16.0.0 typescript: specifier: ~6.0.3 version: 6.0.3 @@ -42,6 +57,10 @@ importers: packages: + '@apidevtools/json-schema-ref-parser@11.9.3': + resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} + engines: {node: '>= 16'} + '@biomejs/biome@2.5.14': resolution: {integrity: sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw==} engines: {node: '>=14.21.3'} @@ -99,6 +118,15 @@ packages: cpu: [x64] os: [win32] + '@fontsource-variable/public-sans@5.3.0': + resolution: {integrity: sha512-AVfkmAt50BMXWpOO21FAntiJFKGX6xTc2dSL8dxtDteONe9IuRXJWGbs0EbG955vAMCq23ENeuopuW87cGWDSQ==} + + '@fontsource/barlow-semi-condensed@5.3.0': + resolution: {integrity: sha512-EdpQ1GlLMofGqDsQhEw+g6DonNKmzt21p8y/oNbTl4BFzEkqYkFyE2fj25YGxmZBL/LZZGAxe74FlvEWcgc31w==} + + '@fontsource/ibm-plex-mono@5.3.0': + resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -109,6 +137,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@oxc-project/types@0.151.0': resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} @@ -220,6 +251,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/node@24.13.6': resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==} @@ -261,6 +298,12 @@ packages: '@vitest/spy@5.0.1': resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -286,6 +329,12 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -300,6 +349,22 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + js-yaml@5.4.2: + resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} + hasBin: true + + json-schema-to-typescript@16.0.0: + resolution: {integrity: sha512-Ah6kK4q6SjlMzWBH1eNOQkdRh5Qhr5T/RCFwfjQqWZA7UDW+N47A8wfyoiN0L884s4L4bAEI54IjIuN3/u0B/A==} + engines: {node: '>=16.0.0'} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -374,9 +439,15 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + magic-string@1.4.2: resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + nanoid@3.3.19: resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -397,6 +468,11 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + engines: {node: '>=14'} + hasBin: true + react-dom@19.3.0: resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: @@ -406,6 +482,10 @@ packages: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rolldown@1.2.11: resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -538,6 +618,12 @@ packages: snapshots: + '@apidevtools/json-schema-ref-parser@11.9.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.3.2 + '@biomejs/biome@2.5.14': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.5.14 @@ -573,6 +659,12 @@ snapshots: '@biomejs/cli-win32-x64@2.5.14': optional: true + '@fontsource-variable/public-sans@5.3.0': {} + + '@fontsource/barlow-semi-condensed@5.3.0': {} + + '@fontsource/ibm-plex-mono@5.3.0': {} + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -582,6 +674,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@jsdevtools/ono@7.1.3': {} + '@oxc-project/types@0.151.0': {} '@rolldown/binding-android-arm-eabi@1.2.11': @@ -640,6 +734,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} + + '@types/lodash@4.17.25': {} + '@types/node@24.13.6': dependencies: undici-types: 7.18.2 @@ -668,6 +766,15 @@ snapshots: '@vitest/spy@5.0.1': {} + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + argparse@2.0.1: {} + assertion-error@2.0.1: {} chai@6.2.2: {} @@ -684,6 +791,10 @@ snapshots: expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -691,6 +802,27 @@ snapshots: fsevents@2.3.3: optional: true + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + js-yaml@5.4.2: + dependencies: + argparse: 2.0.1 + + json-schema-to-typescript@16.0.0: + dependencies: + '@apidevtools/json-schema-ref-parser': 11.9.3 + '@types/json-schema': 7.0.15 + '@types/lodash': 4.17.25 + js-yaml: 5.4.2 + lodash: 4.18.1 + minimist: 1.2.8 + prettier: 3.9.9 + tinyglobby: 0.2.17 + + json-schema-traverse@1.0.0: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -740,10 +872,14 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + lodash@4.18.1: {} + magic-string@1.4.2: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + minimist@1.2.8: {} + nanoid@3.3.19: {} obug@2.2.1: {} @@ -758,6 +894,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prettier@3.9.9: {} + react-dom@19.3.0(react@19.3.0): dependencies: react: 19.3.0 @@ -765,6 +903,8 @@ snapshots: react@19.3.0: {} + require-from-string@2.0.2: {} + rolldown@1.2.11: dependencies: '@oxc-project/types': 0.151.0 diff --git a/web/scripts/contract-lib.ts b/web/scripts/contract-lib.ts new file mode 100644 index 000000000..bbc9f37e8 --- /dev/null +++ b/web/scripts/contract-lib.ts @@ -0,0 +1,63 @@ +import { fileURLToPath } from "node:url"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import standaloneCode from "ajv/dist/standalone/index.js"; +import { compile, type JSONSchema } from "json-schema-to-typescript"; + +const here = (path: string) => fileURLToPath(new URL(path, import.meta.url)); + +// server/schemas/result.schema.json is the source of truth; src/contract keeps a copy so the web +// app builds on its own. +export const SERVER_SCHEMA = here("../../server/schemas/result.schema.json"); +export const VENDORED_SCHEMA = here("../src/contract/result.schema.json"); +export const RESULT_TYPES = here("../src/contract/result.ts"); +export const RESULT_VALIDATOR = here("../src/contract/validate-result.js"); + +const BANNER = "// Generated from result.schema.json by `pnpm contract`. Do not edit by hand."; + +/** The schema as the page applies it: fields a newer server adds within the same major version + * are accepted, as the iOS app does. The copy stays byte-identical to the server's; only what is + * generated from it is loosened. */ +export function tolerantSchema(schema: JSONSchema): JSONSchema { + const version = schema.properties?.schema_version?.const; + if (typeof version !== "string" || !/^\d+\.\d+$/.test(version)) { + throw new Error( + `result.schema.json: properties.schema_version.const should be ".", got ${JSON.stringify(version)}`, + ); + } + const major = version.split(".")[0]; + const loose = allowAddedFields(structuredClone(schema)) as JSONSchema; + loose.properties = { + ...loose.properties, + schema_version: { type: "string", pattern: `^${major}\\.\\d+$` }, + }; + return loose; +} + +function allowAddedFields(node: unknown): unknown { + if (Array.isArray(node)) { + return node.map(allowAddedFields); + } + if (typeof node !== "object" || node === null) { + return node; + } + return Object.fromEntries( + Object.entries(node) + .filter(([key, value]) => !(key === "additionalProperties" && value === false)) + .map(([key, value]) => [key, allowAddedFields(value)]), + ); +} + +export function compileResultTypes(schema: JSONSchema): Promise { + return compile(tolerantSchema(schema), "PlacementResult", { + bannerComment: BANNER, + additionalProperties: false, + unknownAny: true, + }); +} + +/** The schema compiled to plain JavaScript, so the page validates answers without shipping Ajv + * or evaluating code at runtime. */ +export function compileResultValidator(schema: JSONSchema): string { + const ajv = new Ajv2020({ code: { source: true, esm: true } }); + return `${BANNER}\n${standaloneCode.default(ajv, ajv.compile(tolerantSchema(schema)))}\n`; +} diff --git a/web/scripts/contract.test.ts b/web/scripts/contract.test.ts new file mode 100644 index 000000000..a05dd9532 --- /dev/null +++ b/web/scripts/contract.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + compileResultTypes, + compileResultValidator, + RESULT_TYPES, + RESULT_VALIDATOR, + SERVER_SCHEMA, + VENDORED_SCHEMA, +} from "./contract-lib.ts"; + +describe("result contract", () => { + it("generated types match the vendored schema", async () => { + const schema = JSON.parse(readFileSync(VENDORED_SCHEMA, "utf8")); + expect(readFileSync(RESULT_TYPES, "utf8")).toBe(await compileResultTypes(schema)); + }); + + it("generated validator matches the vendored schema", () => { + const schema = JSON.parse(readFileSync(VENDORED_SCHEMA, "utf8")); + expect(readFileSync(RESULT_VALIDATOR, "utf8")).toBe(compileResultValidator(schema)); + }); + + // #15 is stacked on #11, so server/schemas is always here; a missing file fails the test. + it("vendored schema matches server/schemas", () => { + expect(readFileSync(VENDORED_SCHEMA, "utf8")).toBe(readFileSync(SERVER_SCHEMA, "utf8")); + }); +}); diff --git a/web/scripts/contract.ts b/web/scripts/contract.ts new file mode 100644 index 000000000..230bf40fc --- /dev/null +++ b/web/scripts/contract.ts @@ -0,0 +1,17 @@ +// Regenerates the result types and validator in src/contract from the result schema. +// Run `pnpm contract` after server/schemas/result.schema.json changes; contract.test.ts fails +// until the vendored copy and everything generated from it match. +import { copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import { + compileResultTypes, + compileResultValidator, + RESULT_TYPES, + RESULT_VALIDATOR, + SERVER_SCHEMA, + VENDORED_SCHEMA, +} from "./contract-lib.ts"; + +copyFileSync(SERVER_SCHEMA, VENDORED_SCHEMA); +const schema = JSON.parse(readFileSync(VENDORED_SCHEMA, "utf8")); +writeFileSync(RESULT_TYPES, await compileResultTypes(schema)); +writeFileSync(RESULT_VALIDATOR, compileResultValidator(schema)); diff --git a/web/scripts/record_samples.py b/web/scripts/record_samples.py new file mode 100644 index 000000000..f1292d183 --- /dev/null +++ b/web/scripts/record_samples.py @@ -0,0 +1,118 @@ +"""Record the bundled sample scenes and the placement server's answers to them. + +The web page ships three synthetic scenes, one per decision, with the answer and site plan the +server gave, so it can show a saved answer when no server is reachable. Rerun after the solver or +the result schema changes: + + cd server && PYTHONPATH=. uv run python ../web/scripts/record_samples.py + +The answers use the public rules under their demo policy, as the hosted server does, so a saved +answer says what the live server says: a pass, a manual review and a reject, each labelled as +demo rules. +""" + +import json +from pathlib import Path + +from rules import public_rules_dict, rules_from_dict +from scene import parse_scene +from siteplan import render +from solver import solve + +OUT = Path(__file__).resolve().parents[1] / "src" / "samples" +RULES = rules_from_dict(public_rules_dict()) + + +def rect(x0, x1, z0, z1): + return [[x0, z0], [x1, z0], [x1, z1], [x0, z1]] + + +def side_wall(right_end="limit", wall_end=24.0, seen_to=30.0): + """A 44 ft side wall with the meter at x = 0, a gas meter to its left and mulch in front.""" + return { + "schema_version": "1.0", + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "side", "plus_minus_ft": 0.1}, + "walls": [ + {"id": "side", "baseline": [[-20.0, 0.0], [wall_end, 0.0]], "plus_minus_ft": 0.1} + ], + "objects": [ + { + "type": "gas_meter", "wall_id": "side", "span_ft": [-6.0, -5.0], + "bottom_ft": 0.5, "top_ft": 2.5, "source": "tap", "plus_minus_ft": 0.1, + "footprint": rect(-6.0, -5.0, 0.0, 1.0), + }, + { + "type": "window", "wall_id": "side", "span_ft": [-14.0, -11.0], + "bottom_ft": 3.0, "top_ft": 7.0, "attrs": {"operable": True}, "source": "tap", + "plus_minus_ft": 0.1, + }, + ], + "ground": [ + {"type": "mulch", "polygon": rect(-40.0, 40.0, 0.0, 4.0), "plus_minus_ft": 0.1}, + {"type": "lawn", "polygon": rect(-40.0, 40.0, 4.0, 20.0), "plus_minus_ft": 0.1}, + ], + "overheads": [], + "facing": [ + {"wall_id": "side", "span_ft": [-20.0, wall_end], "depth_ft": 16.0, + "plus_minus_ft": 0.2} + ], + "coverage": { + "ends": {"left": {"kind": "limit"}, "right": {"kind": right_end}}, + "observed": [ + {"band": "wall", "span_ft": [-20.0, seen_to]}, + {"band": "ground", "span_ft": [-20.0, seen_to], "out_ft": 20.0}, + {"band": "overhead", "span_ft": [-20.0, seen_to]}, + {"band": "facing", "span_ft": [-20.0, seen_to]}, + ], + }, + } + + +def fits(): + return side_wall() + + +def corner_not_walked(): + """The walk stopped at a corner 9 ft right of the meter, and the only room is next to it.""" + scene = side_wall(right_end="unexplored", wall_end=9.0, seen_to=9.0) + scene["walls"][0]["baseline"][0] = [-3.0, 0.0] + scene["objects"] = [] + scene["facing"][0]["span_ft"] = [-3.0, 9.0] + return scene + + +def garage_in_the_way(): + scene = side_wall() + scene["objects"] += [ + {"type": "garage_door", "wall_id": "side", "span_ft": [1.5, 24.0], "bottom_ft": 0.0, + "top_ft": 7.0, "source": "tap", "plus_minus_ft": 0.1}, + {"type": "gas_meter", "wall_id": "side", "span_ft": [-18.0, -17.0], "bottom_ft": 0.5, + "top_ft": 2.5, "source": "tap", "plus_minus_ft": 0.1, + "footprint": rect(-18.0, -17.0, 0.0, 1.0)}, + ] + return scene + + +SAMPLES = { + "fits": fits, + "corner-not-walked": corner_not_walked, + "garage-in-the-way": garage_in_the_way, +} + + +def main() -> None: + for name, build in SAMPLES.items(): + raw = build() + scene = parse_scene(json.loads(json.dumps(raw)), RULES.rules) + result = solve(scene, RULES) + result["stats"]["elapsed_ms"] = 0 # keeps the recorded files stable between runs + folder = OUT / name + folder.mkdir(parents=True, exist_ok=True) + (folder / "scene.json").write_text(json.dumps(raw, indent=2) + "\n") + (folder / "result.json").write_text(json.dumps(result, indent=2) + "\n") + (folder / "plan.svg").write_text(render(scene, result, RULES)) + print(f"{name}: {result['decision']} - {result['summary']}") + + +if __name__ == "__main__": + main() diff --git a/web/src/App.tsx b/web/src/App.tsx index d0ba8ff97..b291ee1cd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,395 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { type PlanState, ResultView, STAMP } from "./components/ResultView.tsx"; +import { ScenePanel } from "./components/ScenePanel.tsx"; +import { ServerSetting } from "./components/ServerSetting.tsx"; +import { + checkHealth, + type Failure, + type Health, + isMixedContent, + PlacementError, + parseResult, + type Result, + requestPlacement, + requestPlan, +} from "./lib/api.ts"; +import { shownDecision } from "./lib/format.ts"; +import { type SceneInput, sceneFromFile, sceneFromSample } from "./lib/scene-input.ts"; +import { SAMPLES, type Sample } from "./samples/index.ts"; + +// "/api" is the Vite dev proxy to a local server (vite.config.ts). A deployment points +// VITE_PLACEMENT_API at its server; a reviewer can override it in the page. +const DEFAULT_SERVER = import.meta.env.VITE_PLACEMENT_API ?? "/api"; +const SERVER_KEY = "placement-server"; + +type View = + | { status: "empty" } + | { status: "loading"; input: SceneInput; previous: Shown | null } + | { status: "shown"; input: SceneInput; shown: Shown } + /** input is null for a file refused in the browser, which there is no point resending. */ + | { status: "failed"; input: SceneInput | null; name: string; failure: Failure }; + +interface Shown { + result: Result; + plan: PlanState; + saved: boolean; + /** Changes for every new answer, so its entrance plays again. */ + key: number; + /** Whether it replaced another answer, which then only re-stamps instead of rising in. */ + replacing: boolean; +} + +/** Puts a site plan that arrived after its answer into whichever view still holds that answer. */ +function withPlan(view: View, key: number, plan: PlanState): View { + if (view.status === "shown" && view.shown.key === key) { + return { ...view, shown: { ...view.shown, plan } }; + } + if (view.status === "loading" && view.previous?.key === key) { + return { ...view, previous: { ...view.previous, plan } }; + } + return view; +} + +// Storage can be blocked (a sandboxed frame, strict privacy settings); the page still works. +function storedServer(): string { + try { + return localStorage.getItem(SERVER_KEY) ?? DEFAULT_SERVER; + } catch { + return DEFAULT_SERVER; + } +} + +function storeServer(server: string) { + try { + // Only an override is stored, so a new VITE_PLACEMENT_API reaches returning visitors. + if (server === DEFAULT_SERVER) { + localStorage.removeItem(SERVER_KEY); + } else { + localStorage.setItem(SERVER_KEY, server); + } + } catch { + // Nothing to do: the choice lasts for this visit. + } +} + +function setSampleParam(id: string | null) { + const url = new URL(window.location.href); + if (id === null) { + url.searchParams.delete("sample"); + } else { + url.searchParams.set("sample", id); + } + window.history.replaceState(null, "", url); +} + +function failureOf(error: unknown): Failure { + if (error instanceof PlacementError) { + return error.failure; + } + return { kind: "unexpected", message: error instanceof Error ? error.message : String(error) }; +} + +const FAILURE_TITLE: Record = { + unreachable: "Can't reach the placement server", + refused: "The server refused this scene", + http_error: "The server answered with an error", + too_large: "Too large to send", + unexpected: "That didn't work", +}; + export function App() { + const [server, setServer] = useState(storedServer); + const [healthCheck, setHealthCheck] = useState(0); + const [health, setHealth] = useState(null); + const [view, setView] = useState({ status: "empty" }); + const serverRef = useRef(server); + const inFlight = useRef(null); + // Separate, so changing servers stops a pending answer without dropping a plan on its way. + const planInFlight = useRef(null); + const answers = useRef(0); + const sheet = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: healthCheck re-runs the same check. + useEffect(() => { + const controller = new AbortController(); + setHealth(null); + checkHealth(server, controller.signal).then((next) => { + if (!controller.signal.aborted) { + setHealth(next); + } + }); + return () => controller.abort(); + }, [server, healthCheck]); + + const solve = useCallback(async (input: SceneInput) => { + inFlight.current?.abort(); + planInFlight.current?.abort(); + const controller = new AbortController(); + inFlight.current = controller; + setView((current) => ({ + status: "loading", + input, + previous: current.status === "shown" ? current.shown : null, + })); + let result: Result; + try { + result = await requestPlacement(serverRef.current, input, controller.signal); + } catch (error) { + if (controller.signal.aborted) { + return; + } + const failure = failureOf(error); + setView({ status: "failed", input, name: input.name, failure }); + if (failure.kind === "unreachable") { + setHealth({ ok: false }); + } + return; + } + answers.current += 1; + const key = answers.current; + setView((current) => ({ + status: "shown", + input, + shown: { + result, + plan: { status: "drawing" }, + saved: false, + key, + replacing: current.status === "loading" && current.previous !== null, + }, + })); + // The answer proves the server is up and names its rules. + setHealth({ ok: true, policy: result.policy.id ?? "none" }); + + // The plan is a second upload, so it is asked for only once the scene has an answer. + const planController = new AbortController(); + planInFlight.current = planController; + try { + const svg = await requestPlan(serverRef.current, input, planController.signal); + const plan: PlanState = svg === null ? { status: "missing" } : { status: "ready", svg }; + setView((current) => withPlan(current, key, plan)); + } catch { + // Aborted: a newer scene replaced this answer. + } + }, []); + + function pickSample(sample: Sample) { + // The address names the sample, so a link opens the same answer. + setSampleParam(sample.id); + solve(sceneFromSample(sample.id, sample.title, sample.scene)); + } + + async function pickFile(file: File) { + setSampleParam(null); + try { + await solve(await sceneFromFile(file)); + } catch (error) { + inFlight.current?.abort(); + setView({ status: "failed", input: null, name: file.name, failure: failureOf(error) }); + } + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: opens the linked sample once, on load. + useEffect(() => { + const linked = SAMPLES.find( + (s) => s.id === new URLSearchParams(window.location.search).get("sample"), + ); + if (linked) { + pickSample(linked); + } + }, []); + + function showSaved(sample: Sample, input: SceneInput) { + answers.current += 1; + setView({ + status: "shown", + input, + shown: { + result: parseResult(JSON.parse(sample.savedResult)), + plan: { status: "ready", svg: sample.savedPlan }, + saved: true, + key: answers.current, + replacing: false, + }, + }); + } + + function changeServer(next: string) { + inFlight.current?.abort(); + serverRef.current = next; + storeServer(next); + setServer(next); + setHealthCheck((n) => n + 1); + // A scene that failed is usually why the server was changed: ask the new one. + if (view.status === "failed" && view.input) { + solve(view.input); + } else if (view.status === "loading") { + setView( + view.previous + ? { status: "shown", input: view.input, shown: view.previous } + : { status: "empty" }, + ); + } + } + + // On a phone the scene list sits above the sheet, so bring a new answer into view. + useEffect(() => { + if (view.status !== "shown" && view.status !== "failed") { + return; + } + const top = sheet.current?.getBoundingClientRect().top ?? 0; + if (top > window.innerHeight * 0.6) { + sheet.current?.scrollIntoView({ block: "start" }); + } + }, [view.status]); + + const input = view.status === "empty" ? null : view.input; + const failed = view.status === "failed" ? view : null; + const retryInput = failed?.input ?? null; + const fileName = + view.status === "failed" && view.input === null + ? view.name + : input && input.sampleId === null + ? input.name + : null; + const shown = + view.status === "shown" ? view.shown : view.status === "loading" ? view.previous : null; + const announcement = + view.status === "loading" + ? `Checking ${view.input.name}…` + : view.status === "shown" + ? `${STAMP[shownDecision(view.shown.result)]}: ${view.shown.result.summary}` + : view.status === "failed" + ? FAILURE_TITLE[view.failure.kind] + : ""; + + return ( +
+
+

+ Placement review House Scan +

+ +
+

+ {announcement} +

+
+ +
+ {view.status === "loading" &&
+
+
+ ); +} + +function EmptyView() { + return ( +
+

Where does the battery go?

+

+ Pick a sample or drop a scan. The placement server answers with a spot, the cable run, a + site plan and the reason behind every check. +

+
+ ); +} + +interface FailureProps { + failure: Failure; + sample: Sample | null; + /** Null when resending cannot help (a file the page itself refused). */ + onRetry: (() => void) | null; + onSaved: (sample: Sample) => void; +} + +function FailureView({ failure, sample, onRetry, onSaved }: FailureProps) { return ( -
-

House Scan: web capture

-

Browser capture experiments live here.

-
+
+

{FAILURE_TITLE[failure.kind]}

+ {failure.kind === "unreachable" && + (isMixedContent(failure.server, window.location.protocol) ? ( +

+ This page was loaded over https, so the browser blocks calls to the http address{" "} + {failure.server}. Give the server an https address, or + run this page locally with pnpm dev. +

+ ) : ( + <> +

+ Nothing answered at {failure.server}. Start the + server from server/ with uv run uvicorn api:app, or point + this page at another one with Change above. +

+

{failure.detail}

+ + ))} + {failure.kind === "http_error" && ( + <> +

+ {failure.status >= 500 + ? "The server or its host failed before answering, or took too long. Try again; a scene that keeps failing may be too large for the hosted server." + : "The answer isn't in the placement server's format, so this address may be another site. Check it with Change above."} +

+

+ HTTP {failure.status} from {failure.server} +

+ + )} + {failure.kind === "too_large" &&

{failure.message}

} + {failure.kind === "refused" && ( + <> +

{failure.message}

+

+ {failure.code} + {failure.path ? ` at ${failure.path}` : ""} +

+ + )} + {failure.kind === "unexpected" &&

{failure.message}

} +
+ {onRetry && ( + + )} + {sample && failure.kind === "unreachable" && ( + + )} +
+
); } diff --git a/web/src/components/ResultView.tsx b/web/src/components/ResultView.tsx new file mode 100644 index 000000000..1b196fabd --- /dev/null +++ b/web/src/components/ResultView.tsx @@ -0,0 +1,189 @@ +import { useMemo } from "react"; +import type { Result } from "../lib/api.ts"; +import { feet, fromMeter, measured, shownDecision } from "../lib/format.ts"; + +type Check = Result["checks"][number]; + +export const STAMP: Record = { + pass: "Fits", + manual_review: "Needs review", + reject: "No spot", +}; + +const CAUSE: Record, string> = { + margin: "Too close to call", + unobserved: "Not seen", + unknown_attribute: "Detail missing", + rule_requires_review: "Policy asks a person", +}; + +const OUTCOME_WORD: Record = { + pass: "Pass", + unsure: "Unsure", + fail: "Fail", +}; + +/** The site plan arrives after the answer (a second request), or not at all. */ +export type PlanState = + | { status: "drawing" } + | { status: "ready"; svg: string } + | { status: "missing" }; + +interface Props { + result: Result; + plan: PlanState; + saved: boolean; + /** "full" when an answer appears; "stamp" when it replaces another, so only the stamp lands. */ + entrance: "full" | "stamp"; +} + +/** The SVG's own width and height, so the image reserves its space before it decodes. */ +function planSize(svg: string): { width: number; height: number } | null { + const root = new DOMParser().parseFromString(svg, "image/svg+xml").documentElement; + const width = Number(root.getAttribute("width")); + const height = Number(root.getAttribute("height")); + return width > 0 && height > 0 ? { width, height } : null; +} + +function planAlt(result: Result): string { + const spot = result.spot; + const where = spot + ? `the battery ${fromMeter((spot.span_ft[0] + spot.span_ft[1]) / 2)}` + : "no battery spot"; + const cable = result.route ? `, and a ${feet(result.route.length_ft)} cable run` : ""; + return `Site plan from above: the scanned wall and the meter, ${where}${cable}.`; +} + +export function ResultView({ result, plan, saved, entrance }: Props) { + const decision = shownDecision(result); + const spot = result.spot ?? result.nearest_considered ?? null; + const attention = result.checks.filter((c) => c.outcome !== "pass"); + const passing = result.checks.length - attention.length; + const views = result.missing_evidence; + const svg = plan.status === "ready" ? plan.svg : null; + const size = useMemo(() => (svg ? planSize(svg) : null), [svg]); + + return ( +
+
+

+ {STAMP[decision]} +

+
+

{result.summary}

+

+ {saved ? "Saved answer" : "Live answer"} · rules {result.policy.id ?? "none"} + {result.policy.version ? ` v${result.policy.version}` : ""} + {result.policy.auto_approve ? "" : " (not approved for automatic decisions)"} +

+
+
+ +
+
+
{result.spot ? "Battery" : "Closest spot tried"}
+
{spot ? fromMeter((spot.span_ft[0] + spot.span_ft[1]) / 2) : "None"}
+
+
+
Cable run
+
+ {result.route + ? measured(result.route.length_ft, result.route.plus_minus_ft) + : spot?.route_length_ft != null + ? feet(spot.route_length_ft) + : "None"} +
+
+
+
Checks
+
+ {passing} of {result.checks.length} pass +
+
+
+ +
+ {svg ? ( + {planAlt(result)} + ) : plan.status === "drawing" ? ( +

Drawing the site plan…

+ ) : ( +

The server did not send a site plan for this answer.

+ )} +
+ + {attention.length > 0 && ( +
+

Needs a look

+
    + {attention.map((check) => ( + + ))} +
+
+ )} + + {views.length > 0 && ( +
+

Views to add

+
    + {views.map((view) => ( +
  1. + {view.message} +
  2. + ))} +
+
+ )} + +
+ All {result.checks.length} checks +
    + {result.checks.map((check) => ( + + ))} +
+
+
+ ); +} + +function limitText(check: Check): string | null { + if (check.threshold_ft == null) { + return null; + } + if (check.comparison === "at_most") { + const review = + check.review_threshold_ft == null ? "" : `, review past ${feet(check.review_threshold_ft)}`; + return `max ${feet(check.threshold_ft)}${review}`; + } + return `needs more than ${feet(check.threshold_ft)}`; +} + +function CheckRow({ check }: { check: Check }) { + const limit = limitText(check); + return ( +
  • +
  • + ); +} diff --git a/web/src/components/ScenePanel.tsx b/web/src/components/ScenePanel.tsx new file mode 100644 index 000000000..361f39211 --- /dev/null +++ b/web/src/components/ScenePanel.tsx @@ -0,0 +1,85 @@ +import { type DragEvent, useEffect, useState } from "react"; +import { SAMPLES, type Sample } from "../samples/index.ts"; + +interface Props { + activeSampleId: string | null; + fileName: string | null; + onSample: (sample: Sample) => void; + onFile: (file: File) => void; +} + +export function ScenePanel({ activeSampleId, fileName, onSample, onFile }: Props) { + const [dragging, setDragging] = useState(false); + + // A file dropped anywhere else would make the browser open it and leave the page. + useEffect(() => { + const ignore = (event: globalThis.DragEvent) => event.preventDefault(); + window.addEventListener("dragover", ignore); + window.addEventListener("drop", ignore); + return () => { + window.removeEventListener("dragover", ignore); + window.removeEventListener("drop", ignore); + }; + }, []); + + function drop(event: DragEvent) { + event.preventDefault(); + setDragging(false); + const file = event.dataTransfer.files[0]; + if (file) { + onFile(file); + } + } + + return ( +
    +

    Scene

    +
      + {SAMPLES.map((sample) => ( +
    • + +
    • + ))} +
    + +
    + ); +} diff --git a/web/src/components/ServerSetting.tsx b/web/src/components/ServerSetting.tsx new file mode 100644 index 000000000..fefad6248 --- /dev/null +++ b/web/src/components/ServerSetting.tsx @@ -0,0 +1,105 @@ +import { type FormEvent, useEffect, useRef, useState } from "react"; +import { type Health, isMixedContent } from "../lib/api.ts"; + +interface Props { + server: string; + health: Health | null; + onChange: (server: string) => void; +} + +export function ServerSetting({ server, health, onChange }: Props) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(server); + const changeButton = useRef(null); + const wasEditing = useRef(false); + + // Closing the form unmounts the focused control; give focus back to Change. + useEffect(() => { + if (wasEditing.current && !editing) { + changeButton.current?.focus(); + } + wasEditing.current = editing; + }, [editing]); + + function save(event: FormEvent) { + event.preventDefault(); + onChange(draft.trim() || server); + setEditing(false); + } + + const pageProtocol = window.location.protocol; + const draftBlocked = isMixedContent(draft, pageProtocol); + const status = health === null ? "checking" : health.ok ? "up" : "down"; + const statusText = + health === null + ? "Checking…" + : health.ok + ? `Connected · rules ${health.policy}` + : isMixedContent(server, pageProtocol) + ? "Blocked: an http server from this https page" + : "Not reachable"; + + if (editing) { + return ( +
    { + if (event.key === "Escape") { + setEditing(false); + } + }} + > + + setDraft(event.target.value)} + placeholder="http://localhost:8000" + // biome-ignore lint/a11y/noAutofocus: the field is what Change opened. + autoFocus + spellCheck={false} + autoComplete="off" + autoCapitalize="none" + autoCorrect="off" + inputMode="url" + enterKeyHint="done" + aria-describedby={draftBlocked ? "server-url-warning" : undefined} + /> + + + {draftBlocked && ( +

    + This page is loaded over https, so the browser will block an http:// server. Use an + https address. +

    + )} +
    + ); + } + + return ( +
    +
    + ); +} diff --git a/web/src/contract/result.schema.json b/web/src/contract/result.schema.json new file mode 100644 index 000000000..1c94d7115 --- /dev/null +++ b/web/src/contract/result.schema.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/SamGu-NRX/house-scanning/server/schemas/result.schema.json", + "title": "Battery placement result", + "description": "The placement server's answer for one scene (contract C2). Lengths are in feet; plan coordinates [x, z] and s follow scene.schema.json. The result settles placement only, never the whole installation: electrical checks (panel, meter) are outside it.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "decision", "summary", "reasons", "policy", "spot", "route", "checks", "missing_evidence", "ends", "sweep", "stats"], + "properties": { + "schema_version": {"const": "1.0"}, + "decision": { + "enum": ["pass", "manual_review", "reject"], + "description": "pass: a fully observed spot passes every check under an approved policy. manual_review: a person must decide, because the best spot has an UNSURE check, the policy is not approved for automatic decisions, or an area that could hold a valid spot was not seen. reject: every spot within reach fails by a clear margin, and both ends of the walk are known." + }, + "summary": {"type": "string", "description": "One sentence for the homeowner or reviewer."}, + "reasons": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { + "enum": ["all_checks_pass", "policy_not_approved", "unsure_checks", "unobserved_area", "unexplored_end", "all_spots_fail", "no_wall_segment_fits"] + }, + "message": {"type": "string"}, + "checks": {"type": "array", "items": {"type": "string"}, "description": "Check ids behind this reason."} + } + } + }, + "policy": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "auto_approve", "sources", "rules_sha256"], + "properties": { + "id": {"type": ["string", "null"]}, + "version": {"type": ["string", "null"]}, + "auto_approve": {"type": "boolean", "description": "Whether the rules allow automatic decisions: false when no policy is selected or the rules set auto_approve false (the public strict policy does, because some values are placeholders; the public demo policy decides anyway and says so in notice). When false, every would-be pass or reject becomes manual_review. Each check's rule.placeholder says which values decided it."}, + "sources": {"type": "array", "items": {"enum": ["public", "private"]}, "description": "Which rules files were loaded."}, + "rules_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Hash of the merged rules the decision used."}, + "notice": {"type": ["string", "null"], "description": "Whose rules these are, to show with the answer, for example that the public demo policy is not Base's. Null for rules that need no such label."} + } + }, + "spot": { + "description": "The chosen battery position: the best passing spot, or for manual_review the best spot with no failing check. Null when no such spot exists, including every reject.", + "oneOf": [{"type": "null"}, {"$ref": "#/$defs/spot"}] + }, + "route": { + "description": "Cable route from the meter to the chosen spot. Null when `spot` is null.", + "oneOf": [{"type": "null"}, {"$ref": "#/$defs/route"}] + }, + "checks": { + "description": "Every check at the chosen spot, or, when `spot` is null, at `nearest_considered`.", + "type": "array", + "items": {"$ref": "#/$defs/check"} + }, + "nearest_considered": { + "description": "When `spot` is null: the evaluated spot closest to passing (fewest failing checks, then fewest unsure, then shortest route), whose checks explain why it fails. Null otherwise.", + "oneOf": [{"type": "null"}, {"$ref": "#/$defs/spot"}] + }, + "missing_evidence": { + "description": "Views that would settle an UNSURE result. Only areas nobody observed appear here; an UNSURE caused by a measurement inside its error band needs a person, not more photos. Empty for pass and reject. Supplying exactly what a band request names settles it: a coverage.observed entry with the same band and span_ft, and out_ft at least the request's out_ft.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "message"], + "properties": { + "kind": {"enum": ["band", "past_end"]}, + "band": {"enum": ["wall", "ground", "overhead", "facing"]}, + "span_ft": {"$ref": "#/$defs/span"}, + "out_ft": {"type": "number", "minimum": 0, "description": "For ground, facing and overhead requests: how far the view must reach, out from the wall (ground, facing) or up from the ground (overhead). Report it as the observed entry's out_ft; seeing further also settles it."}, + "side": {"enum": ["left", "right"]}, + "checks": {"type": "array", "items": {"type": "string"}}, + "message": {"type": "string"} + } + } + }, + "ends": { + "type": "object", + "additionalProperties": false, + "required": ["left", "right"], + "properties": { + "left": {"$ref": "#/$defs/end"}, + "right": {"$ref": "#/$defs/end"} + } + }, + "sweep": { + "description": "Outcome of every battery start position along the wall, merged into runs of equal outcome. Shows why each stretch fails; the site plan colours the wall with it.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["wall_id", "start_ft", "outcome", "failing", "unsure"], + "properties": { + "wall_id": {"type": "string"}, + "segment": {"type": "integer", "minimum": 0, "description": "Straight baseline segment of `wall_id`; runs never cross a corner."}, + "start_ft": {"$ref": "#/$defs/span", "description": "Range of battery start positions (left edge, in s) in this run. Stretches too far along the wall for any cable route are one failing run, not evaluated start by start."}, + "outcome": {"$ref": "#/$defs/outcome"}, + "failing": {"type": "array", "items": {"type": "string"}}, + "unsure": {"type": "array", "items": {"type": "string"}} + } + } + }, + "stats": { + "type": "object", + "additionalProperties": false, + "required": ["candidates", "pass", "unsure", "fail", "elapsed_ms", "input_sha256"], + "properties": { + "candidates": {"type": "integer", "minimum": 0}, + "pass": {"type": "integer", "minimum": 0}, + "unsure": {"type": "integer", "minimum": 0}, + "fail": {"type": "integer", "minimum": 0}, + "elapsed_ms": {"type": "number", "minimum": 0}, + "input_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Hash of the scene JSON as uploaded."} + } + } + }, + "$defs": { + "point2": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}}, + "span": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}}, + "outcome": {"enum": ["pass", "fail", "unsure"]}, + "end": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "s_ft", "point"], + "properties": { + "kind": {"enum": ["limit", "unexplored"]}, + "s_ft": {"type": "number", "description": "Where the wall chain ends, in s."}, + "point": {"$ref": "#/$defs/point2"}, + "beyond_reach": {"type": "boolean", "description": "True when this end is so far along the wall that no spot past it could pass the route-length check, so an unexplored end there does not block a reject."} + } + }, + "check": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "outcome", "reason", "measured_ft", "plus_minus_ft", "threshold_ft", "comparison", "rule"], + "properties": { + "id": {"type": "string", "description": "Stable identifier, for example gas_clearance or route_length."}, + "label": {"type": "string"}, + "outcome": {"$ref": "#/$defs/outcome"}, + "unsure_cause": { + "enum": ["margin", "unobserved", "unknown_attribute", "rule_requires_review"], + "description": "Only for unsure. margin: measured inside the error band, a person must judge. unobserved: the deciding area was not seen, more photos fix it. unknown_attribute: a fact the camera did not establish (for example whether a window opens). rule_requires_review: the policy sends this situation to a person (for example a cable routed over a door)." + }, + "reason": {"type": "string"}, + "measured_ft": {"type": ["number", "null"], "description": "The deciding measurement, for example the gap to the nearest gas meter. Null when nothing relevant was found or it was not observed."}, + "plus_minus_ft": {"type": ["number", "null"]}, + "threshold_ft": {"type": ["number", "null"]}, + "review_threshold_ft": {"type": "number", "description": "Present on checks with a review band (route_length): a value that doesn't clear this line (at_most: measured + error < review_threshold_ft) is at best unsure, even when it clears threshold_ft. Same convention as the scoring harness's review_threshold."}, + "comparison": {"enum": ["at_least", "at_most", null], "description": "at_least: pass needs measured - error > threshold. at_most: pass needs measured + error < threshold."}, + "subject": {"type": ["string", "null"], "description": "What the measurement is to, for example \"objects[2] gas_meter\"."}, + "rule": { + "type": "object", + "additionalProperties": false, + "required": ["key", "source", "placeholder"], + "properties": { + "key": {"type": "string", "description": "Parameter name in rules.yaml."}, + "source": {"type": "string", "description": "Citation for the value."}, + "placeholder": {"type": "boolean", "description": "True for a demo value with no public source."} + } + } + } + }, + "spot": { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "wall_id", "segment", "span_ft", "width_ft", "depth_ft", "height_ft", "footprint", "center", "along", "outward", "meter_offset_ft", "route_length_ft"], + "properties": { + "outcome": {"$ref": "#/$defs/outcome"}, + "wall_id": {"type": "string"}, + "segment": {"type": "integer", "minimum": 0, "description": "Index of the straight baseline segment of `wall_id` the battery backs onto."}, + "span_ft": {"$ref": "#/$defs/span", "description": "Battery's stretch of wall in s, left edge first."}, + "width_ft": {"type": "number"}, + "depth_ft": {"type": "number"}, + "height_ft": {"type": "number"}, + "footprint": {"type": "array", "minItems": 4, "maxItems": 4, "items": {"$ref": "#/$defs/point2"}, "description": "Plan corners [x, z]: back-left, back-right, front-right, front-left."}, + "center": {"$ref": "#/$defs/point2", "description": "Plan centre of the footprint."}, + "along": {"$ref": "#/$defs/point2", "description": "Unit vector along the wall toward +s."}, + "outward": {"$ref": "#/$defs/point2", "description": "Unit vector from the wall out toward the battery's front."}, + "meter_offset_ft": { + "type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}, + "description": "Footprint centre minus the meter's plan position, [dx, dz] in the scene frame's axes. scene.json carries no ground height, so the app sets the height by resting the box on its detected ground plane. Attach AR content to the meter's anchor with this offset, rotated by the anchor's orientation if the anchor is not axis-aligned with the scene frame." + }, + "route_length_ft": {"type": ["number", "null"]} + } + }, + "route": { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "length_ft", "plus_minus_ft", "height_ft", "polyline", "detours", "crossings"], + "properties": { + "outcome": {"$ref": "#/$defs/outcome"}, + "length_ft": {"type": "number", "description": "Length along the supported wall from the meter to the battery's near edge, including detours around wall objects."}, + "plus_minus_ft": {"type": "number"}, + "height_ft": {"type": "number", "description": "Height above ground the cable runs at, from the rules."}, + "polyline": {"type": "array", "items": {"$ref": "#/$defs/point2"}, "description": "Plan points from the meter along the wall to the battery."}, + "detours": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "extra_ft"], + "properties": {"subject": {"type": "string"}, "extra_ft": {"type": "number"}} + } + }, + "crossings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "span_ft", "effect"], + "properties": { + "subject": {"type": "string"}, + "span_ft": {"$ref": "#/$defs/span"}, + "effect": {"enum": ["fail", "review", "detour", "allow"]} + } + } + } + } + } + } +} diff --git a/web/src/contract/result.ts b/web/src/contract/result.ts new file mode 100644 index 000000000..c58ba7f3a --- /dev/null +++ b/web/src/contract/result.ts @@ -0,0 +1,270 @@ +// Generated from result.schema.json by `pnpm contract`. Do not edit by hand. + +export type Outcome = "pass" | "fail" | "unsure"; +/** + * Battery's stretch of wall in s, left edge first. + * + * @minItems 2 + * @maxItems 2 + */ +export type Span = [number, number]; +/** + * @minItems 2 + * @maxItems 2 + */ +export type Point2 = [number, number]; +/** + * Plan centre of the footprint. + * + * @minItems 2 + * @maxItems 2 + */ +export type Point21 = [number, number]; +/** + * Unit vector along the wall toward +s. + * + * @minItems 2 + * @maxItems 2 + */ +export type Point22 = [number, number]; +/** + * Unit vector from the wall out toward the battery's front. + * + * @minItems 2 + * @maxItems 2 + */ +export type Point23 = [number, number]; +/** + * @minItems 2 + * @maxItems 2 + */ +export type Span1 = [number, number]; +/** + * Range of battery start positions (left edge, in s) in this run. Stretches too far along the wall for any cable route are one failing run, not evaluated start by start. + * + * @minItems 2 + * @maxItems 2 + */ +export type Span2 = [number, number]; + +/** + * The placement server's answer for one scene (contract C2). Lengths are in feet; plan coordinates [x, z] and s follow scene.schema.json. The result settles placement only, never the whole installation: electrical checks (panel, meter) are outside it. + */ +export interface BatteryPlacementResult { + schema_version: string; + /** + * pass: a fully observed spot passes every check under an approved policy. manual_review: a person must decide, because the best spot has an UNSURE check, the policy is not approved for automatic decisions, or an area that could hold a valid spot was not seen. reject: every spot within reach fails by a clear margin, and both ends of the walk are known. + */ + decision: "pass" | "manual_review" | "reject"; + /** + * One sentence for the homeowner or reviewer. + */ + summary: string; + reasons: { + code: + | "all_checks_pass" + | "policy_not_approved" + | "unsure_checks" + | "unobserved_area" + | "unexplored_end" + | "all_spots_fail" + | "no_wall_segment_fits"; + message: string; + /** + * Check ids behind this reason. + */ + checks?: string[]; + }[]; + policy: { + id: string | null; + version: string | null; + /** + * Whether the rules allow automatic decisions: false when no policy is selected or the rules set auto_approve false (the public strict policy does, because some values are placeholders; the public demo policy decides anyway and says so in notice). When false, every would-be pass or reject becomes manual_review. Each check's rule.placeholder says which values decided it. + */ + auto_approve: boolean; + /** + * Which rules files were loaded. + */ + sources: ("public" | "private")[]; + /** + * Hash of the merged rules the decision used. + */ + rules_sha256: string; + /** + * Whose rules these are, to show with the answer, for example that the public demo policy is not Base's. Null for rules that need no such label. + */ + notice?: string | null; + }; + /** + * The chosen battery position: the best passing spot, or for manual_review the best spot with no failing check. Null when no such spot exists, including every reject. + */ + spot: null | Spot; + /** + * Cable route from the meter to the chosen spot. Null when `spot` is null. + */ + route: null | Route; + /** + * Every check at the chosen spot, or, when `spot` is null, at `nearest_considered`. + */ + checks: Check[]; + /** + * When `spot` is null: the evaluated spot closest to passing (fewest failing checks, then fewest unsure, then shortest route), whose checks explain why it fails. Null otherwise. + */ + nearest_considered?: null | Spot; + /** + * Views that would settle an UNSURE result. Only areas nobody observed appear here; an UNSURE caused by a measurement inside its error band needs a person, not more photos. Empty for pass and reject. Supplying exactly what a band request names settles it: a coverage.observed entry with the same band and span_ft, and out_ft at least the request's out_ft. + */ + missing_evidence: { + kind: "band" | "past_end"; + band?: "wall" | "ground" | "overhead" | "facing"; + span_ft?: Span1; + /** + * For ground, facing and overhead requests: how far the view must reach, out from the wall (ground, facing) or up from the ground (overhead). Report it as the observed entry's out_ft; seeing further also settles it. + */ + out_ft?: number; + side?: "left" | "right"; + checks?: string[]; + message: string; + }[]; + ends: { + left: End; + right: End; + }; + /** + * Outcome of every battery start position along the wall, merged into runs of equal outcome. Shows why each stretch fails; the site plan colours the wall with it. + */ + sweep: { + wall_id: string; + /** + * Straight baseline segment of `wall_id`; runs never cross a corner. + */ + segment?: number; + start_ft: Span2; + outcome: Outcome; + failing: string[]; + unsure: string[]; + }[]; + stats: { + candidates: number; + pass: number; + unsure: number; + fail: number; + elapsed_ms: number; + /** + * Hash of the scene JSON as uploaded. + */ + input_sha256: string; + }; +} +export interface Spot { + outcome: Outcome; + wall_id: string; + /** + * Index of the straight baseline segment of `wall_id` the battery backs onto. + */ + segment: number; + span_ft: Span; + width_ft: number; + depth_ft: number; + height_ft: number; + /** + * Plan corners [x, z]: back-left, back-right, front-right, front-left. + * + * @minItems 4 + * @maxItems 4 + */ + footprint: [Point2, Point2, Point2, Point2]; + center: Point21; + along: Point22; + outward: Point23; + /** + * Footprint centre minus the meter's plan position, [dx, dz] in the scene frame's axes. scene.json carries no ground height, so the app sets the height by resting the box on its detected ground plane. Attach AR content to the meter's anchor with this offset, rotated by the anchor's orientation if the anchor is not axis-aligned with the scene frame. + * + * @minItems 2 + * @maxItems 2 + */ + meter_offset_ft: [number, number]; + route_length_ft: number | null; +} +export interface Route { + outcome: Outcome; + /** + * Length along the supported wall from the meter to the battery's near edge, including detours around wall objects. + */ + length_ft: number; + plus_minus_ft: number; + /** + * Height above ground the cable runs at, from the rules. + */ + height_ft: number; + /** + * Plan points from the meter along the wall to the battery. + */ + polyline: Point2[]; + detours: { + subject: string; + extra_ft: number; + }[]; + crossings: { + subject: string; + span_ft: Span1; + effect: "fail" | "review" | "detour" | "allow"; + }[]; +} +export interface Check { + /** + * Stable identifier, for example gas_clearance or route_length. + */ + id: string; + label: string; + outcome: Outcome; + /** + * Only for unsure. margin: measured inside the error band, a person must judge. unobserved: the deciding area was not seen, more photos fix it. unknown_attribute: a fact the camera did not establish (for example whether a window opens). rule_requires_review: the policy sends this situation to a person (for example a cable routed over a door). + */ + unsure_cause?: "margin" | "unobserved" | "unknown_attribute" | "rule_requires_review"; + reason: string; + /** + * The deciding measurement, for example the gap to the nearest gas meter. Null when nothing relevant was found or it was not observed. + */ + measured_ft: number | null; + plus_minus_ft: number | null; + threshold_ft: number | null; + /** + * Present on checks with a review band (route_length): a value that doesn't clear this line (at_most: measured + error < review_threshold_ft) is at best unsure, even when it clears threshold_ft. Same convention as the scoring harness's review_threshold. + */ + review_threshold_ft?: number; + /** + * at_least: pass needs measured - error > threshold. at_most: pass needs measured + error < threshold. + */ + comparison: "at_least" | "at_most" | null; + /** + * What the measurement is to, for example "objects[2] gas_meter". + */ + subject?: string | null; + rule: { + /** + * Parameter name in rules.yaml. + */ + key: string; + /** + * Citation for the value. + */ + source: string; + /** + * True for a demo value with no public source. + */ + placeholder: boolean; + }; +} +export interface End { + kind: "limit" | "unexplored"; + /** + * Where the wall chain ends, in s. + */ + s_ft: number; + point: Point2; + /** + * True when this end is so far along the wall that no spot past it could pass the route-length check, so an unexplored end there does not block a reject. + */ + beyond_reach?: boolean; +} diff --git a/web/src/contract/validate-result.d.ts b/web/src/contract/validate-result.d.ts new file mode 100644 index 000000000..61df25afd --- /dev/null +++ b/web/src/contract/validate-result.d.ts @@ -0,0 +1,7 @@ +import type { BatteryPlacementResult } from "./result.ts"; + +/** Validates a parsed answer against result.schema.json (generated by `pnpm contract`). */ +declare const validate: ((data: unknown) => data is BatteryPlacementResult) & { + errors?: { instancePath: string; message?: string }[] | null; +}; +export default validate; diff --git a/web/src/contract/validate-result.js b/web/src/contract/validate-result.js new file mode 100644 index 000000000..c5fb26123 --- /dev/null +++ b/web/src/contract/validate-result.js @@ -0,0 +1,2 @@ +// Generated from result.schema.json by `pnpm contract`. Do not edit by hand. +"use strict";export const validate = validate20;export default validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://github.com/SamGu-NRX/house-scanning/server/schemas/result.schema.json","title":"Battery placement result","description":"The placement server's answer for one scene (contract C2). Lengths are in feet; plan coordinates [x, z] and s follow scene.schema.json. The result settles placement only, never the whole installation: electrical checks (panel, meter) are outside it.","type":"object","required":["schema_version","decision","summary","reasons","policy","spot","route","checks","missing_evidence","ends","sweep","stats"],"properties":{"schema_version":{"type":"string","pattern":"^1\\.\\d+$"},"decision":{"enum":["pass","manual_review","reject"],"description":"pass: a fully observed spot passes every check under an approved policy. manual_review: a person must decide, because the best spot has an UNSURE check, the policy is not approved for automatic decisions, or an area that could hold a valid spot was not seen. reject: every spot within reach fails by a clear margin, and both ends of the walk are known."},"summary":{"type":"string","description":"One sentence for the homeowner or reviewer."},"reasons":{"type":"array","items":{"type":"object","required":["code","message"],"properties":{"code":{"enum":["all_checks_pass","policy_not_approved","unsure_checks","unobserved_area","unexplored_end","all_spots_fail","no_wall_segment_fits"]},"message":{"type":"string"},"checks":{"type":"array","items":{"type":"string"},"description":"Check ids behind this reason."}}}},"policy":{"type":"object","required":["id","version","auto_approve","sources","rules_sha256"],"properties":{"id":{"type":["string","null"]},"version":{"type":["string","null"]},"auto_approve":{"type":"boolean","description":"Whether the rules allow automatic decisions: false when no policy is selected or the rules set auto_approve false (the public strict policy does, because some values are placeholders; the public demo policy decides anyway and says so in notice). When false, every would-be pass or reject becomes manual_review. Each check's rule.placeholder says which values decided it."},"sources":{"type":"array","items":{"enum":["public","private"]},"description":"Which rules files were loaded."},"rules_sha256":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"Hash of the merged rules the decision used."},"notice":{"type":["string","null"],"description":"Whose rules these are, to show with the answer, for example that the public demo policy is not Base's. Null for rules that need no such label."}}},"spot":{"description":"The chosen battery position: the best passing spot, or for manual_review the best spot with no failing check. Null when no such spot exists, including every reject.","oneOf":[{"type":"null"},{"$ref":"#/$defs/spot"}]},"route":{"description":"Cable route from the meter to the chosen spot. Null when `spot` is null.","oneOf":[{"type":"null"},{"$ref":"#/$defs/route"}]},"checks":{"description":"Every check at the chosen spot, or, when `spot` is null, at `nearest_considered`.","type":"array","items":{"$ref":"#/$defs/check"}},"nearest_considered":{"description":"When `spot` is null: the evaluated spot closest to passing (fewest failing checks, then fewest unsure, then shortest route), whose checks explain why it fails. Null otherwise.","oneOf":[{"type":"null"},{"$ref":"#/$defs/spot"}]},"missing_evidence":{"description":"Views that would settle an UNSURE result. Only areas nobody observed appear here; an UNSURE caused by a measurement inside its error band needs a person, not more photos. Empty for pass and reject. Supplying exactly what a band request names settles it: a coverage.observed entry with the same band and span_ft, and out_ft at least the request's out_ft.","type":"array","items":{"type":"object","required":["kind","message"],"properties":{"kind":{"enum":["band","past_end"]},"band":{"enum":["wall","ground","overhead","facing"]},"span_ft":{"$ref":"#/$defs/span"},"out_ft":{"type":"number","minimum":0,"description":"For ground, facing and overhead requests: how far the view must reach, out from the wall (ground, facing) or up from the ground (overhead). Report it as the observed entry's out_ft; seeing further also settles it."},"side":{"enum":["left","right"]},"checks":{"type":"array","items":{"type":"string"}},"message":{"type":"string"}}}},"ends":{"type":"object","required":["left","right"],"properties":{"left":{"$ref":"#/$defs/end"},"right":{"$ref":"#/$defs/end"}}},"sweep":{"description":"Outcome of every battery start position along the wall, merged into runs of equal outcome. Shows why each stretch fails; the site plan colours the wall with it.","type":"array","items":{"type":"object","required":["wall_id","start_ft","outcome","failing","unsure"],"properties":{"wall_id":{"type":"string"},"segment":{"type":"integer","minimum":0,"description":"Straight baseline segment of `wall_id`; runs never cross a corner."},"start_ft":{"$ref":"#/$defs/span","description":"Range of battery start positions (left edge, in s) in this run. Stretches too far along the wall for any cable route are one failing run, not evaluated start by start."},"outcome":{"$ref":"#/$defs/outcome"},"failing":{"type":"array","items":{"type":"string"}},"unsure":{"type":"array","items":{"type":"string"}}}}},"stats":{"type":"object","required":["candidates","pass","unsure","fail","elapsed_ms","input_sha256"],"properties":{"candidates":{"type":"integer","minimum":0},"pass":{"type":"integer","minimum":0},"unsure":{"type":"integer","minimum":0},"fail":{"type":"integer","minimum":0},"elapsed_ms":{"type":"number","minimum":0},"input_sha256":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"Hash of the scene JSON as uploaded."}}}},"$defs":{"point2":{"type":"array","minItems":2,"maxItems":2,"items":{"type":"number"}},"span":{"type":"array","minItems":2,"maxItems":2,"items":{"type":"number"}},"outcome":{"enum":["pass","fail","unsure"]},"end":{"type":"object","required":["kind","s_ft","point"],"properties":{"kind":{"enum":["limit","unexplored"]},"s_ft":{"type":"number","description":"Where the wall chain ends, in s."},"point":{"$ref":"#/$defs/point2"},"beyond_reach":{"type":"boolean","description":"True when this end is so far along the wall that no spot past it could pass the route-length check, so an unexplored end there does not block a reject."}}},"check":{"type":"object","required":["id","label","outcome","reason","measured_ft","plus_minus_ft","threshold_ft","comparison","rule"],"properties":{"id":{"type":"string","description":"Stable identifier, for example gas_clearance or route_length."},"label":{"type":"string"},"outcome":{"$ref":"#/$defs/outcome"},"unsure_cause":{"enum":["margin","unobserved","unknown_attribute","rule_requires_review"],"description":"Only for unsure. margin: measured inside the error band, a person must judge. unobserved: the deciding area was not seen, more photos fix it. unknown_attribute: a fact the camera did not establish (for example whether a window opens). rule_requires_review: the policy sends this situation to a person (for example a cable routed over a door)."},"reason":{"type":"string"},"measured_ft":{"type":["number","null"],"description":"The deciding measurement, for example the gap to the nearest gas meter. Null when nothing relevant was found or it was not observed."},"plus_minus_ft":{"type":["number","null"]},"threshold_ft":{"type":["number","null"]},"review_threshold_ft":{"type":"number","description":"Present on checks with a review band (route_length): a value that doesn't clear this line (at_most: measured + error < review_threshold_ft) is at best unsure, even when it clears threshold_ft. Same convention as the scoring harness's review_threshold."},"comparison":{"enum":["at_least","at_most",null],"description":"at_least: pass needs measured - error > threshold. at_most: pass needs measured + error < threshold."},"subject":{"type":["string","null"],"description":"What the measurement is to, for example \"objects[2] gas_meter\"."},"rule":{"type":"object","required":["key","source","placeholder"],"properties":{"key":{"type":"string","description":"Parameter name in rules.yaml."},"source":{"type":"string","description":"Citation for the value."},"placeholder":{"type":"boolean","description":"True for a demo value with no public source."}}}}},"spot":{"type":"object","required":["outcome","wall_id","segment","span_ft","width_ft","depth_ft","height_ft","footprint","center","along","outward","meter_offset_ft","route_length_ft"],"properties":{"outcome":{"$ref":"#/$defs/outcome"},"wall_id":{"type":"string"},"segment":{"type":"integer","minimum":0,"description":"Index of the straight baseline segment of `wall_id` the battery backs onto."},"span_ft":{"$ref":"#/$defs/span","description":"Battery's stretch of wall in s, left edge first."},"width_ft":{"type":"number"},"depth_ft":{"type":"number"},"height_ft":{"type":"number"},"footprint":{"type":"array","minItems":4,"maxItems":4,"items":{"$ref":"#/$defs/point2"},"description":"Plan corners [x, z]: back-left, back-right, front-right, front-left."},"center":{"$ref":"#/$defs/point2","description":"Plan centre of the footprint."},"along":{"$ref":"#/$defs/point2","description":"Unit vector along the wall toward +s."},"outward":{"$ref":"#/$defs/point2","description":"Unit vector from the wall out toward the battery's front."},"meter_offset_ft":{"type":"array","minItems":2,"maxItems":2,"items":{"type":"number"},"description":"Footprint centre minus the meter's plan position, [dx, dz] in the scene frame's axes. scene.json carries no ground height, so the app sets the height by resting the box on its detected ground plane. Attach AR content to the meter's anchor with this offset, rotated by the anchor's orientation if the anchor is not axis-aligned with the scene frame."},"route_length_ft":{"type":["number","null"]}}},"route":{"type":"object","required":["outcome","length_ft","plus_minus_ft","height_ft","polyline","detours","crossings"],"properties":{"outcome":{"$ref":"#/$defs/outcome"},"length_ft":{"type":"number","description":"Length along the supported wall from the meter to the battery's near edge, including detours around wall objects."},"plus_minus_ft":{"type":"number"},"height_ft":{"type":"number","description":"Height above ground the cable runs at, from the rules."},"polyline":{"type":"array","items":{"$ref":"#/$defs/point2"},"description":"Plan points from the meter along the wall to the battery."},"detours":{"type":"array","items":{"type":"object","required":["subject","extra_ft"],"properties":{"subject":{"type":"string"},"extra_ft":{"type":"number"}}}},"crossings":{"type":"array","items":{"type":"object","required":["subject","span_ft","effect"],"properties":{"subject":{"type":"string"},"span_ft":{"$ref":"#/$defs/span"},"effect":{"enum":["fail","review","detour","allow"]}}}}}}}};const schema34 = {"type":"array","minItems":2,"maxItems":2,"items":{"type":"number"}};const schema33 = {"enum":["pass","fail","unsure"]};const pattern4 = new RegExp("^1\\.\\d+$", "u");const pattern5 = new RegExp("^[0-9a-f]{64}$", "u");const schema32 = {"type":"object","required":["outcome","wall_id","segment","span_ft","width_ft","depth_ft","height_ft","footprint","center","along","outward","meter_offset_ft","route_length_ft"],"properties":{"outcome":{"$ref":"#/$defs/outcome"},"wall_id":{"type":"string"},"segment":{"type":"integer","minimum":0,"description":"Index of the straight baseline segment of `wall_id` the battery backs onto."},"span_ft":{"$ref":"#/$defs/span","description":"Battery's stretch of wall in s, left edge first."},"width_ft":{"type":"number"},"depth_ft":{"type":"number"},"height_ft":{"type":"number"},"footprint":{"type":"array","minItems":4,"maxItems":4,"items":{"$ref":"#/$defs/point2"},"description":"Plan corners [x, z]: back-left, back-right, front-right, front-left."},"center":{"$ref":"#/$defs/point2","description":"Plan centre of the footprint."},"along":{"$ref":"#/$defs/point2","description":"Unit vector along the wall toward +s."},"outward":{"$ref":"#/$defs/point2","description":"Unit vector from the wall out toward the battery's front."},"meter_offset_ft":{"type":"array","minItems":2,"maxItems":2,"items":{"type":"number"},"description":"Footprint centre minus the meter's plan position, [dx, dz] in the scene frame's axes. scene.json carries no ground height, so the app sets the height by resting the box on its detected ground plane. Attach AR content to the meter's anchor with this offset, rotated by the anchor's orientation if the anchor is not axis-aligned with the scene frame."},"route_length_ft":{"type":["number","null"]}}};const schema35 = {"type":"array","minItems":2,"maxItems":2,"items":{"type":"number"}};function validate21(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate21.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(errors === 0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((((((((((((((data.outcome === undefined) && (missing0 = "outcome")) || ((data.wall_id === undefined) && (missing0 = "wall_id"))) || ((data.segment === undefined) && (missing0 = "segment"))) || ((data.span_ft === undefined) && (missing0 = "span_ft"))) || ((data.width_ft === undefined) && (missing0 = "width_ft"))) || ((data.depth_ft === undefined) && (missing0 = "depth_ft"))) || ((data.height_ft === undefined) && (missing0 = "height_ft"))) || ((data.footprint === undefined) && (missing0 = "footprint"))) || ((data.center === undefined) && (missing0 = "center"))) || ((data.along === undefined) && (missing0 = "along"))) || ((data.outward === undefined) && (missing0 = "outward"))) || ((data.meter_offset_ft === undefined) && (missing0 = "meter_offset_ft"))) || ((data.route_length_ft === undefined) && (missing0 = "route_length_ft"))){validate21.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {if(data.outcome !== undefined){let data0 = data.outcome;const _errs1 = errors;if(!(((data0 === "pass") || (data0 === "fail")) || (data0 === "unsure"))){validate21.errors = [{instancePath:instancePath+"/outcome",schemaPath:"#/$defs/outcome/enum",keyword:"enum",params:{allowedValues: schema33.enum},message:"must be equal to one of the allowed values"}];return false;}var valid0 = _errs1 === errors;}else {var valid0 = true;}if(valid0){if(data.wall_id !== undefined){const _errs3 = errors;if(typeof data.wall_id !== "string"){validate21.errors = [{instancePath:instancePath+"/wall_id",schemaPath:"#/properties/wall_id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs3 === errors;}else {var valid0 = true;}if(valid0){if(data.segment !== undefined){let data2 = data.segment;const _errs5 = errors;if(!(((typeof data2 == "number") && (!(data2 % 1) && !isNaN(data2))) && (isFinite(data2)))){validate21.errors = [{instancePath:instancePath+"/segment",schemaPath:"#/properties/segment/type",keyword:"type",params:{type: "integer"},message:"must be integer"}];return false;}if(errors === _errs5){if((typeof data2 == "number") && (isFinite(data2))){if(data2 < 0 || isNaN(data2)){validate21.errors = [{instancePath:instancePath+"/segment",schemaPath:"#/properties/segment/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"}];return false;}}}var valid0 = _errs5 === errors;}else {var valid0 = true;}if(valid0){if(data.span_ft !== undefined){let data3 = data.span_ft;const _errs7 = errors;const _errs8 = errors;if(errors === _errs8){if(Array.isArray(data3)){if(data3.length > 2){validate21.errors = [{instancePath:instancePath+"/span_ft",schemaPath:"#/$defs/span/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data3.length < 2){validate21.errors = [{instancePath:instancePath+"/span_ft",schemaPath:"#/$defs/span/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid3 = true;const len0 = data3.length;for(let i0=0; i0 4){validate21.errors = [{instancePath:instancePath+"/footprint",schemaPath:"#/properties/footprint/maxItems",keyword:"maxItems",params:{limit: 4},message:"must NOT have more than 4 items"}];return false;}else {if(data8.length < 4){validate21.errors = [{instancePath:instancePath+"/footprint",schemaPath:"#/properties/footprint/minItems",keyword:"minItems",params:{limit: 4},message:"must NOT have fewer than 4 items"}];return false;}else {var valid4 = true;const len1 = data8.length;for(let i1=0; i1 2){validate21.errors = [{instancePath:instancePath+"/footprint/" + i1,schemaPath:"#/$defs/point2/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data9.length < 2){validate21.errors = [{instancePath:instancePath+"/footprint/" + i1,schemaPath:"#/$defs/point2/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid6 = true;const len2 = data9.length;for(let i2=0; i2 2){validate21.errors = [{instancePath:instancePath+"/center",schemaPath:"#/$defs/point2/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data11.length < 2){validate21.errors = [{instancePath:instancePath+"/center",schemaPath:"#/$defs/point2/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid8 = true;const len3 = data11.length;for(let i3=0; i3 2){validate21.errors = [{instancePath:instancePath+"/along",schemaPath:"#/$defs/point2/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data13.length < 2){validate21.errors = [{instancePath:instancePath+"/along",schemaPath:"#/$defs/point2/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid10 = true;const len4 = data13.length;for(let i4=0; i4 2){validate21.errors = [{instancePath:instancePath+"/outward",schemaPath:"#/$defs/point2/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data15.length < 2){validate21.errors = [{instancePath:instancePath+"/outward",schemaPath:"#/$defs/point2/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid12 = true;const len5 = data15.length;for(let i5=0; i5 2){validate21.errors = [{instancePath:instancePath+"/meter_offset_ft",schemaPath:"#/properties/meter_offset_ft/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data17.length < 2){validate21.errors = [{instancePath:instancePath+"/meter_offset_ft",schemaPath:"#/properties/meter_offset_ft/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid13 = true;const len6 = data17.length;for(let i6=0; i6 2){validate23.errors = [{instancePath:instancePath+"/polyline/" + i0,schemaPath:"#/$defs/point2/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data5.length < 2){validate23.errors = [{instancePath:instancePath+"/polyline/" + i0,schemaPath:"#/$defs/point2/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid4 = true;const len1 = data5.length;for(let i1=0; i1 2){validate23.errors = [{instancePath:instancePath+"/crossings/" + i3+"/span_ft",schemaPath:"#/$defs/span/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data14.length < 2){validate23.errors = [{instancePath:instancePath+"/crossings/" + i3+"/span_ft",schemaPath:"#/$defs/span/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid10 = true;const len4 = data14.length;for(let i4=0; i4 threshold. at_most: pass needs measured + error < threshold."},"subject":{"type":["string","null"],"description":"What the measurement is to, for example \"objects[2] gas_meter\"."},"rule":{"type":"object","required":["key","source","placeholder"],"properties":{"key":{"type":"string","description":"Parameter name in rules.yaml."},"source":{"type":"string","description":"Citation for the value."},"placeholder":{"type":"boolean","description":"True for a demo value with no public source."}}}}};function validate25(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate25.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(errors === 0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((((((((((data.id === undefined) && (missing0 = "id")) || ((data.label === undefined) && (missing0 = "label"))) || ((data.outcome === undefined) && (missing0 = "outcome"))) || ((data.reason === undefined) && (missing0 = "reason"))) || ((data.measured_ft === undefined) && (missing0 = "measured_ft"))) || ((data.plus_minus_ft === undefined) && (missing0 = "plus_minus_ft"))) || ((data.threshold_ft === undefined) && (missing0 = "threshold_ft"))) || ((data.comparison === undefined) && (missing0 = "comparison"))) || ((data.rule === undefined) && (missing0 = "rule"))){validate25.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {if(data.id !== undefined){const _errs1 = errors;if(typeof data.id !== "string"){validate25.errors = [{instancePath:instancePath+"/id",schemaPath:"#/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs1 === errors;}else {var valid0 = true;}if(valid0){if(data.label !== undefined){const _errs3 = errors;if(typeof data.label !== "string"){validate25.errors = [{instancePath:instancePath+"/label",schemaPath:"#/properties/label/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs3 === errors;}else {var valid0 = true;}if(valid0){if(data.outcome !== undefined){let data2 = data.outcome;const _errs5 = errors;if(!(((data2 === "pass") || (data2 === "fail")) || (data2 === "unsure"))){validate25.errors = [{instancePath:instancePath+"/outcome",schemaPath:"#/$defs/outcome/enum",keyword:"enum",params:{allowedValues: schema33.enum},message:"must be equal to one of the allowed values"}];return false;}var valid0 = _errs5 === errors;}else {var valid0 = true;}if(valid0){if(data.unsure_cause !== undefined){let data3 = data.unsure_cause;const _errs7 = errors;if(!((((data3 === "margin") || (data3 === "unobserved")) || (data3 === "unknown_attribute")) || (data3 === "rule_requires_review"))){validate25.errors = [{instancePath:instancePath+"/unsure_cause",schemaPath:"#/properties/unsure_cause/enum",keyword:"enum",params:{allowedValues: schema43.properties.unsure_cause.enum},message:"must be equal to one of the allowed values"}];return false;}var valid0 = _errs7 === errors;}else {var valid0 = true;}if(valid0){if(data.reason !== undefined){const _errs8 = errors;if(typeof data.reason !== "string"){validate25.errors = [{instancePath:instancePath+"/reason",schemaPath:"#/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs8 === errors;}else {var valid0 = true;}if(valid0){if(data.measured_ft !== undefined){let data5 = data.measured_ft;const _errs10 = errors;if((!((typeof data5 == "number") && (isFinite(data5)))) && (data5 !== null)){validate25.errors = [{instancePath:instancePath+"/measured_ft",schemaPath:"#/properties/measured_ft/type",keyword:"type",params:{type: schema43.properties.measured_ft.type},message:"must be number,null"}];return false;}var valid0 = _errs10 === errors;}else {var valid0 = true;}if(valid0){if(data.plus_minus_ft !== undefined){let data6 = data.plus_minus_ft;const _errs12 = errors;if((!((typeof data6 == "number") && (isFinite(data6)))) && (data6 !== null)){validate25.errors = [{instancePath:instancePath+"/plus_minus_ft",schemaPath:"#/properties/plus_minus_ft/type",keyword:"type",params:{type: schema43.properties.plus_minus_ft.type},message:"must be number,null"}];return false;}var valid0 = _errs12 === errors;}else {var valid0 = true;}if(valid0){if(data.threshold_ft !== undefined){let data7 = data.threshold_ft;const _errs14 = errors;if((!((typeof data7 == "number") && (isFinite(data7)))) && (data7 !== null)){validate25.errors = [{instancePath:instancePath+"/threshold_ft",schemaPath:"#/properties/threshold_ft/type",keyword:"type",params:{type: schema43.properties.threshold_ft.type},message:"must be number,null"}];return false;}var valid0 = _errs14 === errors;}else {var valid0 = true;}if(valid0){if(data.review_threshold_ft !== undefined){let data8 = data.review_threshold_ft;const _errs16 = errors;if(!((typeof data8 == "number") && (isFinite(data8)))){validate25.errors = [{instancePath:instancePath+"/review_threshold_ft",schemaPath:"#/properties/review_threshold_ft/type",keyword:"type",params:{type: "number"},message:"must be number"}];return false;}var valid0 = _errs16 === errors;}else {var valid0 = true;}if(valid0){if(data.comparison !== undefined){let data9 = data.comparison;const _errs18 = errors;if(!(((data9 === "at_least") || (data9 === "at_most")) || (data9 === null))){validate25.errors = [{instancePath:instancePath+"/comparison",schemaPath:"#/properties/comparison/enum",keyword:"enum",params:{allowedValues: schema43.properties.comparison.enum},message:"must be equal to one of the allowed values"}];return false;}var valid0 = _errs18 === errors;}else {var valid0 = true;}if(valid0){if(data.subject !== undefined){let data10 = data.subject;const _errs19 = errors;if((typeof data10 !== "string") && (data10 !== null)){validate25.errors = [{instancePath:instancePath+"/subject",schemaPath:"#/properties/subject/type",keyword:"type",params:{type: schema43.properties.subject.type},message:"must be string,null"}];return false;}var valid0 = _errs19 === errors;}else {var valid0 = true;}if(valid0){if(data.rule !== undefined){let data11 = data.rule;const _errs21 = errors;if(errors === _errs21){if(data11 && typeof data11 == "object" && !Array.isArray(data11)){let missing1;if((((data11.key === undefined) && (missing1 = "key")) || ((data11.source === undefined) && (missing1 = "source"))) || ((data11.placeholder === undefined) && (missing1 = "placeholder"))){validate25.errors = [{instancePath:instancePath+"/rule",schemaPath:"#/properties/rule/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}];return false;}else {if(data11.key !== undefined){const _errs23 = errors;if(typeof data11.key !== "string"){validate25.errors = [{instancePath:instancePath+"/rule/key",schemaPath:"#/properties/rule/properties/key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid2 = _errs23 === errors;}else {var valid2 = true;}if(valid2){if(data11.source !== undefined){const _errs25 = errors;if(typeof data11.source !== "string"){validate25.errors = [{instancePath:instancePath+"/rule/source",schemaPath:"#/properties/rule/properties/source/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid2 = _errs25 === errors;}else {var valid2 = true;}if(valid2){if(data11.placeholder !== undefined){const _errs27 = errors;if(typeof data11.placeholder !== "boolean"){validate25.errors = [{instancePath:instancePath+"/rule/placeholder",schemaPath:"#/properties/rule/properties/placeholder/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid2 = _errs27 === errors;}else {var valid2 = true;}}}}}else {validate25.errors = [{instancePath:instancePath+"/rule",schemaPath:"#/properties/rule/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid0 = _errs21 === errors;}else {var valid0 = true;}}}}}}}}}}}}}}else {validate25.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate25.errors = vErrors;return errors === 0;}validate25.evaluated = {"props":{"id":true,"label":true,"outcome":true,"unsure_cause":true,"reason":true,"measured_ft":true,"plus_minus_ft":true,"threshold_ft":true,"review_threshold_ft":true,"comparison":true,"subject":true,"rule":true},"dynamicProps":false,"dynamicItems":false};const schema46 = {"type":"object","required":["kind","s_ft","point"],"properties":{"kind":{"enum":["limit","unexplored"]},"s_ft":{"type":"number","description":"Where the wall chain ends, in s."},"point":{"$ref":"#/$defs/point2"},"beyond_reach":{"type":"boolean","description":"True when this end is so far along the wall that no spot past it could pass the route-length check, so an unexplored end there does not block a reject."}}};function validate28(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate28.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(errors === 0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((((data.kind === undefined) && (missing0 = "kind")) || ((data.s_ft === undefined) && (missing0 = "s_ft"))) || ((data.point === undefined) && (missing0 = "point"))){validate28.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {if(data.kind !== undefined){let data0 = data.kind;const _errs1 = errors;if(!((data0 === "limit") || (data0 === "unexplored"))){validate28.errors = [{instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: schema46.properties.kind.enum},message:"must be equal to one of the allowed values"}];return false;}var valid0 = _errs1 === errors;}else {var valid0 = true;}if(valid0){if(data.s_ft !== undefined){let data1 = data.s_ft;const _errs2 = errors;if(!((typeof data1 == "number") && (isFinite(data1)))){validate28.errors = [{instancePath:instancePath+"/s_ft",schemaPath:"#/properties/s_ft/type",keyword:"type",params:{type: "number"},message:"must be number"}];return false;}var valid0 = _errs2 === errors;}else {var valid0 = true;}if(valid0){if(data.point !== undefined){let data2 = data.point;const _errs4 = errors;const _errs5 = errors;if(errors === _errs5){if(Array.isArray(data2)){if(data2.length > 2){validate28.errors = [{instancePath:instancePath+"/point",schemaPath:"#/$defs/point2/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data2.length < 2){validate28.errors = [{instancePath:instancePath+"/point",schemaPath:"#/$defs/point2/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid2 = true;const len0 = data2.length;for(let i0=0; i0 2){validate20.errors = [{instancePath:instancePath+"/missing_evidence/" + i4+"/span_ft",schemaPath:"#/$defs/span/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data26.length < 2){validate20.errors = [{instancePath:instancePath+"/missing_evidence/" + i4+"/span_ft",schemaPath:"#/$defs/span/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid13 = true;const len5 = data26.length;for(let i5=0; i5=", limit: 0},message:"must be >= 0"}];return false;}}else {validate20.errors = [{instancePath:instancePath+"/missing_evidence/" + i4+"/out_ft",schemaPath:"#/properties/missing_evidence/items/properties/out_ft/type",keyword:"type",params:{type: "number"},message:"must be number"}];return false;}}var valid11 = _errs61 === errors;}else {var valid11 = true;}if(valid11){if(data23.side !== undefined){let data29 = data23.side;const _errs63 = errors;if(!((data29 === "left") || (data29 === "right"))){validate20.errors = [{instancePath:instancePath+"/missing_evidence/" + i4+"/side",schemaPath:"#/properties/missing_evidence/items/properties/side/enum",keyword:"enum",params:{allowedValues: schema31.properties.missing_evidence.items.properties.side.enum},message:"must be equal to one of the allowed values"}];return false;}var valid11 = _errs63 === errors;}else {var valid11 = true;}if(valid11){if(data23.checks !== undefined){let data30 = data23.checks;const _errs64 = errors;if(errors === _errs64){if(Array.isArray(data30)){var valid14 = true;const len6 = data30.length;for(let i6=0; i6=", limit: 0},message:"must be >= 0"}];return false;}}}var valid17 = _errs80 === errors;}else {var valid17 = true;}if(valid17){if(data37.start_ft !== undefined){let data40 = data37.start_ft;const _errs82 = errors;const _errs83 = errors;if(errors === _errs83){if(Array.isArray(data40)){if(data40.length > 2){validate20.errors = [{instancePath:instancePath+"/sweep/" + i7+"/start_ft",schemaPath:"#/$defs/span/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"}];return false;}else {if(data40.length < 2){validate20.errors = [{instancePath:instancePath+"/sweep/" + i7+"/start_ft",schemaPath:"#/$defs/span/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"}];return false;}else {var valid19 = true;const len8 = data40.length;for(let i8=0; i8=", limit: 0},message:"must be >= 0"}];return false;}}}var valid23 = _errs99 === errors;}else {var valid23 = true;}if(valid23){if(data47.pass !== undefined){let data49 = data47.pass;const _errs101 = errors;if(!(((typeof data49 == "number") && (!(data49 % 1) && !isNaN(data49))) && (isFinite(data49)))){validate20.errors = [{instancePath:instancePath+"/stats/pass",schemaPath:"#/properties/stats/properties/pass/type",keyword:"type",params:{type: "integer"},message:"must be integer"}];return false;}if(errors === _errs101){if((typeof data49 == "number") && (isFinite(data49))){if(data49 < 0 || isNaN(data49)){validate20.errors = [{instancePath:instancePath+"/stats/pass",schemaPath:"#/properties/stats/properties/pass/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"}];return false;}}}var valid23 = _errs101 === errors;}else {var valid23 = true;}if(valid23){if(data47.unsure !== undefined){let data50 = data47.unsure;const _errs103 = errors;if(!(((typeof data50 == "number") && (!(data50 % 1) && !isNaN(data50))) && (isFinite(data50)))){validate20.errors = [{instancePath:instancePath+"/stats/unsure",schemaPath:"#/properties/stats/properties/unsure/type",keyword:"type",params:{type: "integer"},message:"must be integer"}];return false;}if(errors === _errs103){if((typeof data50 == "number") && (isFinite(data50))){if(data50 < 0 || isNaN(data50)){validate20.errors = [{instancePath:instancePath+"/stats/unsure",schemaPath:"#/properties/stats/properties/unsure/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"}];return false;}}}var valid23 = _errs103 === errors;}else {var valid23 = true;}if(valid23){if(data47.fail !== undefined){let data51 = data47.fail;const _errs105 = errors;if(!(((typeof data51 == "number") && (!(data51 % 1) && !isNaN(data51))) && (isFinite(data51)))){validate20.errors = [{instancePath:instancePath+"/stats/fail",schemaPath:"#/properties/stats/properties/fail/type",keyword:"type",params:{type: "integer"},message:"must be integer"}];return false;}if(errors === _errs105){if((typeof data51 == "number") && (isFinite(data51))){if(data51 < 0 || isNaN(data51)){validate20.errors = [{instancePath:instancePath+"/stats/fail",schemaPath:"#/properties/stats/properties/fail/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"}];return false;}}}var valid23 = _errs105 === errors;}else {var valid23 = true;}if(valid23){if(data47.elapsed_ms !== undefined){let data52 = data47.elapsed_ms;const _errs107 = errors;if(errors === _errs107){if((typeof data52 == "number") && (isFinite(data52))){if(data52 < 0 || isNaN(data52)){validate20.errors = [{instancePath:instancePath+"/stats/elapsed_ms",schemaPath:"#/properties/stats/properties/elapsed_ms/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"}];return false;}}else {validate20.errors = [{instancePath:instancePath+"/stats/elapsed_ms",schemaPath:"#/properties/stats/properties/elapsed_ms/type",keyword:"type",params:{type: "number"},message:"must be number"}];return false;}}var valid23 = _errs107 === errors;}else {var valid23 = true;}if(valid23){if(data47.input_sha256 !== undefined){let data53 = data47.input_sha256;const _errs109 = errors;if(errors === _errs109){if(typeof data53 === "string"){if(!pattern5.test(data53)){validate20.errors = [{instancePath:instancePath+"/stats/input_sha256",schemaPath:"#/properties/stats/properties/input_sha256/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"},message:"must match pattern \""+"^[0-9a-f]{64}$"+"\""}];return false;}}else {validate20.errors = [{instancePath:instancePath+"/stats/input_sha256",schemaPath:"#/properties/stats/properties/input_sha256/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}}var valid23 = _errs109 === errors;}else {var valid23 = true;}}}}}}}}else {validate20.errors = [{instancePath:instancePath+"/stats",schemaPath:"#/properties/stats/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid0 = _errs97 === errors;}else {var valid0 = true;}}}}}}}}}}}}}}}else {validate20.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate20.errors = vErrors;return errors === 0;}validate20.evaluated = {"props":{"schema_version":true,"decision":true,"summary":true,"reasons":true,"policy":true,"spot":true,"route":true,"checks":true,"nearest_considered":true,"missing_evidence":true,"ends":true,"sweep":true,"stats":true},"dynamicProps":false,"dynamicItems":false}; diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts new file mode 100644 index 000000000..30dab410b --- /dev/null +++ b/web/src/lib/api.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import fitsResult from "../samples/fits/result.json?raw"; +import { + type Failure, + isMixedContent, + PlacementError, + parseResult, + requestPlacement, + requestPlan, +} from "./api.ts"; +import { sceneFromSample } from "./scene-input.ts"; + +const input = sceneFromSample("fits", "Clear side wall", "{}"); + +function stubFetch(answer: (url: string) => Response | Promise) { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => Promise.resolve(answer(url))), + ); +} + +async function failureOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + if (error instanceof PlacementError) { + return error.failure; + } + throw error; + } + throw new Error("expected a PlacementError"); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("requestPlacement", () => { + it("sends the scene once and returns the result", async () => { + stubFetch(() => new Response(fitsResult)); + const result = await requestPlacement("/api", input, new AbortController().signal); + expect(result.decision).toBe("pass"); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("sends a refused scene once", async () => { + const envelope = { error: { code: "invalid_scene", message: "bad", path: "/walls/0" } }; + stubFetch(() => Response.json(envelope, { status: 422 })); + await failureOf(requestPlacement("/api", input, new AbortController().signal)); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("reports a refusal with the field at fault", async () => { + const envelope = { error: { code: "invalid_scene", message: "bad", path: "/walls/0" } }; + stubFetch(() => Response.json(envelope, { status: 422 })); + expect(await failureOf(requestPlacement("/api", input, new AbortController().signal))).toEqual({ + kind: "refused", + code: "invalid_scene", + message: "bad", + path: "/walls/0", + }); + }); + + it("calls a network error unreachable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new TypeError("Failed to fetch"))), + ); + const failure = await failureOf( + requestPlacement("http://localhost:8000", input, new AbortController().signal), + ); + expect(failure).toMatchObject({ kind: "unreachable", server: "http://localhost:8000" }); + }); + + it("explains an upload refused for size before it reached the server", async () => { + stubFetch(() => new Response("Request Entity Too Large", { status: 413 })); + const failure = await failureOf(requestPlacement("/api", input, new AbortController().signal)); + expect(failure.kind).toBe("too_large"); + }); + + it("tells an HTTP error without the server's format apart from no answer", async () => { + stubFetch(() => new Response("404", { status: 404 })); + const failure = await failureOf(requestPlacement("/api", input, new AbortController().signal)); + expect(failure).toEqual({ kind: "http_error", server: "/api", status: 404 }); + }); + + it("reports a gateway timeout as an HTTP error, not an unreachable server", async () => { + stubFetch(() => new Response("An error occurred", { status: 504 })); + const failure = await failureOf(requestPlacement("/api", input, new AbortController().signal)); + expect(failure).toEqual({ kind: "http_error", server: "/api", status: 504 }); + }); +}); + +describe("requestPlan", () => { + it("returns the site plan", async () => { + stubFetch(() => new Response("")); + expect(await requestPlan("/api", input, new AbortController().signal)).toBe(""); + }); + + it("returns null when the plan fails, so the result still shows", async () => { + stubFetch(() => new Response("", { status: 500 })); + expect(await requestPlan("/api", input, new AbortController().signal)).toBeNull(); + }); + + it("returns null when the plan can't be fetched", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new TypeError("Failed to fetch"))), + ); + expect(await requestPlan("/api", input, new AbortController().signal)).toBeNull(); + }); +}); + +describe("parseResult", () => { + it("accepts a recorded answer", () => { + expect(parseResult(JSON.parse(fitsResult)).schema_version).toBe("1.0"); + }); + + it("names where an answer breaks the contract", () => { + const broken = { ...JSON.parse(fitsResult), decision: "maybe" }; + expect(() => parseResult(broken)).toThrow(/at \/decision/); + }); + + // A server can add fields within schema 1.x; the deployed page must keep reading its answers. + it("accepts fields added within the same major schema version", () => { + const answer = JSON.parse(fitsResult); + const newer = { + ...answer, + schema_version: "1.3", + added_later: { any: "shape" }, + policy: { ...answer.policy, added_later: true }, + checks: answer.checks.map((c: object) => ({ ...c, added_later: 1 })), + }; + expect(parseResult(newer).schema_version).toBe("1.3"); + }); + + it("refuses an answer from another major schema version", () => { + const answer = { ...JSON.parse(fitsResult), schema_version: "2.0" }; + expect(() => parseResult(answer)).toThrow(/at \/schema_version/); + }); +}); + +describe("isMixedContent", () => { + it("flags an http server from an https page", () => { + expect(isMixedContent("http://192.168.1.20:8000", "https:")).toBe(true); + }); + + it.each([ + ["https://house-scanning-server.vercel.app", "https:"], + ["/api", "https:"], + ["http://localhost:8000", "http:"], + ])("allows %s from an %s page", (server, protocol) => { + expect(isMixedContent(server, protocol)).toBe(false); + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 000000000..99174c517 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,185 @@ +import type { BatteryPlacementResult } from "../contract/result.ts"; +import validate from "../contract/validate-result.js"; +import type { SceneInput } from "./scene-input.ts"; + +export type Result = BatteryPlacementResult; + +/** Why a placement could not be shown. Each kind is a different message and a different fix. */ +export type Failure = + /** No HTTP answer at all: the server is down, the address is wrong, or the browser blocked it. */ + | { kind: "unreachable"; server: string; detail: string } + /** The placement server's own refusal, in its error format. */ + | { kind: "refused"; code: string; message: string; path: string | null } + /** An HTTP error without the server's format: its host failed, or this is another site. */ + | { kind: "http_error"; server: string; status: number } + /** Larger than the hosted server accepts, found by the page or by a readable 413. */ + | { kind: "too_large"; message: string } + | { kind: "unexpected"; message: string }; + +export class PlacementError extends Error { + readonly failure: Failure; + + constructor(failure: Failure) { + super(failureText(failure)); + this.failure = failure; + } +} + +function failureText(failure: Failure): string { + switch (failure.kind) { + case "unreachable": + return failure.detail; + case "http_error": + return `HTTP ${failure.status} from ${failure.server}`; + default: + return failure.message; + } +} + +/** Whether the browser will block calls to `server` from a page loaded over `pageProtocol`. */ +export function isMixedContent(server: string, pageProtocol: string): boolean { + return pageProtocol === "https:" && server.trim().toLowerCase().startsWith("http:"); +} + +/** Checks a parsed body against the result schema, so a contract break is a specific error. */ +export function parseResult(body: unknown): Result { + if (validate(body)) { + return body; + } + const error = validate.errors?.[0]; + const where = error?.instancePath || "the top level"; + throw new PlacementError({ + kind: "unexpected", + message: `The answer breaks the result contract at ${where}: ${error?.message ?? "unknown error"}.`, + }); +} + +function joinUrl(server: string, path: string): string { + return `${server.replace(/\/+$/, "")}${path}`; +} + +async function post(server: string, path: string, input: SceneInput, signal: AbortSignal) { + try { + return await fetch(joinUrl(server, path), { + method: "POST", + headers: { "Content-Type": input.contentType }, + body: input.body, + signal, + }); + } catch (error) { + if (signal.aborted) { + throw error; + } + throw new PlacementError({ + kind: "unreachable", + server, + detail: error instanceof Error ? error.message : String(error), + }); + } +} + +interface ErrorEnvelope { + error: { code: string; message: string; path: string | null }; +} + +function isErrorEnvelope(body: unknown): body is ErrorEnvelope { + if (typeof body !== "object" || body === null || !("error" in body)) { + return false; + } + const error = body.error; + return ( + typeof error === "object" && + error !== null && + "code" in error && + typeof error.code === "string" && + "message" in error && + typeof error.message === "string" + ); +} + +async function readJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return undefined; + } +} + +/** A non-2xx answer: the server's own refusal, or something that is not the placement server. */ +async function failureFrom(response: Response, server: string): Promise { + const body = await readJson(response); + if (isErrorEnvelope(body)) { + return new PlacementError({ + kind: "refused", + code: body.error.code, + message: body.error.message, + path: body.error.path, + }); + } + if (response.status === 413) { + // A host in front of the server refused it first (Vercel caps request bodies at 4.5 MB). + return new PlacementError({ + kind: "too_large", + message: + "The upload is larger than the server accepts. Send scene.json on its own, or a zip without the photos: the placement doesn't use them.", + }); + } + return new PlacementError({ kind: "http_error", server, status: response.status }); +} + +/** Sends the scene once and returns the server's answer. */ +export async function requestPlacement( + server: string, + input: SceneInput, + signal: AbortSignal, +): Promise { + const answer = await post(server, "/v1/placements", input, signal); + if (!answer.ok) { + throw await failureFrom(answer, server); + } + const body = await readJson(answer); + if (body === undefined) { + throw new PlacementError({ kind: "unexpected", message: "The answer is not JSON." }); + } + return parseResult(body); +} + +/** The site plan for a scene the server has already answered, or null when it can't be drawn. + * The server has no endpoint returning both, so this sends the scene a second time; asking only + * after an answer keeps a refused scene to one upload. */ +export async function requestPlan( + server: string, + input: SceneInput, + signal: AbortSignal, +): Promise { + try { + const answer = await post(server, "/v1/placements/site-plan.svg", input, signal); + return answer.ok ? await answer.text() : null; + } catch (error) { + if (signal.aborted) { + throw error; + } + return null; + } +} + +export type Health = { ok: true; policy: string } | { ok: false }; + +/** Which rules the server has loaded, or that it did not answer. Never throws. */ +export async function checkHealth(server: string, signal: AbortSignal): Promise { + try { + const response = await fetch(joinUrl(server, "/health"), { signal }); + const body = await readJson(response); + if (!response.ok || typeof body !== "object" || body === null || !("policy" in body)) { + return { ok: false }; + } + const policy = body.policy; + const id = + typeof policy === "object" && policy !== null && "id" in policy + ? String(policy.id) + : "unnamed"; + return { ok: true, policy: id }; + } catch { + return { ok: false }; + } +} diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts new file mode 100644 index 000000000..012fd41bb --- /dev/null +++ b/web/src/lib/format.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import type { Result } from "./api.ts"; +import { feet, fromMeter, measured, shownDecision } from "./format.ts"; + +describe("feet", () => { + it.each([ + [2.75, "2 ft 9 in"], + [3, "3 ft"], + [7 / 12, "7 in"], + [-9.675, "9 ft 8 in"], + [0.99, "1 ft"], + ])("%d ft reads %s", (value, text) => { + expect(feet(value)).toBe(text); + }); +}); + +describe("measured", () => { + it("adds the error when there is one", () => { + expect(measured(9.675, 7 / 12)).toBe("9 ft 8 in ± 7 in"); + }); + + it("leaves it out when the value is exact", () => { + expect(measured(6, 0)).toBe("6 ft"); + expect(measured(6, null)).toBe("6 ft"); + }); +}); + +describe("fromMeter", () => { + it("says which side", () => { + expect(fromMeter(-10)).toBe("10 ft left of the meter"); + expect(fromMeter(2.75)).toBe("2 ft 9 in right of the meter"); + expect(fromMeter(0.01)).toBe("at the meter"); + }); +}); + +describe("shownDecision", () => { + const answer = (decision: Result["decision"], autoApprove: boolean) => + ({ decision, policy: { auto_approve: autoApprove } }) as Pick; + + it("shows a pass under rules approved for automatic decisions", () => { + expect(shownDecision(answer("pass", true))).toBe("pass"); + }); + + // The rules decide whether a pass stands; under unapproved rules a person decides. + it.each(["pass", "reject"] as const)("sends a %s under unapproved rules to review", (d) => { + expect(shownDecision(answer(d, false))).toBe("manual_review"); + }); +}); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 000000000..200696ba3 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,35 @@ +import type { Result } from "./api.ts"; +import { feetToMeters, formatFeetInches } from "./units.ts"; + +// The placement server reports feet. These read them the way a person says them. + +/** "2 ft 9 in", "7 in", "3 ft". Absolute value: callers say which side. */ +export function feet(value: number): string { + const text = formatFeetInches(feetToMeters(Math.abs(value))); + if (text.startsWith("0 ft ")) { + return text.slice("0 ft ".length); + } + return text.endsWith(" 0 in") ? text.slice(0, -" 0 in".length) : text; +} + +/** A measurement with its error: "9 ft 8 in ± 7 in", or just the length when the error is 0. */ +export function measured(value: number, plusMinus: number | null | undefined): string { + if (!plusMinus) { + return feet(value); + } + return `${feet(value)} ± ${feet(plusMinus)}`; +} + +/** Where a point along the wall is: s is feet from the meter, negative to the left. */ +export function fromMeter(s: number): string { + if (Math.abs(s) < 1 / 24) { + return "at the meter"; + } + return `${feet(s)} ${s < 0 ? "left" : "right"} of the meter`; +} + +/** The decision to show. Under rules not approved for automatic decisions a person decides, even + * if a server says pass or reject. */ +export function shownDecision(result: Pick): Result["decision"] { + return result.policy.auto_approve ? result.decision : "manual_review"; +} diff --git a/web/src/lib/scene-input.test.ts b/web/src/lib/scene-input.test.ts new file mode 100644 index 000000000..3037eee9c --- /dev/null +++ b/web/src/lib/scene-input.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { type Failure, PlacementError } from "./api.ts"; +import { HOST_LIMIT_BYTES, sceneFromFile } from "./scene-input.ts"; + +async function refusal(file: File): Promise { + try { + await sceneFromFile(file); + } catch (error) { + if (error instanceof PlacementError) { + return error.failure; + } + throw error; + } + throw new Error("expected a PlacementError"); +} + +const ZIP_HEAD = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + +describe("sceneFromFile", () => { + it("sends a zip bundle as a zip, whatever its name", async () => { + const file = new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0])], "capture.bin"); + expect((await sceneFromFile(file)).contentType).toBe("application/zip"); + }); + + it("sends scene.json as JSON", async () => { + const file = new File(["{}"], "scene.json"); + expect((await sceneFromFile(file)).contentType).toBe("application/json"); + }); + + it("refuses anything else by name", async () => { + await expect(sceneFromFile(new File(["x"], "photo.jpg"))).rejects.toThrow( + "photo.jpg is neither a scene.json nor a zip bundle.", + ); + }); + + // The hosted server's host refuses larger bodies without CORS headers, so the browser would + // only see a network error; the page has to refuse them before sending. + it("refuses a zip over the host's limit and says scene.json alone is enough", async () => { + const file = new File([ZIP_HEAD, new Uint8Array(HOST_LIMIT_BYTES)], "capture.zip"); + const failure = await refusal(file); + expect(failure.kind).toBe("too_large"); + expect(failure.kind === "too_large" && failure.message).toMatch(/scene\.json on its own/); + }); + + it("refuses a scene.json over the host's limit", async () => { + const file = new File([new Uint8Array(HOST_LIMIT_BYTES + 1)], "scene.json"); + expect((await refusal(file)).kind).toBe("too_large"); + }); + + it("sends a file right at the limit", async () => { + const file = new File([new Uint8Array(HOST_LIMIT_BYTES)], "scene.json"); + expect((await sceneFromFile(file)).body.size).toBe(HOST_LIMIT_BYTES); + }); +}); diff --git a/web/src/lib/scene-input.ts b/web/src/lib/scene-input.ts new file mode 100644 index 000000000..aceae7039 --- /dev/null +++ b/web/src/lib/scene-input.ts @@ -0,0 +1,50 @@ +/** A scene ready to send: the bytes, how to label them, and where it came from. */ +export interface SceneInput { + name: string; + body: Blob; + contentType: "application/json" | "application/zip"; + /** Set for a bundled sample, which also has a saved answer. */ + sampleId: string | null; +} + +import { PlacementError } from "./api.ts"; + +/** Vercel, which hosts the placement server, refuses request bodies over 4.5 MB, and its refusal + * has no CORS headers, so the browser reports only a network error. Refusing here instead says + * what happened. */ +export const HOST_LIMIT_BYTES = 4_500_000; + +const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" + +/** A picked or dropped file: a scene bundle (zip) or bare scene.json. */ +export async function sceneFromFile(file: File): Promise { + const head = new Uint8Array(await file.slice(0, ZIP_MAGIC.length).arrayBuffer()); + const isZip = ZIP_MAGIC.every((byte, i) => head[i] === byte); + if (!isZip && !file.name.toLowerCase().endsWith(".json")) { + throw new Error(`${file.name} is neither a scene.json nor a zip bundle.`); + } + if (file.size > HOST_LIMIT_BYTES) { + const size = `${(file.size / 1_000_000).toFixed(1)} MB`; + throw new PlacementError({ + kind: "too_large", + message: isZip + ? `${file.name} is ${size}, and the server accepts at most 4.5 MB. The placement reads only scene.json, so send scene.json on its own, without the photos.` + : `${file.name} is ${size}, and the server accepts at most 4.5 MB.`, + }); + } + return { + name: file.name, + body: file, + contentType: isZip ? "application/zip" : "application/json", + sampleId: null, + }; +} + +export function sceneFromSample(id: string, name: string, sceneJson: string): SceneInput { + return { + name, + body: new Blob([sceneJson], { type: "application/json" }), + contentType: "application/json", + sampleId: id, + }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 9b362a591..913782a01 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import "@fontsource/barlow-semi-condensed/600.css"; +import "@fontsource/barlow-semi-condensed/700.css"; +import "@fontsource-variable/public-sans"; +import "@fontsource/ibm-plex-mono/400.css"; import { App } from "./App.tsx"; +import "./styles.css"; const rootElement = document.getElementById("root"); if (rootElement === null) { diff --git a/web/src/samples/corner-not-walked/plan.svg b/web/src/samples/corner-not-walked/plan.svg new file mode 100644 index 000000000..595dd8f22 --- /dev/null +++ b/web/src/samples/corner-not-walked/plan.svg @@ -0,0 +1,81 @@ + +Battery site plan: manual_review +More views are needed around the best spot, 2 ft 9 in right of the meter: 2 checks depend on areas the scan did not see. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's. + + + + + + + + + + + + + + + + + + +Cable 1 ft 5 in + + + +Meter + + + + +5 ft + +Battery + +Cable + +Meter working space + +Gas + +Not seen +Needs review + \ No newline at end of file diff --git a/web/src/samples/corner-not-walked/result.json b/web/src/samples/corner-not-walked/result.json new file mode 100644 index 000000000..b488f43fd --- /dev/null +++ b/web/src/samples/corner-not-walked/result.json @@ -0,0 +1,490 @@ +{ + "schema_version": "1.0", + "decision": "manual_review", + "summary": "More views are needed around the best spot, 2 ft 9 in right of the meter: 2 checks depend on areas the scan did not see. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [ + { + "code": "unsure_checks", + "checks": [ + "drive_clearance", + "pool_clearance" + ], + "message": "The best spot has checks nobody can settle from this scan: distance from the driveway; distance from a pool." + }, + { + "code": "unobserved_area", + "checks": [ + "drive_clearance", + "pool_clearance" + ], + "message": "Part of the area the checks need was not seen." + }, + { + "code": "unexplored_end", + "message": "The wall continues past an end of the scan within cable reach." + } + ], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "68693248e605d4252cd14dd74afd2e4e042408b9632612a0a4ac6dc5a708da2b", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": { + "outcome": "unsure", + "wall_id": "side", + "segment": 0, + "span_ft": [ + 1.458333, + 4.041667 + ], + "width_ft": 2.583333, + "depth_ft": 1.833333, + "height_ft": 3.291667, + "footprint": [ + [ + 1.458333, + 0.0 + ], + [ + 4.041667, + 0.0 + ], + [ + 4.041667, + 1.833333 + ], + [ + 1.458333, + 1.833333 + ] + ], + "center": [ + 2.75, + 0.916667 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + -0.0, + 1.0 + ], + "meter_offset_ft": [ + 2.75, + 0.916667 + ], + "route_length_ft": 1.458333 + }, + "route": { + "outcome": "pass", + "length_ft": 1.458333, + "plus_minus_ft": 0.2, + "height_ft": 1.0, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + 1.458333, + 0.0 + ] + ], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "wall_backing", + "label": "Flush against one straight wall", + "outcome": "pass", + "reason": "The whole footprint backs onto one straight, observed wall segment.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "battery.width_ft", + "source": "Base Core spec: 30.68 in wide, rounded up to 31 in (docs/01 C2)", + "placeholder": false + } + }, + { + "id": "ground_surface", + "label": "Ground under the battery", + "outcome": "pass", + "reason": "The whole footprint stands on an allowed surface.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "ground.allowed", + "source": "docs/01 ground types; which surfaces are allowed is a demo choice", + "placeholder": true + } + }, + { + "id": "meter_working_space", + "label": "Clear of the meter's working space", + "outcome": "pass", + "reason": "The battery is 0 ft 3 in clear of the meter's 2 ft 6 in wide by 3 ft 0 in deep working space.", + "measured_ft": 0.208333, + "plus_minus_ft": 0.2, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": "meter", + "rule": { + "key": "meter_working_space.width_ft", + "source": "NEC 110.26(A)(2): 30 in wide working space", + "placeholder": false + } + }, + { + "id": "gas_clearance", + "label": "Distance from gas equipment", + "outcome": "pass", + "reason": "No gas meter or pipe within 3 ft 0 in of the battery, and that whole area was seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.gas_ft", + "source": "Base help page: 3 ft from the gas meter; Austin Energy \u00a71.9; Texas Gas Service", + "placeholder": false + } + }, + { + "id": "ac_clearance", + "label": "Distance from AC units", + "outcome": "pass", + "reason": "No AC unit within 3 ft 0 in of the battery, and that whole area was seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.ac_ft", + "source": "Base help page: 3 ft from AC units, fences and other batteries", + "placeholder": false + } + }, + { + "id": "drive_clearance", + "label": "Distance from the driveway", + "outcome": "unsure", + "reason": "Not everything within 5 ft 0 in of the battery was seen, so a drivable surface could hide there.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 5.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.drive_ft", + "source": "No public value (docs/01 rules table: TBD)", + "placeholder": true + }, + "unsure_cause": "unobserved" + }, + { + "id": "pool_clearance", + "label": "Distance from a pool", + "outcome": "unsure", + "reason": "Not everything within 10 ft 0 in of the battery was seen, so a pool could hide there.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 10.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.pool_ft", + "source": "No public value (docs/01 rules table: TBD); the demo ring drawn in docs/eli5.html", + "placeholder": true + }, + "unsure_cause": "unobserved" + }, + { + "id": "opening_clearance", + "label": "Distance from doors and windows", + "outcome": "pass", + "reason": "No door or window within 3 ft 0 in of the battery, and that whole area was seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.opening_ft", + "source": "IRC 2021 R328.4: 3 ft from doors and windows directly entering the dwelling. Applying it to garage doors (openings.types) is a demo choice the code does not require", + "placeholder": false + } + }, + { + "id": "wall_equipment_above", + "label": "No box or vent above the battery", + "outcome": "pass", + "reason": "Nothing is mounted on the wall above the battery.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.wall_equipment_ft", + "source": "No public value; 0 means only overlap fails", + "placeholder": true + } + }, + { + "id": "facing_gap", + "label": "Open space in front", + "outcome": "pass", + "reason": "The gap in front of the battery is 14 ft 2 in (\u00b1 0 ft 2 in), clear of the 3 ft 0 in rule.", + "measured_ft": 14.166667, + "plus_minus_ft": 0.2, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "facing[0]", + "rule": { + "key": "facing.min_ft", + "source": "Base help page: 3 ft from fences", + "placeholder": false + } + }, + { + "id": "headroom", + "label": "Headroom above", + "outcome": "pass", + "reason": "Nothing limits the headroom over the battery's stretch of wall.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 6.5, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "headroom.min_ft", + "source": "NEC 110.26(A)(3) working-space headroom, applied to the battery as a demo choice", + "placeholder": true + } + }, + { + "id": "route_path", + "label": "Cable route along the wall", + "outcome": "pass", + "reason": "The cable runs along continuous, observed wall with nothing blocking it.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "route.crossing", + "source": "docs/02 Lane C: the cable can't cross a door, a garage or a stretch with no wall", + "placeholder": false + } + }, + { + "id": "route_length", + "label": "Cable run length", + "outcome": "pass", + "reason": "The cable run is 1 ft 6 in (\u00b1 0 ft 2 in), within the 15 ft 0 in confident reach.", + "measured_ft": 1.458333, + "plus_minus_ft": 0.2, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route.max_ft", + "source": "Base help page: within 20 ft of the meter. Review past 15 ft 0 in: No public value", + "placeholder": true + }, + "review_threshold_ft": 15.0 + } + ], + "nearest_considered": null, + "missing_evidence": [ + { + "kind": "band", + "band": "ground", + "span_ft": [ + -3.933334, + -3.0 + ], + "checks": [ + "drive_clearance", + "pool_clearance" + ], + "out_ft": 4.806396, + "message": "Show the ground in front of the wall from 3 ft 11 in left of the meter to 3 ft 0 in left of the meter, at least 4 ft 10 in out from the wall. Part of it is past the left end: point the camera there from the end." + }, + { + "kind": "past_end", + "side": "right", + "span_ft": [ + 9.0, + 9.0 + ], + "message": "Keep walking past the right end of the scan (9 ft 0 in right of the meter): a spot within reach may be there." + } + ], + "ends": { + "left": { + "kind": "limit", + "s_ft": -3.0, + "point": [ + -3.0, + 0.0 + ], + "beyond_reach": false + }, + "right": { + "kind": "unexplored", + "s_ft": 9.0, + "point": [ + 9.0, + 0.0 + ], + "beyond_reach": false + } + }, + "sweep": [ + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -3.0, + -2.845833 + ], + "outcome": "fail", + "failing": [ + "meter_working_space" + ], + "unsure": [ + "ac_clearance", + "drive_clearance", + "gas_clearance", + "ground_surface", + "opening_clearance", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -2.791667, + 0.070833 + ], + "outcome": "fail", + "failing": [ + "meter_working_space" + ], + "unsure": [ + "ac_clearance", + "drive_clearance", + "gas_clearance", + "opening_clearance", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 0.1, + 1.041667 + ], + "outcome": "fail", + "failing": [ + "meter_working_space" + ], + "unsure": [ + "drive_clearance", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 1.095833, + 1.375 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "drive_clearance", + "meter_working_space", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 1.458333, + 3.291667 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "drive_clearance", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 3.375, + 6.316667 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "ac_clearance", + "drive_clearance", + "gas_clearance", + "opening_clearance", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 6.345833, + 6.416667 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "ac_clearance", + "drive_clearance", + "gas_clearance", + "ground_surface", + "opening_clearance", + "pool_clearance" + ] + } + ], + "stats": { + "candidates": 151, + "pass": 0, + "unsure": 74, + "fail": 77, + "elapsed_ms": 0, + "input_sha256": "8381e865208e0f984bcef83fd3f42294b49d40dcc3e81fde2f74fea35eab5bdd" + } +} diff --git a/web/src/samples/corner-not-walked/scene.json b/web/src/samples/corner-not-walked/scene.json new file mode 100644 index 000000000..9913cbbb9 --- /dev/null +++ b/web/src/samples/corner-not-walked/scene.json @@ -0,0 +1,128 @@ +{ + "schema_version": "1.0", + "meter": { + "pos": [ + 0.0, + 5.0, + 0.0 + ], + "wall_id": "side", + "plus_minus_ft": 0.1 + }, + "walls": [ + { + "id": "side", + "baseline": [ + [ + -3.0, + 0.0 + ], + [ + 9.0, + 0.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "objects": [], + "ground": [ + { + "type": "mulch", + "polygon": [ + [ + -40.0, + 0.0 + ], + [ + 40.0, + 0.0 + ], + [ + 40.0, + 4.0 + ], + [ + -40.0, + 4.0 + ] + ], + "plus_minus_ft": 0.1 + }, + { + "type": "lawn", + "polygon": [ + [ + -40.0, + 4.0 + ], + [ + 40.0, + 4.0 + ], + [ + 40.0, + 20.0 + ], + [ + -40.0, + 20.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "overheads": [], + "facing": [ + { + "wall_id": "side", + "span_ft": [ + -3.0, + 9.0 + ], + "depth_ft": 16.0, + "plus_minus_ft": 0.2 + } + ], + "coverage": { + "ends": { + "left": { + "kind": "limit" + }, + "right": { + "kind": "unexplored" + } + }, + "observed": [ + { + "band": "wall", + "span_ft": [ + -20.0, + 9.0 + ] + }, + { + "band": "ground", + "span_ft": [ + -20.0, + 9.0 + ], + "out_ft": 20.0 + }, + { + "band": "overhead", + "span_ft": [ + -20.0, + 9.0 + ] + }, + { + "band": "facing", + "span_ft": [ + -20.0, + 9.0 + ] + } + ] + } +} diff --git a/web/src/samples/fits/plan.svg b/web/src/samples/fits/plan.svg new file mode 100644 index 000000000..c3e4bc601 --- /dev/null +++ b/web/src/samples/fits/plan.svg @@ -0,0 +1,90 @@ + +Battery site plan: pass +The battery fits 2 ft 9 in right of the meter with a 1 ft 6 in cable run: every check passes. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's. + + + + + + + + + + + + + + + + + + + + + +Gas + + + +Window + +Cable 1 ft 5 in + + + +Meter + + + + +5 ft + +Battery + +Cable + +Meter working space + +Gas + +Not seen +Fits + \ No newline at end of file diff --git a/web/src/samples/fits/result.json b/web/src/samples/fits/result.json new file mode 100644 index 000000000..448c51f0e --- /dev/null +++ b/web/src/samples/fits/result.json @@ -0,0 +1,643 @@ +{ + "schema_version": "1.0", + "decision": "pass", + "summary": "The battery fits 2 ft 9 in right of the meter with a 1 ft 6 in cable run: every check passes. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [ + { + "code": "all_checks_pass", + "message": "A fully observed spot passes every check." + } + ], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "68693248e605d4252cd14dd74afd2e4e042408b9632612a0a4ac6dc5a708da2b", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": { + "outcome": "pass", + "wall_id": "side", + "segment": 0, + "span_ft": [ + 1.458333, + 4.041667 + ], + "width_ft": 2.583333, + "depth_ft": 1.833333, + "height_ft": 3.291667, + "footprint": [ + [ + 1.458333, + 0.0 + ], + [ + 4.041667, + 0.0 + ], + [ + 4.041667, + 1.833333 + ], + [ + 1.458333, + 1.833333 + ] + ], + "center": [ + 2.75, + 0.916667 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + -0.0, + 1.0 + ], + "meter_offset_ft": [ + 2.75, + 0.916667 + ], + "route_length_ft": 1.458333 + }, + "route": { + "outcome": "pass", + "length_ft": 1.458333, + "plus_minus_ft": 0.2, + "height_ft": 1.0, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + 1.458333, + 0.0 + ] + ], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "wall_backing", + "label": "Flush against one straight wall", + "outcome": "pass", + "reason": "The whole footprint backs onto one straight, observed wall segment.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "battery.width_ft", + "source": "Base Core spec: 30.68 in wide, rounded up to 31 in (docs/01 C2)", + "placeholder": false + } + }, + { + "id": "ground_surface", + "label": "Ground under the battery", + "outcome": "pass", + "reason": "The whole footprint stands on an allowed surface.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "ground.allowed", + "source": "docs/01 ground types; which surfaces are allowed is a demo choice", + "placeholder": true + } + }, + { + "id": "meter_working_space", + "label": "Clear of the meter's working space", + "outcome": "pass", + "reason": "The battery is 0 ft 3 in clear of the meter's 2 ft 6 in wide by 3 ft 0 in deep working space.", + "measured_ft": 0.208333, + "plus_minus_ft": 0.2, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": "meter", + "rule": { + "key": "meter_working_space.width_ft", + "source": "NEC 110.26(A)(2): 30 in wide working space", + "placeholder": false + } + }, + { + "id": "gas_clearance", + "label": "Distance from gas equipment", + "outcome": "pass", + "reason": "Nearest gas meter or pipe is 6 ft 6 in (\u00b1 0 ft 2 in) away, clear of the 3 ft 0 in rule, and the area around the battery was seen.", + "measured_ft": 6.458333, + "plus_minus_ft": 0.2, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "objects[0] gas_meter", + "rule": { + "key": "clearances.gas_ft", + "source": "Base help page: 3 ft from the gas meter; Austin Energy \u00a71.9; Texas Gas Service", + "placeholder": false + } + }, + { + "id": "ac_clearance", + "label": "Distance from AC units", + "outcome": "pass", + "reason": "No AC unit within 3 ft 0 in of the battery, and that whole area was seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.ac_ft", + "source": "Base help page: 3 ft from AC units, fences and other batteries", + "placeholder": false + } + }, + { + "id": "drive_clearance", + "label": "Distance from the driveway", + "outcome": "pass", + "reason": "No drivable surface within 5 ft 0 in of the battery, and that whole area was seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 5.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.drive_ft", + "source": "No public value (docs/01 rules table: TBD)", + "placeholder": true + } + }, + { + "id": "pool_clearance", + "label": "Distance from a pool", + "outcome": "pass", + "reason": "No pool within 10 ft 0 in of the battery, and that whole area was seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 10.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.pool_ft", + "source": "No public value (docs/01 rules table: TBD); the demo ring drawn in docs/eli5.html", + "placeholder": true + } + }, + { + "id": "opening_clearance", + "label": "Distance from doors and windows", + "outcome": "pass", + "reason": "Nearest door or window is 12 ft 6 in (\u00b1 0 ft 2 in) away, clear of the 3 ft 0 in rule, and the area around the battery was seen.", + "measured_ft": 12.458333, + "plus_minus_ft": 0.2, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "objects[1] window", + "rule": { + "key": "clearances.opening_ft", + "source": "IRC 2021 R328.4: 3 ft from doors and windows directly entering the dwelling. Applying it to garage doors (openings.types) is a demo choice the code does not require", + "placeholder": false + } + }, + { + "id": "wall_equipment_above", + "label": "No box or vent above the battery", + "outcome": "pass", + "reason": "Nothing is mounted on the wall above the battery.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.wall_equipment_ft", + "source": "No public value; 0 means only overlap fails", + "placeholder": true + } + }, + { + "id": "facing_gap", + "label": "Open space in front", + "outcome": "pass", + "reason": "The gap in front of the battery is 14 ft 2 in (\u00b1 0 ft 2 in), clear of the 3 ft 0 in rule.", + "measured_ft": 14.166667, + "plus_minus_ft": 0.2, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "facing[0]", + "rule": { + "key": "facing.min_ft", + "source": "Base help page: 3 ft from fences", + "placeholder": false + } + }, + { + "id": "headroom", + "label": "Headroom above", + "outcome": "pass", + "reason": "Nothing limits the headroom over the battery's stretch of wall.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 6.5, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "headroom.min_ft", + "source": "NEC 110.26(A)(3) working-space headroom, applied to the battery as a demo choice", + "placeholder": true + } + }, + { + "id": "route_path", + "label": "Cable route along the wall", + "outcome": "pass", + "reason": "The cable runs along continuous, observed wall with nothing blocking it.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "route.crossing", + "source": "docs/02 Lane C: the cable can't cross a door, a garage or a stretch with no wall", + "placeholder": false + } + }, + { + "id": "route_length", + "label": "Cable run length", + "outcome": "pass", + "reason": "The cable run is 1 ft 6 in (\u00b1 0 ft 2 in), within the 15 ft 0 in confident reach.", + "measured_ft": 1.458333, + "plus_minus_ft": 0.2, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route.max_ft", + "source": "Base help page: within 20 ft of the meter. Review past 15 ft 0 in: No public value", + "placeholder": true + }, + "review_threshold_ft": 15.0 + } + ], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "s_ft": -20.0, + "point": [ + -20.0, + 0.0 + ], + "beyond_reach": false + }, + "right": { + "kind": "limit", + "s_ft": 24.0, + "point": [ + 24.0, + 0.0 + ], + "beyond_reach": true + } + }, + "sweep": [ + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -20.0, + -19.845833 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "ac_clearance", + "drive_clearance", + "gas_clearance", + "ground_surface", + "opening_clearance", + "pool_clearance", + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -19.791667, + -19.383333 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "ac_clearance", + "drive_clearance", + "gas_clearance", + "opening_clearance", + "pool_clearance", + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -19.3375, + -16.958333 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [ + "ac_clearance", + "drive_clearance", + "gas_clearance", + "pool_clearance", + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -16.879167, + -16.208333 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [ + "drive_clearance", + "pool_clearance", + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -16.125, + -14.958333 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [ + "drive_clearance", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -14.875, + -11.7875 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [ + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -11.783333, + -11.383333 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [ + "gas_clearance", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -11.3375, + -9.958333 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "opening_clearance" + ], + "unsure": [ + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -9.875, + -8.245833 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "opening_clearance" + ], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -8.2, + -7.8 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [ + "opening_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -7.795833, + -4.041667 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -3.958333, + -3.679167 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [ + "meter_working_space" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -3.625, + -2.245833 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "meter_working_space" + ], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -2.2, + -1.8 + ], + "outcome": "fail", + "failing": [ + "meter_working_space" + ], + "unsure": [ + "gas_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -1.795833, + 1.041667 + ], + "outcome": "fail", + "failing": [ + "meter_working_space" + ], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 1.095833, + 1.375 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "meter_working_space" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 1.458333, + 14.754167 + ], + "outcome": "pass", + "failing": [], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 14.8, + 17.291667 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 17.375, + 20.2 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "pool_clearance", + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 20.204167, + 20.600001 + ], + "outcome": "fail", + "failing": [ + "route_length" + ], + "unsure": [ + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 20.600001, + 21.416667 + ], + "outcome": "fail", + "failing": [ + "route_length" + ], + "unsure": [] + } + ], + "stats": { + "candidates": 723, + "pass": 161, + "unsure": 102, + "fail": 460, + "elapsed_ms": 0, + "input_sha256": "b66a32bcffe9e20e9def354c97d38a4b509ca966db6443c876b523ead0ab5dfd" + } +} diff --git a/web/src/samples/fits/scene.json b/web/src/samples/fits/scene.json new file mode 100644 index 000000000..d1cb2672a --- /dev/null +++ b/web/src/samples/fits/scene.json @@ -0,0 +1,174 @@ +{ + "schema_version": "1.0", + "meter": { + "pos": [ + 0.0, + 5.0, + 0.0 + ], + "wall_id": "side", + "plus_minus_ft": 0.1 + }, + "walls": [ + { + "id": "side", + "baseline": [ + [ + -20.0, + 0.0 + ], + [ + 24.0, + 0.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "objects": [ + { + "type": "gas_meter", + "wall_id": "side", + "span_ft": [ + -6.0, + -5.0 + ], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tap", + "plus_minus_ft": 0.1, + "footprint": [ + [ + -6.0, + 0.0 + ], + [ + -5.0, + 0.0 + ], + [ + -5.0, + 1.0 + ], + [ + -6.0, + 1.0 + ] + ] + }, + { + "type": "window", + "wall_id": "side", + "span_ft": [ + -14.0, + -11.0 + ], + "bottom_ft": 3.0, + "top_ft": 7.0, + "attrs": { + "operable": true + }, + "source": "tap", + "plus_minus_ft": 0.1 + } + ], + "ground": [ + { + "type": "mulch", + "polygon": [ + [ + -40.0, + 0.0 + ], + [ + 40.0, + 0.0 + ], + [ + 40.0, + 4.0 + ], + [ + -40.0, + 4.0 + ] + ], + "plus_minus_ft": 0.1 + }, + { + "type": "lawn", + "polygon": [ + [ + -40.0, + 4.0 + ], + [ + 40.0, + 4.0 + ], + [ + 40.0, + 20.0 + ], + [ + -40.0, + 20.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "overheads": [], + "facing": [ + { + "wall_id": "side", + "span_ft": [ + -20.0, + 24.0 + ], + "depth_ft": 16.0, + "plus_minus_ft": 0.2 + } + ], + "coverage": { + "ends": { + "left": { + "kind": "limit" + }, + "right": { + "kind": "limit" + } + }, + "observed": [ + { + "band": "wall", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "ground", + "span_ft": [ + -20.0, + 30.0 + ], + "out_ft": 20.0 + }, + { + "band": "overhead", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "facing", + "span_ft": [ + -20.0, + 30.0 + ] + } + ] + } +} diff --git a/web/src/samples/garage-in-the-way/plan.svg b/web/src/samples/garage-in-the-way/plan.svg new file mode 100644 index 000000000..030453c09 --- /dev/null +++ b/web/src/samples/garage-in-the-way/plan.svg @@ -0,0 +1,89 @@ + +Battery site plan: reject +No spot within reach works: every spot fails opening clearance, gas clearance, route path. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's. + + + + + + + + + + + + + + + +Gas + + + +Window + + + +Garage + + +Gas + + + +Meter + + + + +5 ft + +Battery + +Cable + +Meter working space + +Gas + +Not seen +No spot + \ No newline at end of file diff --git a/web/src/samples/garage-in-the-way/result.json b/web/src/samples/garage-in-the-way/result.json new file mode 100644 index 000000000..1e2e12321 --- /dev/null +++ b/web/src/samples/garage-in-the-way/result.json @@ -0,0 +1,730 @@ +{ + "schema_version": "1.0", + "decision": "reject", + "summary": "No spot within reach works: every spot fails opening clearance, gas clearance, route path. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [ + { + "code": "all_spots_fail", + "checks": [ + "opening_clearance", + "gas_clearance", + "route_path", + "meter_working_space", + "route_length" + ], + "message": "Every spot on the walls scanned fails at least one check by a clear margin." + } + ], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "68693248e605d4252cd14dd74afd2e4e042408b9632612a0a4ac6dc5a708da2b", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": null, + "route": null, + "checks": [ + { + "id": "wall_backing", + "label": "Flush against one straight wall", + "outcome": "pass", + "reason": "The whole footprint backs onto one straight, observed wall segment.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "battery.width_ft", + "source": "Base Core spec: 30.68 in wide, rounded up to 31 in (docs/01 C2)", + "placeholder": false + } + }, + { + "id": "ground_surface", + "label": "Ground under the battery", + "outcome": "pass", + "reason": "The whole footprint stands on an allowed surface.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "ground.allowed", + "source": "docs/01 ground types; which surfaces are allowed is a demo choice", + "placeholder": true + } + }, + { + "id": "meter_working_space", + "label": "Clear of the meter's working space", + "outcome": "pass", + "reason": "The battery is 0 ft 3 in clear of the meter's 2 ft 6 in wide by 3 ft 0 in deep working space.", + "measured_ft": 0.25, + "plus_minus_ft": 0.2, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": "meter", + "rule": { + "key": "meter_working_space.width_ft", + "source": "NEC 110.26(A)(2): 30 in wide working space", + "placeholder": false + } + }, + { + "id": "gas_clearance", + "label": "Distance from gas equipment", + "outcome": "pass", + "reason": "Nearest gas meter or pipe is 6 ft 6 in (\u00b1 0 ft 2 in) away, clear of the 3 ft 0 in rule, and the area around the battery was seen.", + "measured_ft": 6.5, + "plus_minus_ft": 0.2, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "objects[0] gas_meter", + "rule": { + "key": "clearances.gas_ft", + "source": "Base help page: 3 ft from the gas meter; Austin Energy \u00a71.9; Texas Gas Service", + "placeholder": false + } + }, + { + "id": "ac_clearance", + "label": "Distance from AC units", + "outcome": "pass", + "reason": "No AC unit within 3 ft 0 in of the battery, and that whole area was seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.ac_ft", + "source": "Base help page: 3 ft from AC units, fences and other batteries", + "placeholder": false + } + }, + { + "id": "drive_clearance", + "label": "Distance from the driveway", + "outcome": "pass", + "reason": "No drivable surface within 5 ft 0 in of the battery, and that whole area was seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 5.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.drive_ft", + "source": "No public value (docs/01 rules table: TBD)", + "placeholder": true + } + }, + { + "id": "pool_clearance", + "label": "Distance from a pool", + "outcome": "pass", + "reason": "No pool within 10 ft 0 in of the battery, and that whole area was seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 10.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.pool_ft", + "source": "No public value (docs/01 rules table: TBD); the demo ring drawn in docs/eli5.html", + "placeholder": true + } + }, + { + "id": "opening_clearance", + "label": "Distance from doors and windows", + "outcome": "fail", + "reason": "objects[2] garage_door is 0 ft 0 in (\u00b1 0 ft 2 in) from the battery; the rule needs more than 3 ft 0 in.", + "measured_ft": 0.0, + "plus_minus_ft": 0.2, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "objects[2] garage_door", + "rule": { + "key": "clearances.opening_ft", + "source": "IRC 2021 R328.4: 3 ft from doors and windows directly entering the dwelling. Applying it to garage doors (openings.types) is a demo choice the code does not require", + "placeholder": false + } + }, + { + "id": "wall_equipment_above", + "label": "No box or vent above the battery", + "outcome": "pass", + "reason": "Nothing is mounted on the wall above the battery.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "clearances.wall_equipment_ft", + "source": "No public value; 0 means only overlap fails", + "placeholder": true + } + }, + { + "id": "facing_gap", + "label": "Open space in front", + "outcome": "pass", + "reason": "The gap in front of the battery is 14 ft 2 in (\u00b1 0 ft 2 in), clear of the 3 ft 0 in rule.", + "measured_ft": 14.166667, + "plus_minus_ft": 0.2, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "facing[0]", + "rule": { + "key": "facing.min_ft", + "source": "Base help page: 3 ft from fences", + "placeholder": false + } + }, + { + "id": "headroom", + "label": "Headroom above", + "outcome": "pass", + "reason": "Nothing limits the headroom over the battery's stretch of wall.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 6.5, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "headroom.min_ft", + "source": "NEC 110.26(A)(3) working-space headroom, applied to the battery as a demo choice", + "placeholder": true + } + }, + { + "id": "route_path", + "label": "Cable route along the wall", + "outcome": "pass", + "reason": "The cable runs along continuous, observed wall with nothing blocking it.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "route.crossing", + "source": "docs/02 Lane C: the cable can't cross a door, a garage or a stretch with no wall", + "placeholder": false + } + }, + { + "id": "route_length", + "label": "Cable run length", + "outcome": "pass", + "reason": "The cable run is 1 ft 6 in (\u00b1 0 ft 2 in), within the 15 ft 0 in confident reach.", + "measured_ft": 1.5, + "plus_minus_ft": 0.2, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route.max_ft", + "source": "Base help page: within 20 ft of the meter. Review past 15 ft 0 in: No public value", + "placeholder": true + }, + "review_threshold_ft": 15.0 + } + ], + "nearest_considered": { + "outcome": "fail", + "wall_id": "side", + "segment": 0, + "span_ft": [ + 1.5, + 4.083333 + ], + "width_ft": 2.583333, + "depth_ft": 1.833333, + "height_ft": 3.291667, + "footprint": [ + [ + 1.5, + 0.0 + ], + [ + 4.083333, + 0.0 + ], + [ + 4.083333, + 1.833333 + ], + [ + 1.5, + 1.833333 + ] + ], + "center": [ + 2.791667, + 0.916667 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + -0.0, + 1.0 + ], + "meter_offset_ft": [ + 2.791667, + 0.916667 + ], + "route_length_ft": 1.5 + }, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "s_ft": -20.0, + "point": [ + -20.0, + 0.0 + ], + "beyond_reach": false + }, + "right": { + "kind": "limit", + "s_ft": 24.0, + "point": [ + 24.0, + 0.0 + ], + "beyond_reach": true + } + }, + "sweep": [ + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -20.0, + -19.845833 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [ + "ac_clearance", + "drive_clearance", + "ground_surface", + "opening_clearance", + "pool_clearance", + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -19.791667, + -19.383333 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [ + "ac_clearance", + "drive_clearance", + "opening_clearance", + "pool_clearance", + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -19.3375, + -16.905624 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "opening_clearance" + ], + "unsure": [ + "ac_clearance", + "drive_clearance", + "pool_clearance", + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -16.9, + -16.208333 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "opening_clearance" + ], + "unsure": [ + "drive_clearance", + "pool_clearance", + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -16.125, + -14.958333 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "opening_clearance" + ], + "unsure": [ + "drive_clearance", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -14.875, + -14.245833 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "opening_clearance" + ], + "unsure": [ + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -14.2, + -13.8 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [ + "gas_clearance", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -13.795833, + -11.7875 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [ + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -11.783333, + -11.383333 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [ + "gas_clearance", + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -11.3375, + -9.958333 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "opening_clearance" + ], + "unsure": [ + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -9.875, + -8.245833 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "opening_clearance" + ], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -8.2, + -7.8 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [ + "opening_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -7.795833, + -4.2875 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -4.283333, + -4.083333 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [ + "opening_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -4.020833, + -3.883333 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [ + "meter_working_space", + "opening_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -3.858333, + -3.663934 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "opening_clearance" + ], + "unsure": [ + "meter_working_space" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -3.625, + -2.245833 + ], + "outcome": "fail", + "failing": [ + "gas_clearance", + "meter_working_space", + "opening_clearance" + ], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -2.2, + -1.8 + ], + "outcome": "fail", + "failing": [ + "meter_working_space", + "opening_clearance" + ], + "unsure": [ + "gas_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + -1.795833, + 1.041667 + ], + "outcome": "fail", + "failing": [ + "meter_working_space", + "opening_clearance" + ], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 1.095833, + 1.45 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [ + "meter_working_space" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 1.5, + 1.5 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 1.520833, + 14.754167 + ], + "outcome": "fail", + "failing": [ + "opening_clearance", + "route_path" + ], + "unsure": [] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 14.8, + 17.291667 + ], + "outcome": "fail", + "failing": [ + "opening_clearance", + "route_path" + ], + "unsure": [ + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 17.375, + 20.2 + ], + "outcome": "fail", + "failing": [ + "opening_clearance", + "route_path" + ], + "unsure": [ + "pool_clearance", + "route_length" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 20.204167, + 21.000001 + ], + "outcome": "fail", + "failing": [ + "opening_clearance", + "route_length", + "route_path" + ], + "unsure": [ + "pool_clearance" + ] + }, + { + "wall_id": "side", + "segment": 0, + "start_ft": [ + 21.000001, + 21.416667 + ], + "outcome": "fail", + "failing": [ + "route_length" + ], + "unsure": [] + } + ], + "stats": { + "candidates": 801, + "pass": 0, + "unsure": 0, + "fail": 801, + "elapsed_ms": 0, + "input_sha256": "78ed52b548f21222c7b6eab84b217aefee265010f99c918485fc1a7a1b469de7" + } +} diff --git a/web/src/samples/garage-in-the-way/scene.json b/web/src/samples/garage-in-the-way/scene.json new file mode 100644 index 000000000..f515de37c --- /dev/null +++ b/web/src/samples/garage-in-the-way/scene.json @@ -0,0 +1,216 @@ +{ + "schema_version": "1.0", + "meter": { + "pos": [ + 0.0, + 5.0, + 0.0 + ], + "wall_id": "side", + "plus_minus_ft": 0.1 + }, + "walls": [ + { + "id": "side", + "baseline": [ + [ + -20.0, + 0.0 + ], + [ + 24.0, + 0.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "objects": [ + { + "type": "gas_meter", + "wall_id": "side", + "span_ft": [ + -6.0, + -5.0 + ], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tap", + "plus_minus_ft": 0.1, + "footprint": [ + [ + -6.0, + 0.0 + ], + [ + -5.0, + 0.0 + ], + [ + -5.0, + 1.0 + ], + [ + -6.0, + 1.0 + ] + ] + }, + { + "type": "window", + "wall_id": "side", + "span_ft": [ + -14.0, + -11.0 + ], + "bottom_ft": 3.0, + "top_ft": 7.0, + "attrs": { + "operable": true + }, + "source": "tap", + "plus_minus_ft": 0.1 + }, + { + "type": "garage_door", + "wall_id": "side", + "span_ft": [ + 1.5, + 24.0 + ], + "bottom_ft": 0.0, + "top_ft": 7.0, + "source": "tap", + "plus_minus_ft": 0.1 + }, + { + "type": "gas_meter", + "wall_id": "side", + "span_ft": [ + -18.0, + -17.0 + ], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tap", + "plus_minus_ft": 0.1, + "footprint": [ + [ + -18.0, + 0.0 + ], + [ + -17.0, + 0.0 + ], + [ + -17.0, + 1.0 + ], + [ + -18.0, + 1.0 + ] + ] + } + ], + "ground": [ + { + "type": "mulch", + "polygon": [ + [ + -40.0, + 0.0 + ], + [ + 40.0, + 0.0 + ], + [ + 40.0, + 4.0 + ], + [ + -40.0, + 4.0 + ] + ], + "plus_minus_ft": 0.1 + }, + { + "type": "lawn", + "polygon": [ + [ + -40.0, + 4.0 + ], + [ + 40.0, + 4.0 + ], + [ + 40.0, + 20.0 + ], + [ + -40.0, + 20.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "overheads": [], + "facing": [ + { + "wall_id": "side", + "span_ft": [ + -20.0, + 24.0 + ], + "depth_ft": 16.0, + "plus_minus_ft": 0.2 + } + ], + "coverage": { + "ends": { + "left": { + "kind": "limit" + }, + "right": { + "kind": "limit" + } + }, + "observed": [ + { + "band": "wall", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "ground", + "span_ft": [ + -20.0, + 30.0 + ], + "out_ft": 20.0 + }, + { + "band": "overhead", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "facing", + "span_ft": [ + -20.0, + 30.0 + ] + } + ] + } +} diff --git a/web/src/samples/index.ts b/web/src/samples/index.ts new file mode 100644 index 000000000..376620679 --- /dev/null +++ b/web/src/samples/index.ts @@ -0,0 +1,48 @@ +// Synthetic scenes with the answers the placement server gave them, recorded by +// scripts/record_samples.py. The saved answer is shown only when the user asks for it after the +// live server could not be reached. +import cornerPlan from "./corner-not-walked/plan.svg?raw"; +import cornerResult from "./corner-not-walked/result.json?raw"; +import cornerScene from "./corner-not-walked/scene.json?raw"; +import fitsPlan from "./fits/plan.svg?raw"; +import fitsResult from "./fits/result.json?raw"; +import fitsScene from "./fits/scene.json?raw"; +import garagePlan from "./garage-in-the-way/plan.svg?raw"; +import garageResult from "./garage-in-the-way/result.json?raw"; +import garageScene from "./garage-in-the-way/scene.json?raw"; + +export interface Sample { + id: string; + title: string; + about: string; + scene: string; + savedResult: string; + savedPlan: string; +} + +export const SAMPLES: readonly Sample[] = [ + { + id: "fits", + title: "Clear side wall", + about: "A long side wall with a gas meter and a window left of the electric meter.", + scene: fitsScene, + savedResult: fitsResult, + savedPlan: fitsPlan, + }, + { + id: "corner-not-walked", + title: "Walk stopped at a corner", + about: "A short wall; the homeowner stopped 9 ft to the right, where the house turns.", + scene: cornerScene, + savedResult: cornerResult, + savedPlan: cornerPlan, + }, + { + id: "garage-in-the-way", + title: "Garage in the way", + about: "A garage door right of the meter and gas meters to its left.", + scene: garageScene, + savedResult: garageResult, + savedPlan: garagePlan, + }, +]; diff --git a/web/src/samples/samples.test.ts b/web/src/samples/samples.test.ts new file mode 100644 index 000000000..1fbb8300b --- /dev/null +++ b/web/src/samples/samples.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { parseResult } from "../lib/api.ts"; +import { SAMPLES } from "./index.ts"; + +// What the hosted server (public rules, demo policy) decides for each sample. +const DECISION: Record = { + fits: "pass", + "corner-not-walked": "manual_review", + "garage-in-the-way": "reject", +}; + +// A saved answer stands in for the live server, so it must be what the hosted server would say +// about the same scene. +describe.each(SAMPLES)("saved answer for $id", (sample) => { + const result = parseResult(JSON.parse(sample.savedResult)); + + it("was recorded under the hosted server's demo policy, and says so", () => { + expect(result.policy).toMatchObject({ id: "demo", auto_approve: true, sources: ["public"] }); + expect(result.policy.notice).toMatch(/not Base's/); + }); + + it("gets the decision the hosted server gives it", () => { + expect(result.decision).toBe(DECISION[sample.id]); + }); +}); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 000000000..75e336acc --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,809 @@ +/* Placement review: an inspector's field sheet on drafting film. The palette matches the site + plan the server draws (paper, ink, blue meter area, purple cable, red battery). */ + +:root { + --paper: #f2f4f3; + --sheet: #fbfcfb; + --ink: #1d262b; + --ink-2: #4a565c; + --rule: #cbd2d5; + --rule-soft: #e3e8ea; + --grid: rgb(29 38 43 / 0.05); + --fits: #1e6b45; + --review: #92560a; + --no-spot: #a12622; + --focus: #2f6fdb; + + --font-display: "Barlow Semi Condensed", "Arial Narrow", sans-serif; + --font-body: "Public Sans Variable", "Public Sans", system-ui, sans-serif; + --font-data: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace; + + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + + color-scheme: light dark; + background: var(--paper); + color: var(--ink); + font-family: var(--font-body); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: transparent; +} + +@media (prefers-reduced-motion: no-preference) { + :root { + scroll-behavior: smooth; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --paper: #141a1d; + --sheet: #1a2225; + --ink: #e4e9eb; + --ink-2: #a9b4b9; + --rule: #34403f; + --rule-soft: #243033; + --grid: rgb(228 233 235 / 0.04); + --fits: #5fc592; + --review: #e7a64a; + --no-spot: #f07a6f; + --focus: #7aa7f5; + } +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; + /* Faint drafting grid behind the sheet. */ + background-image: + linear-gradient(var(--grid) 1px, transparent 1px), + linear-gradient(90deg, var(--grid) 1px, transparent 1px); + background-size: 24px 24px; +} + +body { + margin: 0; +} + +h1, +h2, +p, +dl, +dd, +ol, +ul, +figure { + margin: 0; +} + +ul, +ol { + padding: 0; +} + +code { + /* Commands and addresses stay whole; a long address still breaks rather than overflow. */ + white-space: nowrap; + font-family: var(--font-data); + font-size: 0.9em; + background: var(--rule-soft); + padding: 0.05em 0.35em; + border-radius: 4px; +} + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* --- page ------------------------------------------------------------------------------------ */ + +.page { + max-width: 1180px; + margin: 0 auto; + padding: 0 20px 48px; +} + +.bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 24px; + padding: 18px 0; + border-bottom: 1px solid var(--rule); +} + +.brand { + margin: 0; + font-family: var(--font-display); + font-weight: 700; + font-size: 1.35rem; + letter-spacing: 0.01em; +} + +.brand-sub { + margin-left: 8px; + font-family: var(--font-body); + font-weight: 500; + font-size: 1.05rem; + color: var(--ink-2); +} + +.layout { + display: grid; + gap: 24px; + padding-top: 24px; +} + +@media (min-width: 900px) { + .layout { + grid-template-columns: 300px minmax(0, 1fr); + gap: 32px; + align-items: start; + } + + .aside { + position: sticky; + top: 24px; + } +} + +/* --- controls -------------------------------------------------------------------------------- */ + +.button { + min-height: 44px; + padding: 0 16px; + border: 1px solid var(--ink); + border-radius: 6px; + background: var(--ink); + color: var(--sheet); + font: 600 0.95rem var(--font-body); + cursor: pointer; + touch-action: manipulation; + user-select: none; + transition: transform 160ms var(--ease-out); +} + +.button.quiet { + background: transparent; + color: var(--ink); + border-color: var(--rule); +} + +.button:active { + transform: scale(0.97); +} + +.server { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: none; + background: var(--rule); +} + +.status-dot[data-status="up"] { + background: var(--fits); +} + +.status-dot[data-status="down"] { + background: var(--no-spot); +} + +.server-text { + display: grid; + min-width: 0; + line-height: 1.25; +} + +.server-url { + font-family: var(--font-data); + font-size: 0.85rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.server-status { + font-size: 0.8rem; + color: var(--ink-2); +} + +.server-form { + flex-wrap: wrap; +} + +.server-form label { + font-size: 0.85rem; + color: var(--ink-2); +} + +.server-warning { + flex-basis: 100%; + font-size: 0.85rem; + color: var(--review); +} + +.server-form input { + min-height: 44px; + min-width: 0; + width: 18rem; + max-width: 100%; + padding: 0 12px; + /* --ink-2 keeps the field's edge above 3:1 on the paper (WCAG 1.4.11). */ + border: 1px solid var(--ink-2); + border-radius: 6px; + background: var(--sheet); + color: var(--ink); + /* 16px or more, or iOS zooms in on focus. */ + font: 1rem var(--font-data); +} + +/* --- scene panel ----------------------------------------------------------------------------- */ + +.panel-title, +.block h2 { + font-family: var(--font-display); + font-weight: 600; + font-size: 0.8rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-2); +} + +.scene-panel { + display: grid; + gap: 12px; +} + +.samples { + display: grid; + gap: 8px; + list-style: none; +} + +.sample { + display: grid; + gap: 2px; + width: 100%; + min-height: 44px; + padding: 12px 14px; + border: 1px solid var(--rule); + border-radius: 8px; + background: var(--sheet); + color: var(--ink); + text-align: left; + font: inherit; + cursor: pointer; + touch-action: manipulation; + transition: + transform 160ms var(--ease-out), + border-color 160ms ease; +} + +.sample:active { + transform: scale(0.98); +} + +.sample[aria-current="true"] { + border-color: var(--ink); + box-shadow: inset 3px 0 0 var(--ink); +} + +@media (hover: hover) and (pointer: fine) { + .sample:hover { + border-color: var(--ink-2); + } +} + +.sample-title { + font-weight: 650; +} + +.sample-about { + font-size: 0.85rem; + color: var(--ink-2); +} + +/* On a phone the list sits above the answer: titles only, so the answer stays close. */ +@media (max-width: 899px) { + .sample-about { + display: none; + } + + .sample { + padding: 10px 14px; + } +} + +.drop { + display: grid; + gap: 2px; + padding: 14px; + border: 1.5px dashed var(--rule); + border-radius: 8px; + cursor: pointer; + transition: border-color 160ms ease; +} + +.drop[data-dragging="true"], +.drop:active { + border-color: var(--ink); + background: var(--rule-soft); +} + +.drop:has(:focus-visible) { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.drop-title { + font-weight: 650; + overflow-wrap: anywhere; +} + +.drop-hint { + font-size: 0.85rem; + color: var(--ink-2); +} + +/* --- sheet ----------------------------------------------------------------------------------- */ + +.sheet { + position: relative; + scroll-margin-top: 16px; + min-height: 360px; + padding: 24px; + border: 1px solid var(--rule); + border-radius: 10px; + background: var(--sheet); + overflow: hidden; +} + +@media (max-width: 599px) { + .sheet { + padding: 18px 16px; + } +} + +.empty, +.failure { + display: grid; + gap: 12px; + max-width: 36rem; +} + +.empty h2, +.failure h2 { + font-family: var(--font-display); + font-weight: 700; + font-size: 1.75rem; + line-height: 1.15; +} + +code.address { + white-space: normal; + overflow-wrap: anywhere; +} + +.failure .detail { + font-family: var(--font-data); + font-size: 0.85rem; + color: var(--ink-2); + overflow-wrap: anywhere; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-top: 4px; +} + +.waiting { + color: var(--ink-2); +} + +/* Shown only when an answer takes longer than 150ms; most arrive before it would flash. */ +.progress { + position: absolute; + inset: 0 0 auto; + height: 3px; + overflow: hidden; + opacity: 0; + animation: appear 0s linear 150ms forwards; +} + +.progress::before { + content: ""; + position: absolute; + inset: 0; + width: 30%; + background: var(--ink); + animation: sweep 1.1s linear infinite; +} + +@keyframes appear { + to { + opacity: 1; + } +} + +@keyframes sweep { + from { + transform: translateX(-100%); + } + to { + transform: translateX(340%); + } +} + +/* Dim a stale answer only if the next one is slow; undo the dim at once when it arrives. */ +.answer { + transition: opacity 120ms var(--ease-out); +} + +.answer[data-stale="true"] { + opacity: 0.45; + transition: opacity 160ms ease 150ms; +} + +/* --- result ---------------------------------------------------------------------------------- */ + +.result { + display: grid; + gap: 24px; +} + +.verdict { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px 24px; +} + +/* The inspector's stamp: the one mark the page is remembered by. */ +.stamp { + flex: none; + margin: 0; + padding: 0.15em 0.6em 0.1em; + border: 3px solid currentColor; + border-radius: 6px; + box-shadow: + inset 0 0 0 2px var(--sheet), + inset 0 0 0 3.5px currentColor; + font-family: var(--font-display); + font-weight: 700; + font-size: clamp(1.5rem, 4vw, 2.1rem); + letter-spacing: 0.08em; + line-height: 1.2; + text-transform: uppercase; + transform: rotate(-3deg); + animation: stamp 220ms var(--ease-out) both; +} + +.stamp[data-decision="pass"] { + color: var(--fits); +} + +.stamp[data-decision="manual_review"] { + color: var(--review); +} + +.stamp[data-decision="reject"] { + color: var(--no-spot); +} + +@keyframes stamp { + from { + opacity: 0; + transform: rotate(-3deg) scale(1.12); + } + to { + opacity: 1; + transform: rotate(-3deg) scale(1); + } +} + +.verdict-text { + display: grid; + gap: 4px; + flex: 1 1 18rem; +} + +.summary { + font-size: 1.15rem; + font-weight: 550; + line-height: 1.4; + text-wrap: pretty; +} + +.source { + font-size: 0.85rem; + color: var(--ink-2); +} + +.facts { + display: grid; + gap: 12px; + padding: 14px 0; + border-block: 1px solid var(--rule); +} + +@media (min-width: 600px) { + .facts { + /* The battery's place is the longest fact. */ + grid-template-columns: minmax(0, 1.7fr) minmax(0, 1.2fr) minmax(0, 0.8fr); + gap: 16px; + } +} + +.facts dt { + font-family: var(--font-display); + font-weight: 600; + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-2); +} + +.facts dd { + font-family: var(--font-data); + font-size: 1.02rem; +} + +.plan img { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--rule); + border-radius: 6px; +} + +.plan-missing { + padding: 24px; + border: 1px dashed var(--rule); + border-radius: 6px; + color: var(--ink-2); +} + +/* Holds roughly a plan's shape (the server draws them 960 wide, about 340 tall), so the sections + below move less when it arrives. */ +.plan-drawing { + display: grid; + place-items: center; + aspect-ratio: 960 / 340; +} + +.block { + display: grid; + gap: 10px; +} + +.checks { + display: grid; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + gap: 10px; + padding: 12px 0; + border-top: 1px solid var(--rule-soft); +} + +.check:first-child { + border-top: 0; +} + +.outcome::before { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 50%; + font: 700 0.8rem / 1 var(--font-body); + color: var(--sheet); +} + +.check[data-outcome="pass"] .outcome::before { + content: "✓"; + background: var(--fits); +} + +.check[data-outcome="unsure"] .outcome::before { + content: "?"; + background: var(--review); +} + +.check[data-outcome="fail"] .outcome::before { + content: "✕"; + background: var(--no-spot); +} + +.check-body { + display: grid; + gap: 2px; +} + +.check-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; +} + +.check-label { + font-weight: 650; +} + +.cause { + padding: 0 8px; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + color: var(--review); +} + +.figure { + font-family: var(--font-data); + font-size: 0.9rem; +} + +.limit { + color: var(--ink-2); +} + +.reason { + font-size: 0.92rem; + color: var(--ink-2); + text-wrap: pretty; +} + +.views { + display: grid; + gap: 8px; + padding-left: 1.4em; +} + +.views li::marker { + font-family: var(--font-data); + color: var(--ink-2); +} + +.all-checks summary { + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; + cursor: pointer; + list-style: none; + font-family: var(--font-display); + font-weight: 600; + font-size: 0.8rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-2); + touch-action: manipulation; +} + +.all-checks summary:active { + color: var(--ink); +} + +.all-checks summary::-webkit-details-marker { + display: none; +} + +.all-checks summary::before { + content: ""; + width: 7px; + height: 7px; + border-right: 2px solid var(--ink-2); + border-bottom: 2px solid var(--ink-2); + transform: rotate(-45deg); + transition: transform 160ms var(--ease-out); +} + +.all-checks[open] summary::before { + transform: rotate(45deg); +} + +/* A new answer's sections rise in under the stamp, 40ms apart. An answer that replaces another + only re-stamps: someone stepping through samples sees this often. */ +.result[data-entrance="full"] > .reveal { + animation: rise 240ms var(--ease-out) both; +} + +.result[data-entrance="full"] > .reveal:nth-child(2) { + animation-delay: 40ms; +} + +.result[data-entrance="full"] > .reveal:nth-child(3) { + animation-delay: 80ms; +} + +.result[data-entrance="full"] > .reveal:nth-child(n + 4) { + animation-delay: 120ms; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes pulse { + from { + opacity: 0.25; + } + to { + opacity: 0.6; + } +} + +@media (prefers-reduced-motion: reduce) { + .stamp, + .result[data-entrance="full"] > .reveal { + animation: fade 160ms ease both; + } + + /* Still says "working", without movement. */ + .progress::before { + width: 100%; + animation: pulse 1.1s ease-in-out infinite alternate; + } + + .button:active, + .sample:active { + transform: none; + opacity: 0.85; + } +} + +@media (forced-colors: active) { + .sample[aria-current="true"] { + outline: 2px solid Highlight; + } + + .progress::before { + background: Highlight; + } +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 000000000..eca652acd --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,4 @@ +interface ImportMetaEnv { + /** Base URL of the placement server. Defaults to "/api", the dev proxy in vite.config.ts. */ + readonly VITE_PLACEMENT_API?: string; +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json index 5f6af8443..69b7965b4 100644 --- a/web/tsconfig.node.json +++ b/web/tsconfig.node.json @@ -19,5 +19,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "scripts"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index b96b41deb..e1014d04a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,7 +3,16 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], + server: { + // `pnpm dev` forwards /api to a local placement server (server/: uv run uvicorn api:app). + proxy: { + "/api": { + target: process.env.PLACEMENT_SERVER ?? "http://localhost:8000", + rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, + }, test: { - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + include: ["src/**/*.test.ts", "src/**/*.test.tsx", "scripts/**/*.test.ts"], }, });