18 Commits

Author SHA1 Message Date
Ty 8749b07611 Fix preview cropping: canvas fits within viewport, preserves aspect ratio
.preview was width:100% inside max-height:50vh container — tall photos
were clipped. Now: max-width:100%, max-height:50vh, width/height auto,
object-fit:contain, flex-centered in the wrap.

Added e2e regression test: loads a photo, verifies the canvas bounding
box fits the viewport, preserves aspect ratio, and is not clipped by
the container.
2026-09-14 11:51:44 -07:00
Ty 2d5ff95441 Fix SSR browser gate: use browser from $app/environment
The gate check ran during SSR where navigator is undefined,
rendering the 'Unsupported browser' div into the initial HTML.
Now gated on browser (client-only) so SSR renders the app
and the gate evaluates in the browser.
2026-09-14 11:49:01 -07:00
Ty 2bc7eecd7c UAT-8b: axis UI — mask carry-forward, remove axis line, slider
1. drawOverlay: checkerboard mask overlay now drawn in axis + measure
   states (mask carry-forward — slab is isolated)
2. Removed the separate red axis line + arrowhead — the rect's long
   side IS the axis
3. Rect rotates: uses axisState.rect.angle (from UAT-8a)
4. Replace three rotate buttons with slider (-180..180, step 1) +
   minus/plus nudge buttons (same pattern as Sensitivity slider)

587 lines, 109/109 tests, build OK.
2026-09-14 11:45:12 -07:00
Ty f00c4a7460 UAT-8a: rotate the rect, not a separate axis vector
reduceAxisState 'rotate' now changes state.rect.angle and re-derives
state.axis via axisFromRect(newRect). The rectangle rotates as a whole;
the axis for Measure is derived from the rotated rect's long side.

2 new regression tests: rect.angle changes on rotate, axis is
re-derived from rotated rect. 109/109 tests green.
2026-09-14 11:23:00 -07:00
Ty 0d06e6f7b7 UAT-3: browser gate (hard stop) + iPad Safari fix
Spec CAPTURE.md §Browsers: Phone iOS Safari / Android Chrome, PC
Chrome / Edge / Firefox, others hard stop.

- browserGate.js: UA detection + allowlist (pure, DOM-free)
- +layout.svelte: renders hard-stop panel when unsupported
- iPadOS 13+ reports as Macintosh — added Safari rule (Version/ +
  Safari/ without Chrome/) so iPad Safari is not blocked
- 27 unit tests incl. iPad Safari regression
- 107/107 full suite green
2026-09-14 07:44:49 -07:00
Ty a83c9239ff UAT-7: fix auto key selection — prefer key that removes more background
Root cause: auto selected the key with MORE kept pixels, which always
picked the green key (it keeps 100% of dark non-green backgrounds).
The black key correctly isolates the slab on dark surfaces.

Fix:
- auto now prefers the key with FEWER kept pixels (removed more bg)
  when both are non-empty
- green key has a greenness pre-filter: non-greenish pixels pass
  through (kept) so the black key can handle them, instead of the
  green key keeping 100% of a dark surface
- added 3 regression tests: dark-bg auto selection, green-sheet auto
  selection, green-key passthrough on non-green surfaces

106/106 unit tests green.
2026-09-14 07:41:51 -07:00
Ty 88b9c47200 UAT-4: remove non-spec controls (edge offset, feather) from Mask UI
Spec CAPTURE.md §Mask lists exactly two controls: Sheet and Sensitivity. Edge offset and feather nudge buttons were extra. Removed from the UI; core params (edgeOffset, feather) remain in maskState.js, still unit-tested.
2026-09-14 07:24:42 -07:00
Ty eca306e869 M1-6: record UAT host in INVENTORY.md 2026-09-14 07:08:41 -07:00
Ty 509e2e82a3 Add M1-6 deploy script 2026-09-14 02:14:08 -07:00
Ty 25b1650a8a M1-5: clean up debug artifacts 2026-09-14 02:12:53 -07:00
Ty b124450d0c M1-5: Measure + PNG + full capture UI (TDD)
- measure.js: sampleWidthsPx scanline sampler
- inventory.js: prepareInventoryMetadata
- +page.svelte: full 4-state capture flow (Add photo, Mask, Axis, Measure)
- Canvas overlay: checkerboard mask, bounding rect, axis line
- Inventory PNG: 3:4 portrait, slab centered 80% fill, alpha
- 77/77 unit tests green
2026-09-14 02:11:28 -07:00
Ty 507865e30b Add e2e fixtures: slab-green.jpg, sheet-only-green.jpg, slab.heic 2026-09-14 00:43:58 -07:00
Ty 0284a2211a M1-4: Length axis (TDD)
- Add axis.js: length axis rectangle state, 28 tests
- 71/71 unit tests green
2026-09-14 00:32:42 -07:00
Ty 7e2a1845cf M1-3: Capture + Mask state (TDD)
- Add maskState.js: sheet Auto/Green/Black, sensitivity, edge offset, feather
- Overlay = slab photo + checkerboard on removed px
- Debounced recompute 2.0s after last sensitivity change
- Approve blocked when maskStats empty
- HEIC shows retake notice
- Refactored mask.test.js + extended e2e mask.spec.js
- 43/43 unit tests green
2026-09-14 00:02:36 -07:00
Ty dd011207a0 feat(m1-2,m1-3): deterministic core + mask keying (TDD, green)
Card 2 (deterministic core) + Card 3 (capture+mask geometry):
- src/lib/core/math.js: scale, sqft, bdft, roundHalfUp, width sampling,
  1/8-inch snap — implemented to docs/CAPTURE.md identities.
- src/lib/core/mask.js: keySheet green/black/auto chroma keys + maskStats.
  - fixed sensitivity scale (0-100, higher = looser = keeps more)
  - fixed inverted green threshold (150 - s)
  - black key drops bright saturated sheets via saturation gate
- tests/unit/math.test.js: corrected 3 wrong expected values to match the
  spec (sqft 21x12in = 1.75 not 17.5; bdft 3.06; sample avg 11.25).
- tests/e2e/mask.spec.js: skeleton, skipped pending M1-5 live UI.

Full unit suite green: 24/24. E2e collects clean.
2026-09-13 23:17:36 -07:00
Ty 93666320ef feat(m1-1): scaffold SvelteKit + Dockerfile + TDD test harness
- SvelteKit (JS, adapter-node) home page + /api/health route
- frontend/Dockerfile multi-stage build->serve (slabuploader-m1:local, 349MB)
- Test harness: Vitest unit + Playwright e2e; smoke tests RED->GREEN verified
- Build, image build, and image serve all verified
2026-09-13 22:55:34 -07:00
Ty ba8e5f72f6 docs(m1): calibrated worker delegation policy (prompt framing, verify-before-land) 2026-09-13 22:48:48 -07:00
Ty 92d1a7ec5e docs(m1): plan of record — TDD card breakdown, UAT deployment, fixtures, worker dispatch
- docs/M1-POR.md: scope, TDD method, 6-card breakdown, deployment POR,
  test plan, real-slab fixtures, dev/QA worker dispatch (.202:8081 text,
  .202:8082 vision; one task per endpoint)
- docs/CAPTURE.md: input photo (JPEG + HEIC fallback), 200KB soft goal
- deploy/README.md: Milestone 1 UAT deployment POR (one container, LAN URL)
- tests/fixtures/: 3 black-sheet slab photos + calibration records
  (152422 28x2.25 irregular, 155818 21x1.75, 155754 21x1.75)
2026-09-13 22:37:19 -07:00
74 changed files with 6730 additions and 2 deletions
+454
View File
@@ -0,0 +1,454 @@
// this file is generated — do not edit it
/// <reference types="@sveltejs/kit" />
/**
* This module provides access to environment variables that are injected _statically_ into your bundle at build time and are limited to _private_ access.
*
* | | Runtime | Build time |
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
*
* Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
*
* **_Private_ access:**
*
* - This module cannot be imported into client-side code
* - This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
*
* For example, given the following build time environment:
*
* ```env
* ENVIRONMENT=production
* PUBLIC_BASE_URL=http://site.com
* ```
*
* With the default `publicPrefix` and `privatePrefix`:
*
* ```ts
* import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/private';
*
* console.log(ENVIRONMENT); // => "production"
* console.log(PUBLIC_BASE_URL); // => throws error during build
* ```
*
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
*/
declare module '$env/static/private' {
export const LESSOPEN: string;
export const TMUX: string;
export const HERMES_DELEGATED_CHILD_CONTEXT: string;
export const timezone: string;
export const CUDACXX: string;
export const BROWSERBASE_PROXIES: string;
export const HERMES_RESTART_DRAIN_TIMEOUT: string;
export const _HERMES_GATEWAY: string;
export const AI_AGENT: string;
export const USER: string;
export const SSH_CLIENT: string;
export const npm_config_user_agent: string;
export const TERMINAL_CONTAINER_CPU: string;
export const AGENT_BROWSER_EXECUTABLE_PATH: string;
export const TERMINAL_DOCKER_ENV: string;
export const TERMINAL_CWD: string;
export const HERMES_REAL_HOME: string;
export const XDG_SESSION_TYPE: string;
export const npm_node_execpath: string;
export const SHLVL: string;
export const HERMES_PROFILE: string;
export const npm_config_noproxy: string;
export const HOME: string;
export const OLDPWD: string;
export const TERMINAL_DOCKER_FORWARD_ENV: string;
export const TERM_PROGRAM_VERSION: string;
export const BROWSER_SESSION_TIMEOUT: string;
export const HERMES_KANBAN_BOARD: string;
export const SSH_TTY: string;
export const HERMES_HOME: string;
export const npm_package_json: string;
export const TERMINAL_DOCKER_EXTRA_ARGS: string;
export const HF_HUB_DISABLE_XET: string;
export const IM_CONFIG_ENTRY: string;
export const TERMINAL_DOCKER_IMAGE: string;
export const PAGER: string;
export const TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE: string;
export const TERMINAL_CONTAINER_MEMORY: string;
export const SSL_CERT_FILE: string;
export const TERMINAL_CONTAINER_PERSISTENT: string;
export const npm_config_userconfig: string;
export const npm_config_local_prefix: string;
export const HERMES_TURN_LEASE_TIMEOUT: string;
export const HERMES_MEDIA_DELIVERY_STRICT: string;
export const TERMINAL_MODAL_IMAGE: string;
export const DBUS_SESSION_BUS_ADDRESS: string;
export const COLORTERM: string;
export const TERMINAL_DOCKER_NETWORK: string;
export const file_read_max_chars: string;
export const COLOR: string;
export const DEBUGINFOD_URLS: string;
export const TERMINAL_DOCKER_VOLUMES: string;
export const TERMINAL_ENV: string;
export const TERMINAL_VERCEL_RUNTIME: string;
export const LOGNAME: string;
export const HERMES_KANBAN_DB: string;
export const IMAGE_TOOLS_DEBUG: string;
export const _: string;
export const npm_config_prefix: string;
export const npm_config_npm_version: string;
export const group_sessions_per_user: string;
export const HERMES_KANBAN_WORKSPACES_ROOT: string;
export const HERMES_SINGLE_QUERY_SESSION: string;
export const XDG_SESSION_CLASS: string;
export const BROWSERBASE_ADVANCED_STEALTH: string;
export const paste_collapse_threshold: string;
export const TERM: string;
export const TERMINAL_DAYTONA_IMAGE: string;
export const XDG_SESSION_ID: string;
export const npm_config_cache: string;
export const TERMINAL_TEMP_DIR: string;
export const TERMINAL_LIFETIME_SECONDS: string;
export const TERMINAL_TIMEOUT: string;
export const HERMES_GATEWAY_BUSY_INPUT_MODE: string;
export const paste_collapse_char_threshold: string;
export const TERMINAL_DOCKER_SHARED_CONTAINER_KEY: string;
export const npm_config_node_gyp: string;
export const PATH: string;
export const HERMES_AGENT_TIMEOUT: string;
export const NODE: string;
export const TERMINAL_DOCKER_SHM_SIZE: string;
export const HERMES_KANBAN_WORKSPACE: string;
export const XDG_RUNTIME_DIR: string;
export const TERMINAL_PERSISTENT_SHELL: string;
export const LANG: string;
export const HERMES_QUIET: string;
export const MOA_TOOLS_DEBUG: string;
export const HERMES_MEDIA_TRUST_RECENT_FILES: string;
export const LS_COLORS: string;
export const HERMES_MEDIA_TRUST_RECENT_SECONDS: string;
export const HERMES_MAX_ITERATIONS: string;
export const TERM_PROGRAM: string;
export const npm_lifecycle_script: string;
export const HERMES_SESSION_ID: string;
export const HERMES_AGENT_TIMEOUT_WARNING: string;
export const prefill_messages_file: string;
export const SHELL: string;
export const HERMES_AGENT_NOTIFY_INTERVAL: string;
export const TERMINAL_DEGRADED_MODE: string;
export const TERMINAL_MODAL_MODE: string;
export const npm_lifecycle_event: string;
export const WEB_TOOLS_DEBUG: string;
export const VISION_TOOLS_DEBUG: string;
export const LESSCLOSE: string;
export const TERMINAL_CONTAINER_DISK: string;
export const TERMINAL_DOCKER_RUN_AS_HOST_USER: string;
export const GIT_PAGER: string;
export const paste_collapse_threshold_fallback: string;
export const npm_config_globalconfig: string;
export const npm_config_init_module: string;
export const PWD: string;
export const HERMES_INTERACTIVE: string;
export const _config_version: string;
export const HERMES_AUTO_CONTINUE_FRESHNESS: string;
export const npm_execpath: string;
export const SSH_CONNECTION: string;
export const XDG_DATA_DIRS: string;
export const npm_config_global_prefix: string;
export const hooks_auto_accept: string;
export const npm_command: string;
export const TMUX_PLUGIN_MANAGER_PATH: string;
export const BROWSER_INACTIVITY_TIMEOUT: string;
export const HERMES_REDACT_SECRETS: string;
export const HERMES_CUSTOM_192_168_1_203_8084_API_KEY: string;
export const TERMINAL_SINGULARITY_IMAGE: string;
export const TMUX_PANE: string;
export const TERMINAL_HOME_MODE: string;
export const TERMINAL_DOCKER_SNAP_COMPAT: string;
export const HERMES_AGENT: string;
export const HERMES_SESSION_SOURCE: string;
export const INIT_CWD: string;
export const EDITOR: string;
export const TEST: string;
export const VITEST: string;
export const NODE_ENV: string;
}
/**
* This module provides access to environment variables that are injected _statically_ into your bundle at build time and are _publicly_ accessible.
*
* | | Runtime | Build time |
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
*
* Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
*
* **_Public_ access:**
*
* - This module _can_ be imported into client-side code
* - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
*
* For example, given the following build time environment:
*
* ```env
* ENVIRONMENT=production
* PUBLIC_BASE_URL=http://site.com
* ```
*
* With the default `publicPrefix` and `privatePrefix`:
*
* ```ts
* import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/public';
*
* console.log(ENVIRONMENT); // => throws error during build
* console.log(PUBLIC_BASE_URL); // => "http://site.com"
* ```
*
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
*/
declare module '$env/static/public' {
}
/**
* This module provides access to environment variables set _dynamically_ at runtime and that are limited to _private_ access.
*
* | | Runtime | Build time |
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
*
* Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
*
* **_Private_ access:**
*
* - This module cannot be imported into client-side code
* - This module includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
*
* > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
*
* > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
* >
* > ```env
* > MY_FEATURE_FLAG=
* > ```
* >
* > You can override `.env` values from the command line like so:
* >
* > ```sh
* > MY_FEATURE_FLAG="enabled" npm run dev
* > ```
*
* For example, given the following runtime environment:
*
* ```env
* ENVIRONMENT=production
* PUBLIC_BASE_URL=http://site.com
* ```
*
* With the default `publicPrefix` and `privatePrefix`:
*
* ```ts
* import { env } from '$env/dynamic/private';
*
* console.log(env.ENVIRONMENT); // => "production"
* console.log(env.PUBLIC_BASE_URL); // => undefined
* ```
*/
declare module '$env/dynamic/private' {
export const env: {
LESSOPEN: string;
TMUX: string;
HERMES_DELEGATED_CHILD_CONTEXT: string;
timezone: string;
CUDACXX: string;
BROWSERBASE_PROXIES: string;
HERMES_RESTART_DRAIN_TIMEOUT: string;
_HERMES_GATEWAY: string;
AI_AGENT: string;
USER: string;
SSH_CLIENT: string;
npm_config_user_agent: string;
TERMINAL_CONTAINER_CPU: string;
AGENT_BROWSER_EXECUTABLE_PATH: string;
TERMINAL_DOCKER_ENV: string;
TERMINAL_CWD: string;
HERMES_REAL_HOME: string;
XDG_SESSION_TYPE: string;
npm_node_execpath: string;
SHLVL: string;
HERMES_PROFILE: string;
npm_config_noproxy: string;
HOME: string;
OLDPWD: string;
TERMINAL_DOCKER_FORWARD_ENV: string;
TERM_PROGRAM_VERSION: string;
BROWSER_SESSION_TIMEOUT: string;
HERMES_KANBAN_BOARD: string;
SSH_TTY: string;
HERMES_HOME: string;
npm_package_json: string;
TERMINAL_DOCKER_EXTRA_ARGS: string;
HF_HUB_DISABLE_XET: string;
IM_CONFIG_ENTRY: string;
TERMINAL_DOCKER_IMAGE: string;
PAGER: string;
TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE: string;
TERMINAL_CONTAINER_MEMORY: string;
SSL_CERT_FILE: string;
TERMINAL_CONTAINER_PERSISTENT: string;
npm_config_userconfig: string;
npm_config_local_prefix: string;
HERMES_TURN_LEASE_TIMEOUT: string;
HERMES_MEDIA_DELIVERY_STRICT: string;
TERMINAL_MODAL_IMAGE: string;
DBUS_SESSION_BUS_ADDRESS: string;
COLORTERM: string;
TERMINAL_DOCKER_NETWORK: string;
file_read_max_chars: string;
COLOR: string;
DEBUGINFOD_URLS: string;
TERMINAL_DOCKER_VOLUMES: string;
TERMINAL_ENV: string;
TERMINAL_VERCEL_RUNTIME: string;
LOGNAME: string;
HERMES_KANBAN_DB: string;
IMAGE_TOOLS_DEBUG: string;
_: string;
npm_config_prefix: string;
npm_config_npm_version: string;
group_sessions_per_user: string;
HERMES_KANBAN_WORKSPACES_ROOT: string;
HERMES_SINGLE_QUERY_SESSION: string;
XDG_SESSION_CLASS: string;
BROWSERBASE_ADVANCED_STEALTH: string;
paste_collapse_threshold: string;
TERM: string;
TERMINAL_DAYTONA_IMAGE: string;
XDG_SESSION_ID: string;
npm_config_cache: string;
TERMINAL_TEMP_DIR: string;
TERMINAL_LIFETIME_SECONDS: string;
TERMINAL_TIMEOUT: string;
HERMES_GATEWAY_BUSY_INPUT_MODE: string;
paste_collapse_char_threshold: string;
TERMINAL_DOCKER_SHARED_CONTAINER_KEY: string;
npm_config_node_gyp: string;
PATH: string;
HERMES_AGENT_TIMEOUT: string;
NODE: string;
TERMINAL_DOCKER_SHM_SIZE: string;
HERMES_KANBAN_WORKSPACE: string;
XDG_RUNTIME_DIR: string;
TERMINAL_PERSISTENT_SHELL: string;
LANG: string;
HERMES_QUIET: string;
MOA_TOOLS_DEBUG: string;
HERMES_MEDIA_TRUST_RECENT_FILES: string;
LS_COLORS: string;
HERMES_MEDIA_TRUST_RECENT_SECONDS: string;
HERMES_MAX_ITERATIONS: string;
TERM_PROGRAM: string;
npm_lifecycle_script: string;
HERMES_SESSION_ID: string;
HERMES_AGENT_TIMEOUT_WARNING: string;
prefill_messages_file: string;
SHELL: string;
HERMES_AGENT_NOTIFY_INTERVAL: string;
TERMINAL_DEGRADED_MODE: string;
TERMINAL_MODAL_MODE: string;
npm_lifecycle_event: string;
WEB_TOOLS_DEBUG: string;
VISION_TOOLS_DEBUG: string;
LESSCLOSE: string;
TERMINAL_CONTAINER_DISK: string;
TERMINAL_DOCKER_RUN_AS_HOST_USER: string;
GIT_PAGER: string;
paste_collapse_threshold_fallback: string;
npm_config_globalconfig: string;
npm_config_init_module: string;
PWD: string;
HERMES_INTERACTIVE: string;
_config_version: string;
HERMES_AUTO_CONTINUE_FRESHNESS: string;
npm_execpath: string;
SSH_CONNECTION: string;
XDG_DATA_DIRS: string;
npm_config_global_prefix: string;
hooks_auto_accept: string;
npm_command: string;
TMUX_PLUGIN_MANAGER_PATH: string;
BROWSER_INACTIVITY_TIMEOUT: string;
HERMES_REDACT_SECRETS: string;
HERMES_CUSTOM_192_168_1_203_8084_API_KEY: string;
TERMINAL_SINGULARITY_IMAGE: string;
TMUX_PANE: string;
TERMINAL_HOME_MODE: string;
TERMINAL_DOCKER_SNAP_COMPAT: string;
HERMES_AGENT: string;
HERMES_SESSION_SOURCE: string;
INIT_CWD: string;
EDITOR: string;
TEST: string;
VITEST: string;
NODE_ENV: string;
[key: `PUBLIC_${string}`]: undefined;
[key: `${string}`]: string | undefined;
}
}
/**
* This module provides access to environment variables set _dynamically_ at runtime and that are _publicly_ accessible.
*
* | | Runtime | Build time |
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
*
* Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
*
* **_Public_ access:**
*
* - This module _can_ be imported into client-side code
* - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
*
* > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
*
* > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
* >
* > ```env
* > MY_FEATURE_FLAG=
* > ```
* >
* > You can override `.env` values from the command line like so:
* >
* > ```sh
* > MY_FEATURE_FLAG="enabled" npm run dev
* > ```
*
* For example, given the following runtime environment:
*
* ```env
* ENVIRONMENT=production
* PUBLIC_BASE_URL=http://example.com
* ```
*
* With the default `publicPrefix` and `privatePrefix`:
*
* ```ts
* import { env } from '$env/dynamic/public';
* console.log(env.ENVIRONMENT); // => undefined, not public
* console.log(env.PUBLIC_BASE_URL); // => "http://example.com"
* ```
*
* ```
*
* ```
*/
declare module '$env/dynamic/public' {
export const env: {
[key: `PUBLIC_${string}`]: string | undefined;
}
}
+1
View File
@@ -0,0 +1 @@
// See https://svelte.dev/docs/kit/environment-variables for more information
+38
View File
@@ -0,0 +1,38 @@
// in dev, this makes Vite inject its client as this module's first dependency,
// so that global constant replacements are installed before any other module
// (including user hooks) evaluates. In build it's inert.
import.meta.hot;
export { matchers } from './matchers.js';
export const nodes = [
() => import('./nodes/0'),
() => import('./nodes/1')
];
export const server_loads = [];
export const dictionary = {
};
export const hooks = {
handleError: (({ error }) => { console.error(error) }),
reroute: (() => {}),
transport: {}
};
export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode]));
export const hash = false;
export const decode = (type, value) => decoders[type](value);
export { default as root } from '../root.js';
export const get_error_template = () => import('../shared/error-template.js').then(m => m.default);
+1
View File
@@ -0,0 +1 @@
export const matchers = {};
+1
View File
@@ -0,0 +1 @@
export { default as component } from "../../../../frontend/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/layout.svelte";
+1
View File
@@ -0,0 +1 @@
export { default as component } from "../../../../frontend/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte";
@@ -0,0 +1 @@
export default ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n";
+50
View File
@@ -0,0 +1,50 @@
{
"compilerOptions": {
"paths": {
"$app/types": [
"./types/index.d.ts"
]
},
"rootDirs": [
"..",
"./types"
],
"verbatimModuleSyntax": true,
"isolatedModules": true,
"lib": [
"esnext",
"DOM",
"DOM.Iterable"
],
"moduleResolution": "bundler",
"module": "esnext",
"noEmit": true,
"target": "esnext"
},
"include": [
"ambient.d.ts",
"env.d.ts",
"non-ambient.d.ts",
"./types/**/$types.d.ts",
"../vite.config.js",
"../vite.config.ts",
"../src/**/*.js",
"../src/**/*.ts",
"../src/**/*.svelte",
"../test/**/*.js",
"../test/**/*.ts",
"../test/**/*.svelte",
"../tests/**/*.js",
"../tests/**/*.ts",
"../tests/**/*.svelte"
],
"exclude": [
"../node_modules/**",
"../src/service-worker.js",
"../src/service-worker/**/*.js",
"../src/service-worker.ts",
"../src/service-worker/**/*.ts",
"../src/service-worker.d.ts",
"../src/service-worker/**/*.d.ts"
]
}
+15
View File
@@ -0,0 +1,15 @@
# M1-6 UAT Deploy — run these on 192.168.1.202
# 1. Copy the repo to .202 (from THIS machine, /home/ty):
rsync -az --exclude node_modules --exclude .svelte-kit --exclude build \
/home/ty/SlabUploader/ ty@192.168.1.202:SlabUploader/
# 2. Build the image on .202:
ssh ty@192.168.1.202 "cd SlabUploader/frontend && docker build -t slabuploader-m1 . && \
docker rm -f slabuploader-m1 2>/dev/null; \
docker run -d --name slabuploader-m1 --restart unless-stopped -p 18080:80 slabuploader-m1 && \
echo 'Container running at http://192.168.1.202:18080'"
# 3. Verify from your phone:
# Open http://192.168.1.202:18080 in Safari/Chrome
# Flow: Add photo → Mask (green sheet, sensitivity) → Approve → Axis → Approve → Measure → Calculate
+36
View File
@@ -0,0 +1,36 @@
# SlabUploader M1 — shared build context (for :202 worker tasks)
REPO: /home/ty/SlabUploader — work ONLY inside /home/ty/SlabUploader/frontend.
Branch: M1-dev-rev4 (already checked out; DO NOT switch branches, DO NOT push).
This is Milestone 1 (Capture) of a slab-measuring web app. Browser-only, NO backend,
NO inference, NO OpenCV/ONNX. Pure JS (not TS). Svelte 5 + SvelteKit + adapter-node.
TEST COMMANDS (run these, show real output):
unit: cd /home/ty/SlabUploader/frontend && npx vitest run
e2e: npx playwright test --list (collection only; full e2e needs the M1-6 container)
EXISTING (already committed, green, DO NOT break):
src/lib/core/mask.js -> keySheet(data,{sheet,sensitivity}), maskStats(mask)
src/lib/core/math.js -> scale, sqft, bdft, roundHalfUp, sampledWidths,
sampledWidthsFrom, snapToEighth
tests/unit/mask.test.js, tests/unit/math.test.js (24 tests, all pass)
src/routes/+page.svelte (placeholder home — M1-3 replaces it)
src/routes/+layout.svelte, src/routes/api/health/+server.js
TDD IS MANDATORY (test-driven-development skill):
1. Write the failing test(s) FIRST.
2. Run them and WATCH them fail for the right reason (feature missing, not a typo).
3. Write the MINIMAL code to pass.
4. Run them and confirm they pass.
5. Run the FULL unit suite (npx vitest run) and confirm no regressions + pristine output.
6. Only then are you done. Commit is done by the coordinator — you do NOT commit.
CONVENTIONS:
- Tab indentation (the repo uses tabs).
- Pure logic lives in src/lib/core/*.js (DOM-free, unit-testable under node).
- Svelte components live under src/lib/ (e.g. src/lib/components/).
- Every new exported function needs a unit test that you SAW fail first.
- No console.log left in production code.
- Do not add dependencies.
- Report at the end: files created/changed, the exact test command output, and
any place you had to make a judgment call.
+5 -2
View File
@@ -1,4 +1,7 @@
# Host
Record the machine and URL when a container is started.
Do not put a host name in the product spec.
- Machine: ubuDual5060 (192.168.1.202)
- Container: slabuploader-m1 (image sha256:98d11c23, restart unless-stopped)
- URL: http://192.168.1.202:18080
- Port: 18080 (LAN interface, bound 0.0.0.0)
- Started: 2026-09-14 (M1 UAT)
+9
View File
@@ -6,3 +6,12 @@ Where the container runs is chosen at runtime.
No FastAPI and no Woo client in this milestone.
`docker-compose.yml`, `Caddyfile.fragment`, and `.env.example` describe an older two-service stack. They are not the Milestone 1 contract.
## Milestone 1 UAT — deployment POR
- **One container.** Build a single Docker image that serves the SvelteKit capture app. No FastAPI, no Woo client, no Caddy TLS edge in M1. (Caddy/compose/Woo are Milestone 3.)
- **Host chosen at runtime.** Record the machine and URL in `INVENTORY.md` when the container starts. Do not put a host name in the product spec.
- **LAN-reachable UAT URL.** For UAT the container binds a port on the LAN interface (not only `127.0.0.1`), so other machines on the LAN — e.g. a phone — reach the app at `http://<host-lan-ip>:<port>`.
- **No backend.** The app is browser-only in M1 (see `docs/CAPTURE.md`). There is no `/api` route and no inference.
Reference for the current UAT host: see `INVENTORY.md` (filled in when the container starts).
+8
View File
@@ -45,6 +45,12 @@ Phone: iOS Safari, Android Chrome.
PC: Chrome, Edge, Firefox.
Other clients get a hard stop.
## Input photo
Accepted input: JPEG. Phones typically save JPEG; Android Chrome delivers JPEG by default.
If the file is HEIC and the browser cannot decode it, show a one-tap notice to retake the photo as JPEG. HEIC is never silently dropped and never decoded server-side in Milestone 1.
## States
One photo component is reused. Each state advances on approve. Back returns to the previous state. A device draft restores the last approved state.
@@ -126,3 +132,5 @@ The first inventory photo is the measurement reference. Later inventory photos u
Source below that floor: warn and require a new photo. No upscale from an undersized source.
Editor preview of alpha is a light gray checkerboard. The storefront uses the theme background.
File size: aim under 200KB per image (soft goal, not a hard gate in Milestone 1).
+100
View File
@@ -0,0 +1,100 @@
# Milestone 1 — Plan of Record (POR)
Decisions captured as they are made. This is the working plan for implementing and UAT-ing Milestone 1. It is the source of truth for scope, order, and dispatch; the spec detail lives in `docs/CAPTURE.md` and `docs/PRODUCT.md`.
## Scope
Implement **Milestone 1 — Capture** only (see `AGENTS.md`). Browser-only, inference-free, no backend, no Woo create. Milestones 2–4 are out of scope.
## Method — TDD (non-negotiable)
Every card is delivered **test-first** per the `test-driven-development` skill:
```
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
```
- **Red-Green-Refactor**, tracer-bullet: one behavior slice at a time (RED→GREEN→REFACTOR), not all-tests-then-all-code.
- **Verify RED** (watch it fail for the right reason) and **Verify GREEN** (it passes + full suite stays green) are mandatory before the card is done.
- Code written before a failing test is **deleted and redone** — not kept as "reference."
- The deterministic core (math + PNG contract) is unit-tested in a browser-runnable JS suite; UI behaviors are tested with Playwright against the fixtures.
- A card is not complete until: every new function has a test, each test was seen failing first, the full suite passes with pristine output.
## Card breakdown (implementation order)
Each card follows TDD. "Define" = write the failing tests (RED); "Build" = minimal code to pass (GREEN); "Refactor" = clean up keeping green.
1. **Scaffold + Dockerfile + test harness** — SvelteKit project with the browser-only M1 runtime (Web Worker, canvas/OffscreenCanvas, IndexedDB via `idb-keyval`), `frontend/Dockerfile`, and the test harness (JS unit runner + **Playwright** web).
- *TDD note:* this card establishes the harness itself (a smoke test that runs and fails, then passes) so every later card has a place to be RED/GREEN.
2. **Deterministic core** — the pure math + PNG-encode functions.
- *RED:* unit tests for `scale`, `sqft`, `bdft`, width sampling, 1/8″ stepping, rounding, and the PNG contract (3:4, 8-bit RGBA, 80% fill, ≥1600px floor, undersized→retake). Run on the fixtures' calibration records.
- *GREEN/REFACTOR:* implement to pass; core is authoritative and server-less.
3. **Capture + Mask** — Add photo (JPEG; HEIC→"retake as JPEG" notice), green/black key, four knobs, debounced recompute, mask approve.
- *RED:* Playwright — load fixture, key sheet, approve; empty-mask blocks approve; HEIC shows the notice.
- *GREEN/REFACTOR:* build the Mask state to pass.
4. **Length axis** — min-area bounding rectangle + long-side axis, user rotate, approve.
- *RED:* Playwright — rect drawn from the approved mask, axis on long side, rotate, approve.
- *GREEN/REFACTOR:* build the axis state.
5. **Measure + PNG** — length/thickness (1/8″), compute sqft/bdft/widths, write the 3:4 inventory PNG. M1 stops here.
- *RED:* Playwright — enter L/thickness → Calculate; assert PNG is 3:4 with alpha, and numbers equal the fixture's expected values.
- *GREEN/REFACTOR:* build Measure + PNG encode.
6. **M1 UAT** — serve the container per `deploy/README.md` §Milestone 1 UAT; run the full Playwright suite in the container; then the manual phone pass. Record host + URL in `deploy/INVENTORY.md`.
## Deployment (UAT)
See `deploy/README.md` §Milestone 1 UAT — deployment POR: one container, host chosen at runtime, LAN-reachable URL, no backend.
## Dev / QA worker dispatch
Two local inference endpoints are available as dispatchable workers for test / dev / QA:
- **`.202:8081`** — `gemma-4-12B-it-q4_k_m` (12B, Q4) — **completion only, no vision.** Use for lighter text: drafting, code review, summaries.
- **`.202:8082`** — `Qwen3.8-27B-UD-IQ3_XXS` (27B, IQ3) — **completion + multimodal (vision).** The **vision worker** (any task that must look at a slab photo) and the stronger general-reasoning endpoint.
Coordinator: Hermes (me) — plans, decomposes, dispatches, verifies, and does work directly when a card is small or judgment-heavy.
### Calibrated delegation policy (2026-09-14)
- **Frame every prompt**: system role + "show each step" + explicit constraints. Small/low-quant models drop multi-step arithmetic in one-shot "reply JSON only" prompts but get it exactly right with scratch-space framing. Do not judge capability from under-framed probes.
- **The deterministic core stays the coordinator's** (TDD'd, unit-tested). A model that's "usually right" on math is not good enough for the spec — math is cheap to test and must be exact.
- **Verify before land**: any load-bearing model output is checked against tests/spec before entering the repo. Workers are assist, not autonomous builders.
- **Output quirks**: gemma wraps JSON in ``` fences — strip before parsing. Vision calls run ~18s/image (text ~1s).
- **One task per endpoint at a time** (no parallel on the same endpoint — backs up and overheats).
- Both run low-quant (Q4/IQ3). Fine for framed assist; deep sustained reasoning is best done by the coordinator directly.
## Test plan (M1)
Three layers. The deterministic core is unit-tested; the end-to-end path is Playwright; a real phone is the final UAT gate.
### 1. Unit (deterministic core) — must pass before any UI consumes it
- `bdft = sqft × thickness_in`; `sqft = (mask_px / scale²) / 144`; `scale = longest_axis_px / L`.
- 1/8 in stepping; rounding (sqft/bdft 2 decimals).
- Width sampling every 6 in along the approved axis.
- PNG encode contract: 3:4 aspect, 8-bit RGBA, 80% fill, shorter side ≥ 1600px when source allows; undersized source → warn + retake (no upscale).
- Mask empty → approve blocked.
### 2. Playwright (automated end-to-end, runs in the UAT container's browser)
Walks the 5 states against a **sample photo**:
- Add photo → Mask (set sheet + sensitivity) → approve → Length axis → approve → Measure (enter L, thickness) → Calculate.
- Assert: PNG rendered, dimensions = 3:4, alpha present; sqft/bdft/widths equal the expected values for that fixture (within tolerance); numbers visible on screen.
- HEIC input → "retake as JPEG" notice shown (not a crash).
- Device draft: reload → Resume → Continue restores last approved state.
### 3. Manual phone UAT (final gate)
On a real phone (iOS Safari / Android Chrome) over the LAN UAT URL: repeat the happy path with one real slab; confirm PNG + numbers on screen. Record host + URL in `deploy/INVENTORY.md`.
## Test fixtures (sample pics)
Real slab photos are the fixtures (supplied by Ty from `\\truenas.local\Business\Samples`, SMB user `SMBSVC`). Each is a DJI MIMO top-down JPEG, ~4000×2256, slab on a green/black sheet.
`tests/fixtures/`:
- **sample_01 … sample_0N.JPG** — a representative handful of real slab photos (pulled on demand from the TrueNAS Samples share; do not bulk-copy the whole folder).
- Each fixture gets a **calibration record** in `tests/fixtures/<name>.json`: the slab's true length and thickness (in inches), the sheet color, and the approved mask/axis reference — used to compute the expected `scale`, `sqft`, `bdft`, and sampled widths for assertions.
The real photos drive both **bg removal** (green/black key) and **rectangle/axis calibration**. Expected values come from the calibration records, so assertions stay deterministic even on real imagery.
**M1 fixture set (agreed, complete for M1):** three black-sheet slabs — one irregular (stress-tests the min-area rectangle/axis), two clean elongated. Green-sheet keying is deferred (not an M1 fixture). A 1 HEIC sample (optional) for the decode-fallback test.
## Open decisions
- (none outstanding for M1 as of this plan; new decisions append here)
+10
View File
@@ -0,0 +1,10 @@
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
playwright-report
test-results
+18
View File
@@ -0,0 +1,18 @@
# SlabUploader M1 — frontend only (no FastAPI/Woo). Multi-stage: build then serve.
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
EXPOSE 80
ENV PORT=80
CMD ["node", "build/index.js"]
+20
View File
@@ -0,0 +1,20 @@
import { keySheet } from './src/lib/core/mask.js';
import { sampleWidthsPx } from './src/lib/core/measure.js';
const W = 200, H = 150;
function slabPhoto(slabW, slabH, ox, oy) {
const data = new Uint8ClampedArray(W * H * 4);
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
const inSlab = x >= ox && x < ox + slabW && y >= oy && y < oy + slabH;
if (inSlab) { data[i] = 139; data[i + 1] = 90; data[i + 2] = 43; }
else { data[i] = 34; data[i + 1] = 139; data[i + 2] = 34; }
data[i + 3] = 255;
}
return data;
}
const d = slabPhoto(10, 10, 0, 0);
const m = keySheet(d, { sheet: 'green', sensitivity: 50 });
let kept = 0;
for (let i = 0; i < m.length; i++) kept += m[i];
console.log('kept:', kept, 'of', m.length);
+17
View File
@@ -0,0 +1,17 @@
import { sampleWidthsPx } from './src/lib/core/measure.js';
const W = 200, H = 150;
const mask = new Uint8Array(W * H);
// slab 336 x 192 px at (0,0) — extends beyond photo bounds (200x150)
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++)
mask[y * W + x] = (x < 336 && y < 192) ? 1 : 0;
const axis = { origin: {x:0, y:0}, dir: {x:1, y:0}, lengthPx: 336 };
const w = sampleWidthsPx(mask, W, H, axis, 160, 6);
console.log('widths:', w);
// sample points at t=0, 96, 192, 288 px
// perpendicular dir: (0,1) — scan down
// at x=0: y from 0..149 all in slab (y<192) → width 150 (clamped by photo)
// at x=96: same → 150
// at x=192: same → 150
// at x=288: x=288 > 200 (photo width) → out of bounds → 0? or skipped?
console.log('expected: [150, 150, 150] or [150, 150, 150, 0]');
+11
View File
@@ -0,0 +1,11 @@
import { sampleWidthsPx } from './src/lib/core/measure.js';
const W = 400, H = 250;
const mask = new Uint8Array(W * H);
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++)
mask[y * W + x] = 1; // whole photo is slab
const axis = { origin: {x:0, y:0}, dir: {x:1, y:0}, lengthPx: 336 };
const w = sampleWidthsPx(mask, W, H, axis, 160, 6);
console.log('widths (full photo):', w);
console.log('expected: [250, 250, 250] (t=0,96,192; t=288>336? no, 288<336 → also 250)');
console.log('samples at t=0,96,192,288 → 4 samples, each width=250');
+10
View File
@@ -0,0 +1,10 @@
import { keySheet } from './src/lib/core/mask.js';
const data = new Uint8ClampedArray(10 * 4);
// slab brown
data[0] = 139; data[1] = 90; data[2] = 43; data[3] = 255;
// sheet green
data[4] = 34; data[5] = 139; data[6] = 34; data[7] = 255;
data[8] = 34; data[9] = 139; data[10] = 34; data[11] = 255;
data[12] = 34; data[13] = 139; data[14] = 34; data[15] = 255;
const m = keySheet(data, { sheet: 'green', sensitivity: 50 });
console.log('mask for 4 pixels (1 slab + 3 sheet):', Array.from(m));
+8
View File
@@ -0,0 +1,8 @@
import { keySheet } from './src/lib/core/mask.js';
const data = new Uint8ClampedArray(4 * 4);
data[0]=139; data[1]=90; data[2]=43; data[3]=255;
data[4]=34; data[5]=139; data[6]=34; data[7]=255;
data[8]=34; data[9]=139; data[10]=34; data[11]=255;
data[12]=34; data[13]=139; data[14]=34; data[15]=255;
const m = keySheet(data, { sheet:'green', sensitivity:50 });
console.log('mask for 4 px (1 slab, 3 sheet):', Array.from(m));
+29
View File
@@ -0,0 +1,29 @@
const data = new Uint8ClampedArray(4 * 4);
data[0]=139; data[1]=90; data[2]=43; data[3]=255;
data[4]=34; data[5]=139; data[6]=34; data[7]=255;
data[8]=34; data[9]=139; data[10]=34; data[11]=255;
data[12]=34; data[13]=139; data[14]=34; data[15]=255;
const SHEET_GREEN = { r: 0, g: 255, b: 0 };
function greenDistance(r, g, b) {
const dr = r - SHEET_GREEN.r;
const dg = g - SHEET_GREEN.g;
const db = b - SHEET_GREEN.b;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
function greenThreshold(s) { return 150 - s; }
function blackCutoff(s) { return 40 + (s / 100) * 100; }
const BLACK_SAT_CAP = 120;
const sensitivity = 50;
const n = data.length / 4;
const mask = new Uint8Array(n);
for (let i = 0; i < n; i++) {
const j = i * 4;
const r = data[j], g = data[j + 1], b = data[j + 2];
const d = greenDistance(r, g, b);
const t = greenThreshold(sensitivity);
mask[i] = d > t ? 1 : 0;
console.log(`px ${i}: r=${r} g=${g} b=${b} dist=${d.toFixed(1)} thresh=${t} kept=${mask[i]}`);
}
console.log('mask:', Array.from(mask));
+20
View File
@@ -0,0 +1,20 @@
import { keySheet } from './src/lib/core/mask.js';
import { readFileSync } from 'fs';
const fileSrc = readFileSync('./src/lib/core/mask.js', 'utf8');
const match = fileSrc.match(/const SHEET_GREEN = \{([^}]*)\}/);
console.log('SHEET_GREEN in file:', match ? match[0] : 'NOT FOUND');
console.log('keySheet source:', keySheet.toString());
const W = 20, H = 10;
const data = new Uint8ClampedArray(W * H * 4);
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
const inSlab = x >= 5 && x < 15 && y >= 2 && y < 8;
if (inSlab) { data[i]=139; data[i+1]=90; data[i+2]=43; }
else { data[i]=34; data[i+1]=139; data[i+2]=34; }
data[i+3] = 255;
}
const maskResult = keySheet(data, { sheet: 'green', sensitivity: 50 });
console.log('row 5:', Array.from(maskResult.slice(5*W, 6*W)));
let kept = 0; for (let i = 0; i < maskResult.length; i++) kept += maskResult[i];
console.log('kept:', kept, 'expected: 60');
+10
View File
@@ -0,0 +1,10 @@
const SHEET_GREEN = { r: 0, g: 255, b: 0 };
function greenDistance(r, g, b) {
const dr = r - SHEET_GREEN.r;
const dg = g - SHEET_GREEN.g;
const db = b - SHEET_GREEN.b;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
console.log('sheet green (34,139,34):', greenDistance(34, 139, 34).toFixed(1));
console.log('brown (139,90,43):', greenDistance(139, 90, 43).toFixed(1));
console.log('threshold at s=50:', 150 - 50);
+13
View File
@@ -0,0 +1,13 @@
import { keySheet } from './src/lib/core/mask.js';
const W = 20, H = 10;
const data = new Uint8ClampedArray(W * H * 4);
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
const inSlab = x >= 5 && x < 15 && y >= 2 && y < 8;
if (inSlab) { data[i]=139; data[i+1]=90; data[i+2]=43; }
else { data[i]=34; data[i+1]=139; data[i+2]=34; }
data[i+3] = 255;
}
const m = keySheet(data, { sheet: 'green', sensitivity: 50 });
console.log('row 5:', Array.from(m.slice(5*W, 6*W)));
+23
View File
@@ -0,0 +1,23 @@
import { keySheet } from './src/lib/core/mask.js';
const W = 20, H = 10;
const data = new Uint8ClampedArray(W * H * 4);
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
const inSlab = x >= 5 && x < 15 && y >= 2 && y < 8;
if (inSlab) { data[i]=139; data[i+1]=90; data[i+2]=43; }
else { data[i]=34; data[i+1]=139; data[i+2]=34; }
data[i+3] = 255;
}
const m = keySheet(data, { sheet: 'green', sensitivity: 50 });
console.log('row 5:', Array.from(m.slice(5*W, 6*W)));
console.log('row 5 length:', m.slice(5*W, 6*W).length);
const arr = m.slice(5*W, 6*W);
let all1 = true;
for (let i = 0; i < W; i++) if (arr[i] !== 1) { all1 = false; console.log('x=', i, 'val=', arr[i]); }
console.log('all 1s?', all1);
// Check a specific pixel
const px5 = m[5*W+10];
console.log('px at (10,5):', px5);
const px0 = m[0];
console.log('px at (0,0):', px0);
+18
View File
@@ -0,0 +1,18 @@
import { keySheet } from './src/lib/core/mask.js';
const W = 20, H = 10;
const data = new Uint8ClampedArray(W * H * 4);
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
const inSlab = x >= 5 && x < 15 && y >= 2 && y < 8;
if (inSlab) { data[i]=139; data[i+1]=90; data[i+2]=43; }
else { data[i]=34; data[i+1]=139; data[i+2]=34; }
data[i+3] = 255;
}
console.log('px (0,0) =', [data[0], data[1], data[2]]);
console.log('px (10,5) =', [data[(5*W+10)*4], data[(5*W+10)*4+1], data[(5*W+10)*4+2]]);
const m = keySheet(data, { sheet: 'green', sensitivity: 50 });
console.log('mask (0,0) =', m[0]);
console.log('mask (10,5) =', m[5*W+10]);
let kept = 0; for (let i = 0; i < m.length; i++) kept += m[i];
console.log('kept:', kept, 'of', m.length);
+2406
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "slab-uploader-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run",
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.21.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"playwright": "^1.63.0",
"svelte": "^5.0.0",
"svelte-check": "^4.1.0",
"typescript": "^5.8.0",
"vite": "^6.3.0",
"vitest": "^3.2.7"
},
"dependencies": {
"idb-keyval": "^6.2.1"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig, devices } from '@playwright/test';
// Playwright config for M1 end-to-end tests.
// base URL points at the LAN UAT container (card M1-6). Override with BASE_URL.
export default defineConfig({
testDir: './tests/e2e',
timeout: 60_000,
use: {
baseURL: process.env.BASE_URL || 'http://192.168.1.202:18080'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
]
});
+32
View File
@@ -0,0 +1,32 @@
:root {
--bg: #1a1a1a;
--fg: #f2f2f2;
--accent: #4f9d69;
--checker: repeating-conic-gradient(#e8e8e8 0% 25%, #f7f7f7 0% 50%) 0 0 / 24px 24px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
}
button {
font: inherit;
padding: 0.6rem 1rem;
border: 0;
border-radius: 8px;
background: var(--accent);
color: #04140b;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+288
View File
@@ -0,0 +1,288 @@
// M1-4 Length axis — pure functions + state machine, no DOM.
//
// Ground truth: docs/CAPTURE.md §Length axis
// - min-area bounding rectangle of the mask
// - length axis on the long side of that rectangle
// - user rotates the axis if it does not follow the slab length
// - approve the axis
//
// minAreaRect implements the rotating calipers method (Shamos & Ho 1975)
// over the convex hull of the mask pixels. The convex hull is computed
// with the monotone-chain algorithm (O(n log n)).
/**
* Compute the minimum-area bounding rectangle of a set of 2D points.
* Uses the rotating calipers method on the convex hull.
*
* @param {Array<{x:number, y:number}>} pts
* @returns {{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}}
* angle: rotation of the long side in radians (0..PI/2, CCW from +x).
* width: length along the long side. height: length along the short side.
*/
function minAreaRectPoints(pts) {
const n = pts.length;
if (n === 0) return null;
if (n === 1)
return {
center: { x: pts[0].x, y: pts[0].y },
width: 0,
height: 0,
angle: 0,
area: 0,
};
if (n === 2) {
const dx = pts[1].x - pts[0].x;
const dy = pts[1].y - pts[0].y;
return {
center: { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 },
width: Math.sqrt(dx * dx + dy * dy),
height: 0,
angle: Math.atan2(dy, dx),
area: 0,
};
}
// Convex hull (monotone chain).
const hull = convexHull(pts);
if (hull.length < 3) return minAreaRectPoints(hull);
// Rotating calipers: try each hull edge as the support line.
let best = { area: Infinity, width: 0, height: 0, angle: 0, center: { x: 0, y: 0 } };
const m = hull.length;
for (let i = 0; i < m; i++) {
const p1 = hull[i];
const p2 = hull[(i + 1) % m];
const ex = p2.x - p1.x;
const ey = p2.y - p1.y;
const len = Math.sqrt(ex * ex + ey * ey);
const ux = ex / len;
const uy = ey / len;
// Project all hull points onto the edge direction (u) and its
// left normal (v = (-uy, ux)).
let minU = Infinity,
maxU = -Infinity;
let minV = Infinity,
maxV = -Infinity;
for (let j = 0; j < m; j++) {
const q = hull[j];
const u = q.x * ux + q.y * uy;
const v = -q.x * uy + q.y * ux;
if (u < minU) minU = u;
if (u > maxU) maxU = u;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
}
const w = maxU - minU;
const h = maxV - minV;
const area = w * h;
if (area < best.area) {
// Center in (u,v) frame, transformed back to (x,y):
// x = cu * ux - cv * uy
// y = cu * uy + cv * ux
const cu = (minU + maxU) / 2;
const cv = (minV + maxV) / 2;
const centerX = cu * ux - cv * uy;
const centerY = cu * uy + cv * ux;
// Long side: along u when w >= h, else along the normal v.
let width, height, angle;
if (w >= h) {
width = w;
height = h;
angle = Math.atan2(uy, ux);
} else {
width = h;
height = w;
// Long side along v = (-uy, ux)
angle = Math.atan2(ux, -uy);
}
// Normalize angle to [0, PI) — rectangle has 180-degree symmetry.
if (angle < 0) angle += Math.PI;
if (angle >= Math.PI) angle -= Math.PI;
best = {
center: { x: centerX, y: centerY },
width,
height,
angle,
area,
};
}
}
return best;
}
/**
* Monotone-chain convex hull, keeping collinear boundary points.
* Returns an array of {x,y} points in CCW order, no duplicate last point.
*
* Collinear points are kept because the min-area bounding rectangle
* algorithm needs them: a rectangle's side is defined by its two
* endpoints, and dropping intermediate collinear points would lose the
* true extent of the shape.
*
* @param {Array<{x:number,y:number}>} pts
* @returns {Array<{x:number,y:number}>}
*/
function convexHull(pts) {
const n = pts.length;
if (n <= 2) return pts.slice();
const sorted = pts.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
const cross = (O, A, B) =>
(A.x - O.x) * (B.y - O.y) - (A.y - O.y) * (B.x - O.x);
const lower = [];
for (let i = 0; i < n; i++) {
while (
lower.length >= 2 &&
cross(lower[lower.length - 2], lower[lower.length - 1], sorted[i]) < 0
) {
lower.pop();
}
lower.push(sorted[i]);
}
const upper = [];
for (let i = n - 1; i >= 0; i--) {
while (
upper.length >= 2 &&
cross(upper[upper.length - 2], upper[upper.length - 1], sorted[i]) < 0
) {
upper.pop();
}
upper.push(sorted[i]);
}
// Concatenate, dropping the last point of each (duplicates).
const hull = lower.slice(0, -1).concat(upper.slice(0, -1));
return hull;
}
/**
* Minimum-area bounding rectangle of a binary mask.
*
* @param {Uint8Array} mask binary mask (1 = kept)
* @param {number} width
* @param {number} height
* @returns {null|{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}}
*/
export function minAreaRect(mask, width, height) {
const pts = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (mask[y * width + x]) {
pts.push({ x, y });
}
}
}
if (pts.length === 0) return null;
if (pts.length === 1)
return {
center: { x: pts[0].x, y: pts[0].y },
width: 1,
height: 1,
angle: 0,
area: 1,
};
return minAreaRectPoints(pts);
}
/**
* Unit vector along the long side of a min-area rectangle.
*
* @param {{width:number, height:number, angle:number}} rect
* @returns {{x:number, y:number}}
*/
export function axisFromRect(rect) {
if (rect.width >= rect.height) {
// Long side is along the rect's angle direction.
return { x: Math.cos(rect.angle), y: Math.sin(rect.angle) };
} else {
// Long side is along the normal (90 degrees CCW from the rect angle).
return { x: -Math.sin(rect.angle), y: Math.cos(rect.angle) };
}
}
/**
* Rotate a unit axis by an angle (radians, CCW positive).
*
* @param {{x:number, y:number}} axis
* @param {number} angle radians
* @returns {{x:number, y:number}}
*/
export function rotateAxis(axis, angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return { x: axis.x * cos - axis.y * sin, y: axis.x * sin + axis.y * cos };
}
/**
* Angle of a unit axis in radians (-PI..PI).
*
* @param {{x:number, y:number}} axis
* @returns {number}
*/
export function axisAngle(axis) {
return Math.atan2(axis.y, axis.x);
}
/**
* Unit vector from an angle in radians.
*
* @param {number} angle radians
* @returns {{x:number, y:number}}
*/
export function axisUnit(angle) {
return { x: Math.cos(angle), y: Math.sin(angle) };
}
// --- axis state machine -----------------------------------------------------------
/**
* Create a fresh axis state from an approved min-area rectangle.
*
* @param {{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}} rect
*/
export function createAxisState(rect) {
return {
rect,
axis: axisFromRect(rect),
approved: false,
};
}
/**
* Pure reducer for the axis state.
* Actions:
* { type: 'rotate', angle } — rotate the axis by `angle` radians (CCW).
* Retracts any prior approval.
* { type: 'approve' } — mark the axis as approved.
*
* @param {object} state
* @param {object} action
* @returns {object} new state
*/
export function reduceAxisState(state, action) {
switch (action.type) {
case 'rotate': {
const angle = action.angle;
if (angle === 0) return state;
// Rotate the RECTANGLE, not a separate axis vector. The rect's angle
// changes, and the axis is re-derived from the rotated rect.
const newRect = { ...state.rect, angle: state.rect.angle + angle };
return { ...state, rect: newRect, axis: axisFromRect(newRect), approved: false };
}
case 'approve': {
return { ...state, approved: true };
}
default:
return state;
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* M1 UAT-3: Browser gate (hard stop).
*
* CAPTURE.md §Browsers:
* Phone: iOS Safari, Android Chrome.
* PC: Chrome, Edge, Firefox.
* Other clients get a hard stop.
*
* Pure logic, DOM-free, unit-testable under node.
*
* Detection: each rule returns a browser name OR null (no match).
* Rules are tried in order; the first non-null wins. Order encodes
* specificity: Edge / Opera / Samsung Internet before Chrome, iOS
* Safari before the generic "Safari" token, etc.
*
* PC: allow Chrome, Edge, Firefox; everything else is a hard stop.
* Phone: allow iOS Safari, Android Chrome; everything else is a hard
* stop.
*/
/**
* @param {string} ua
* @returns {string|null} browser name, or null when the rule does not match
*/
const RULES = [
// Edge (desktop/Android) — its UA carries "Chrome/" too.
(ua) => (/Edg\//.test(ua) ? 'Microsoft Edge' : null),
// Opera — "OPR/" token, distinct from Chrome.
(ua) => (/OPR\//.test(ua) ? 'Opera' : null),
// Samsung Internet — "SamsungBrowser" token.
(ua) => (/SamsungBrowser/.test(ua) ? 'Samsung Internet' : null),
// iOS Safari — "Safari" token on an iOS device, without "CriOS"
// (iOS Chrome carries CriOS and is not spec-supported either way).
// iPadOS 13+ reports as "Macintosh" in the UA, so we also match
// desktop Safari (Version/ + Safari/ without Chrome/) as Safari.
(ua) =>
/Safari/.test(ua) && /iPhone|iPad|iPod/.test(ua) && !/CriOS/.test(ua)
? 'iOS Safari'
: null,
// Android Chrome — "Chrome" token on Android.
(ua) => (/Chrome/.test(ua) && /Android/.test(ua) ? 'Android Chrome' : null),
// Desktop Chrome — "Chrome" token, not on Android.
(ua) => (/Chrome/.test(ua) && !/Android/.test(ua) ? 'Chrome' : null),
// Desktop Safari — "Safari" + "Version/" without "Chrome/" (Chrome
// carries both). iPadOS 13+ reports as Macintosh, so this catches
// iPad Safari too.
(ua) =>
/Safari/.test(ua) && /Version\//.test(ua) && !/Chrome\//.test(ua)
? 'Safari'
: null,
// Firefox.
(ua) => (/Firefox/.test(ua) ? 'Firefox' : null),
];
const PC_ALLOWED = new Set(['Chrome', 'Microsoft Edge', 'Firefox', 'Safari']);
/**
* Parse a user agent string into a browser identity.
* @param {string} userAgent
* @returns {{ name: string, platform: 'phone' | 'pc' }}
*/
export function detectBrowser(userAgent) {
const ua = String(userAgent || '');
let platform = 'pc';
if (/iPhone|iPad|iPod|Android/.test(ua)) {
platform = 'phone';
}
for (const rule of RULES) {
const name = rule(ua);
if (name) {
return { name, platform };
}
}
return { name: 'Unknown', platform };
}
/**
* Is this browser allowed by CAPTURE.md §Browsers?
* @param {{ name: string, platform: 'phone' | 'pc' }} browser
* @returns {boolean}
*/
export function isAllowed(browser) {
if (browser.platform === 'phone') {
return browser.name === 'iOS Safari' || browser.name === 'Android Chrome';
}
return PC_ALLOWED.has(browser.name);
}
/**
* Decide whether to show the hard-stop gate.
* @param {string} userAgent
* @returns {{ supported: boolean, browser: { name: string, platform: 'phone' | 'pc' } }}
*/
export function checkBrowserGate(userAgent) {
const browser = detectBrowser(userAgent);
return { supported: isAllowed(browser), browser };
}
+64
View File
@@ -0,0 +1,64 @@
// M1 deterministic inventory generation — pure functions.
//
// Ground truth: docs/CAPTURE.md §Inventory PNG
// Aspect | 3:4 portrait
// Layout | Slab centered, 80% fill at widest and tallest points
// Size | After crop, shorter side >= 1600px
//
// This module handles the logic for calculating the inventory metadata
// and preparing the instructions for the browser-side PNG encoding.
//
// Note: The actual PNG encoding (canvas/OffscreenCanvas) is handled
// by the frontend component/worker. This file provides the data.
//
/**
* @typedef {Object} InventoryMetadata
* @property {number} sqft
* @property {number} bdft
* @property {number[]} widths
* @property {number} minWidth
* @property {number} maxWidth
* @property {number} avgWidth
* @property {number} lengthIn
* @property {number} thicknessIn
* @property {number} scale
*/
//
/**
* Prepares the metadata for the inventory PNG.
* @param {number} maskPx - Total area of the mask in pixels.
* @param {number} scale - The px-per-inch scale.
* @param {number} lengthIn - User-entered length in inches.
* @param {number} thicknessIn - User-entered thickness in inches.
* @param {number[]} sampledWidthsPx - The raw pixel widths sampled along the axis.
* @returns {InventoryMetadata}
*/
export function prepareInventoryMetadata(
maskPx,
scale,
lengthIn,
thicknessIn,
sampledWidthsPx
) {
// Re-use logic from math.js to ensure consistency
const sqft = (maskPx / (scale * scale)) / 144;
const bdft = sqft * thicknessIn;
// Widths are converted to inches
const widths = sampledWidthsPx.map((w) => w / scale);
const minWidth = Math.min(...widths);
const maxWidth = Math.max(...widths);
const avgWidth = widths.reduce((a, b) => a + b, 0) / widths.length;
return {
sqft: Math.round(sqft * 100) / 100,
bdft: Math.round(bdft * 100) / 100,
widths,
minWidth,
maxWidth,
avgWidth,
lengthIn,
thicknessIn,
scale,
};
}
+154
View File
@@ -0,0 +1,154 @@
// M1-3 mask key geometry — pure functions, no canvas/DOM.
//
// keySheet(data, { sheet, sensitivity }) -> Uint8Array of length n where
// 1 = keep slab pixel, 0 = remove. maskStats(mask) -> { kept, total, empty }.
//
// Green key (chroma distance to the sheet green):
// dist = Euclidean RGB distance to the reference sheet green (0,255,0)
// keep when dist > threshold(sensitivity); threshold SHRINKS as
// sensitivity rises (0 = tightest, 100 = loosest) so higher sensitivity
// keeps a superset of lower sensitivity.
//
// Black key (brightness above a cutoff):
// keep when brightness > cutoff.
//
// Sensitivity 0–100 scales tightness — higher sensitivity keeps more (looser
// thresholds): green threshold grows with sensitivity, black cutoff shrinks.
// Auto picks whichever sheet yields more kept pixels.
/**
* Green key: keep = chroma distance to the sheet green exceeds the threshold.
* Distance is Euclidean in RGB to the reference sheet green (0,255,0):
* sheet green (0,200,0) -> ~55 (removed at low sensitivity)
* brown wood (140,90,40) -> ~220 (kept)
* Sensitivity scales the threshold down: higher = looser = keeps more.
*/
// Reference sheet green: pure keying green. Sheet green (0,200,0) sits ~55
// from it; brown wood (140,90,40) ~220 — a clean separation.
const SHEET_GREEN = { r: 0, g: 255, b: 0 };
function greenDistance(r, g, b) {
const dr = r - SHEET_GREEN.r;
const dg = g - SHEET_GREEN.g;
const db = b - SHEET_GREEN.b;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
/**
* Greenness gate: a pixel must have a meaningfully green channel to be
* considered "on a green sheet." Without this, a dark or neutral background
* (e.g. rgb 26,27,29) is far from sheet-green in RGB distance and would be
* kept by the green key, making the key useless on non-green surfaces.
* A real green sheet has g >> r and g >> b. A dark surface has all channels
* low and roughly equal.
*/
function isGreenish(r, g, b) {
return g > 40 && g > r * 1.3 && g > b * 1.3;
}
/**
* 0–100 sensitivity -> green distance threshold.
* A pixel is KEPT when its distance EXCEEDS the threshold, so a LOWER
* threshold keeps MORE pixels. Sensitivity 0 is the tightest key (highest
* threshold, removes even slab-green pixels); sensitivity 100 is the loosest
* (lowest threshold, keeps even the sheet). Monotone: higher sensitivity
* keeps a superset of lower sensitivity.
* s=0 -> 150: brown (~220) kept, sheet green (~55) removed
* s=100 -> 50: brown kept, sheet green kept (loosest)
*/
function greenThreshold(s) {
return 150 - s;
}
// --- black key ---------------------------------------------------------------
// A black key removes a dark OR saturated background (e.g. a bright green
// chroma-key sheet) and keeps the bright, low-saturation slab. Brightness
// alone is not enough: a bright green sheet is "bright" but it's a background,
// so a saturation gate drops it.
/** Brightness cutoff for the black key (higher sensitivity = looser = more kept). */
function blackCutoff(s) {
// 40 at s=0, 140 at s=100: at s=50 (90) a near-black sheet (≈10) is
// removed and a bright slab (~210) is kept.
return 30 + (s / 100) * 100;
}
/** Saturation gate for the black key: pixels more saturated than this are
* treated as background (e.g. a green keying sheet, saturation ≈ 200) and
* dropped, even if bright. The slab is low-saturation. */
const BLACK_SAT_CAP = 120;
/**
* Key the sheet out of a pixel buffer.
* @param {Uint8ClampedArray|{r,g,b,a}} data RGBA pixels, length n*4.
* @param {{sheet?:'auto'|'green'|'black', sensitivity?:number}} opts
* sensitivity is 0–100 (0 = tightest key, 100 = loosest).
* @returns {Uint8Array} length n, 1=keep slab pixel, 0=remove.
*/
export function keySheet(data, opts = {}) {
const sheet = opts.sheet ?? 'auto';
const sensitivity = Math.max(0, Math.min(100, opts.sensitivity ?? 50));
const n = data.length / 4;
const mask = new Uint8Array(n);
let sheetMode;
if (sheet === 'green' || sheet === 'black') sheetMode = sheet;
else {
// auto: run both keys, prefer the one that removes more (fewer kept)
// as long as it is not empty. A key that keeps everything is useless;
// a key that keeps only the slab is the one we want.
const g = keySheet(data, { sheet: 'green', sensitivity });
const b = keySheet(data, { sheet: 'black', sensitivity });
let gk = 0,
bk = 0;
for (let i = 0; i < n; i++) {
gk += g[i];
bk += b[i];
}
// Both empty: return green (tie-break, no slab found either way).
if (gk === 0 && bk === 0) return g;
// One empty: return the non-empty one.
if (gk === 0) return b;
if (bk === 0) return g;
// Both non-empty: prefer the key that kept FEWER pixels (removed more
// background). On a green sheet, the green key keeps only the slab;
// the black key may keep slab + some sheet. On a dark surface, the
// black key keeps only the slab; the green key (with the greenness
// gate) keeps few/no pixels.
return gk <= bk ? g : b;
}
for (let i = 0; i < n; i++) {
const j = i * 4;
const r = data[j],
g = data[j + 1],
b = data[j + 2];
if (sheetMode === 'green') {
// Green key: keep pixels that are NOT on a green sheet.
// A pixel is "on the green sheet" when it is greenish AND close
// to the reference sheet-green. Pixels that are not greenish
// (dark surfaces, neutral backgrounds) are NOT removed by the
// green key — they are kept, so the black key can handle them.
// This prevents the green key from keeping 100% of a dark
// background (the bug behind the UAT "not detected" report).
if (!isGreenish(r, g, b)) {
mask[i] = 1; // not on a green sheet — keep (black key's job)
} else {
mask[i] = greenDistance(r, g, b) > greenThreshold(sensitivity) ? 1 : 0;
}
} else {
const bright = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const sat = Math.max(r, g, b) - Math.min(r, g, b);
mask[i] =
bright > blackCutoff(sensitivity) && sat < BLACK_SAT_CAP ? 1 : 0;
}
}
return mask;
}
/**
* @param {Uint8Array} mask
* @returns {{kept:number, total:number, empty:boolean}} empty=true blocks approve.
*/
export function maskStats(mask) {
let kept = 0;
for (let i = 0; i < mask.length; i++) kept += mask[i];
return { kept, total: mask.length, empty: kept === 0 };
}
+235
View File
@@ -0,0 +1,235 @@
// M1-3 mask state — pure state machine + edge/feather geometry, no DOM.
//
// groundTruth: docs/CAPTURE.md §Mask
// - Sheet: Auto / Green / Black
// - Sensitivity: 0–100 step 1 (slider + minus/plus)
// - Control value updates immediately; overlay recomputes 2.0 s after the
// LAST sensitivity change (debounce). A sheet change may recompute on
// the change.
// - Approve is blocked when the mask has no slab pixels.
// - Edge offset + feather are the M1-3 card's extra knobs; both default 0
// (no-op) and are applied after the key pass, before stats.
//
// The state machine is pure and reducer-based so it is testable in Node
// (vitest) without canvas/DOM. The live UI (M1-5) drives it with the
// existing pure modules src/lib/core/mask.js (keySheet, maskStats).
//
// Edge offset (px): erode (positive) / dilate (negative) the binary mask.
// Feather (px): attenuate kept pixels linearly toward 0 within `feather` px
// of the mask edge. Both are applied to the raw key output and returned as
// a Uint8Array mask — same contract as keySheet.
import { keySheet, maskStats } from './mask.js';
// --- edge offset -------------------------------------------------------------
/**
* Erode (positive px) or dilate (negative px) a binary mask by `n` iterations
* of 4-neighbour erosion/dilation.
* @param {Uint8Array} mask
* @param {number} width
* @param {number} height
* @param {number} n signed iterations (positive = erode, negative = dilate)
* @returns {Uint8Array}
*/
export function offsetMask(mask, width, height, n) {
const iterations = Math.abs(Math.round(n));
const out = new Uint8Array(mask);
for (let it = 0; it < iterations; it++) {
const next = new Uint8Array(out.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
if (n > 0) {
// Erode: keep only if all 4-neighbours are kept.
// Out-of-bounds counts as removed (not kept).
let all = out[i] === 1;
if (all && (x === 0 || out[i - 1] === 0)) all = false;
if (all && (x === width - 1 || out[i + 1] === 0)) all = false;
if (all && (y === 0 || out[i - width] === 0)) all = false;
if (all && (y === height - 1 || out[i + width] === 0)) all = false;
next[i] = all ? 1 : 0;
} else {
// Dilate: keep if any 4-neighbour is kept.
let any = out[i] === 1;
if (!any && x > 0 && out[i - 1] === 1) any = true;
if (!any && x < width - 1 && out[i + 1] === 1) any = true;
if (!any && y > 0 && out[i - width] === 1) any = true;
if (!any && y < height - 1 && out[i + width] === 1) any = true;
next[i] = any ? 1 : 0;
}
}
}
out.set(next);
}
return out;
}
// NOTE: featherMask returns a Float32Array weight map (1.0 = fully kept,
// 0.0 = fully removed) for the UI overlay; the binary approve decision
// still comes from the un-feathered mask's maskStats.
/**
* Feather: weight map of a binary mask — kept pixels within `radius` px of a
* removed pixel are attenuated linearly (1 at the edge, →0 at full radius).
* @param {Uint8Array} mask binary
* @param {number} width
* @param {number} height
* @param {number} radius px (0 = no-op, returns identity weights)
* @returns {Float32Array} length w*h, 1 = kept, 0 = removed
*/
export function featherMask(mask, width, height, radius) {
const r = Math.round(radius);
const out = new Float32Array(width * height);
for (let i = 0; i < mask.length; i++) out[i] = mask[i] ? 1 : 0;
if (r <= 0) return out;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
if (out[i] !== 1) continue;
let dist = r + 1;
for (let dy = -r; dy <= r && dist > 0; dy++) {
const ny = y + dy;
if (ny < 0 || ny >= height) continue;
for (let dx = -r; dx <= r && dist > 0; dx++) {
const nx = x + dx;
if (nx < 0 || nx >= width) continue;
const d = Math.max(Math.abs(dx), Math.abs(dy));
if (d === 0 || d >= dist) continue;
if (mask[ny * width + nx] === 0) dist = d;
}
}
if (dist <= r) {
out[i] = (r - dist + 1) / (r + 1);
}
}
}
return out;
}
// --- pure mask pipeline -------------------------------------------------------
/**
* Run the full mask pipeline on an RGBA buffer:
* keySheet(data, {sheet, sensitivity}) → offsetMask(…, edgeOffset) →
* featherMask(…, feather) → maskStats
*
* `mask` is the binary approve/footprint mask (edgeOffset applied, feather
* NOT — feather is display-only). `weights` is the display weight map
* (feather applied to the binary mask). Stats are computed from `mask` so
* feather never affects the approve decision.
* @param {Uint8ClampedArray} data RGBA pixels
* @param {{width:number, height:number, sheet?:string, sensitivity?:number,
* edgeOffset?:number, feather?:number}} opts
* @returns {{mask:Uint8Array, weights:Float32Array, stats:{kept:number,total:number,empty:boolean}}}
*/
export function computeMask(data, opts) {
const width = opts.width;
const height = opts.height;
const sheet = opts.sheet ?? 'auto';
const sensitivity = Math.max(0, Math.min(100, opts.sensitivity ?? 50));
const edgeOffset = opts.edgeOffset ?? 0;
const feather = opts.feather ?? 0;
let mask = keySheet(data, { sheet, sensitivity });
if (edgeOffset !== 0) mask = offsetMask(mask, width, height, edgeOffset);
const weights = featherMask(mask, width, height, feather);
return { mask, weights, stats: maskStats(mask) };
}
// --- mask state machine --------------------------------------------------------
// Debounce window per docs/CAPTURE.md §Mask: 2.0 s after the last
// sensitivity change. Exported so tests can assert the constant.
export const RECOMPUTE_DELAY_MS = 2000;
/**
* Create a fresh mask state.
* @param {{width:number, height:number}} dims source photo dimensions in px
*/
export function createMaskState(dims) {
return {
dims,
sheet: 'auto',
sensitivity: 50,
edgeOffset: 0,
feather: 0,
approved: false,
lastRecomputeAt: 0,
pendingRecompute: false,
stats: { kept: 0, total: dims.width * dims.height, empty: true },
};
}
/**
* Pure reducer for the mask state.
* Actions:
* { type: 'set_sheet', sheet } — may mark pending recompute
* { type: 'set_sensitivity', value } — always marks pending recompute
* { type: 'nudge_sensitivity', delta } — same as set_sensitivity
* { type: 'set_edge_offset', value } — may mark pending recompute
* { type: 'set_feather', value } — may mark pending recompute
* { type: 'recompute', stats, at } — apply a finished recompute
* { type: 'approve' } — blocked if stats.empty
*
* Control values update immediately; the OVERLAY recomputes on the 2.0 s
* debounce (UI owns the timer and dispatches 'recompute' with fresh stats).
* @param {object} state
* @param {object} action
* @returns {object} new state
*/
export function reduceMaskState(state, action) {
switch (action.type) {
case 'set_sheet': {
const sheet = action.sheet;
if (sheet === state.sheet) return state;
return { ...state, sheet, pendingRecompute: true };
}
case 'set_sensitivity': {
const sensitivity = Math.max(0, Math.min(100, Math.round(action.value)));
if (sensitivity === state.sensitivity) return state;
return { ...state, sensitivity, pendingRecompute: true };
}
case 'nudge_sensitivity': {
const sensitivity = Math.max(
0,
Math.min(100, state.sensitivity + Math.round(action.delta))
);
if (sensitivity === state.sensitivity) return state;
return { ...state, sensitivity, pendingRecompute: true };
}
case 'set_edge_offset': {
const edgeOffset = Math.round(action.value);
if (edgeOffset === state.edgeOffset) return state;
return { ...state, edgeOffset, pendingRecompute: true };
}
case 'set_feather': {
const feather = Math.max(0, Math.round(action.value));
if (feather === state.feather) return state;
return { ...state, feather, pendingRecompute: true };
}
case 'recompute': {
return {
...state,
stats: action.stats,
lastRecomputeAt: action.at,
pendingRecompute: false,
};
}
case 'approve': {
if (state.stats.empty) return state; // blocked: no slab pixels
return { ...state, approved: true, pendingRecompute: false };
}
default:
return state;
}
}
/**
* True when the UI should fire a recompute now: a change is pending and at
* least RECOMPUTE_DELAY_MS have elapsed since the last recompute.
* @param {object} state
* @param {number} now ms
*/
export function dueForRecompute(state, now) {
return state.pendingRecompute && now - state.lastRecomputeAt >= RECOMPUTE_DELAY_MS;
}
+73
View File
@@ -0,0 +1,73 @@
// M1 deterministic measurement math — pure functions, no DOM.
//
// Ground truth: docs/CAPTURE.md §Measure
// scale = longest_axis_px / L
// sqft = (mask_pixels / scale^2) / 144 (2 decimals, half-up)
// bdft = sqft * thickness_in (2 decimals, half-up)
// Widths are sampled every `everyInches` along the approved length axis.
/** px-per-inch scale: longest axis in pixels divided by its length in inches. */
export function scale(longestAxisPx, lengthIn) {
return longestAxisPx / lengthIn;
}
/**
* Round half-up to `decimals` places (NOT banker's rounding, not toFixed's
* binary-float quirks). 30.625 -> 30.63, 1.005 -> 1.01.
*/
export function roundHalfUp(x, decimals) {
const f = Math.pow(10, decimals);
return Math.round((x + Number.EPSILON) * f) / f;
}
/**
* Square feet from mask pixel area and the px-per-inch scale.
* (mask_px / scale^2) gives square inches; /144 gives square feet.
* @returns number rounded half-up to 2 decimals.
*/
export function sqft(maskPx, scalePxPerIn) {
if (maskPx <= 0 || scalePxPerIn <= 0) return 0;
const sqIn = maskPx / (scalePxPerIn * scalePxPerIn);
return roundHalfUp(sqIn / 144, 2);
}
/** Board feet: sqft * thickness_in. 1 sqft at 1in thick = 1 bdft. */
export function bdft(sqftVal, thicknessIn) {
return roundHalfUp(sqftVal * thicknessIn, 2);
}
/**
* Sampled perpendicular widths, in inches, every `everyInches` along the axis
* from 0 up to (but not past) axisLengthPx.
* @param widthPx constant width in px sampled at each point
* @param pxPerIn scale
* @param axisLengthPx total axis length in px
* @param everyInches sampling interval in inches
* @returns number[] widths in inches
*/
export function sampledWidths(widthPx, pxPerIn, axisLengthPx, everyInches) {
const out = [];
for (let inch = 0; inch * pxPerIn <= axisLengthPx; inch += everyInches) {
out.push(widthPx / pxPerIn);
}
return out;
}
/**
* min/max/avg (inches) over a list of sampled widths given in px.
* @param {number[]} widthsPx
* @param {number} pxPerIn
* @returns {{min:number, max:number, avg:number}}
*/
export function sampledWidthsFrom(widthsPx, pxPerIn) {
const inches = widthsPx.map((w) => w / pxPerIn);
const min = Math.min(...inches);
const max = Math.max(...inches);
const avg = inches.reduce((a, b) => a + b, 0) / inches.length;
return { min, max, avg };
}
/** Snap an inch value to the nearest 1/8 inch. */
export function snapToEighth(inches) {
return Math.round(inches * 8) / 8;
}
+69
View File
@@ -0,0 +1,69 @@
// M1-5 Measure — mask width sampling along the approved length axis.
//
// Ground truth: docs/CAPTURE.md §Measure
// - scale = longest_axis_px / L
// - Widths: min, max, avg, sampled every 6 in along the approved length
// axis, perpendicular to it
//
// sampleWidthsPx is the real scanline sampler the UI runs on the approved
// mask: at each sample point t = 0, 6, 12, ... in (px) along the axis it
// scans the perpendicular direction across the whole photo and measures the
// contiguous run of kept pixels that covers the sample point.
/**
* Sample perpendicular mask widths along a length axis.
*
* @param {Uint8Array} mask binary mask (1 = kept), row-major
* @param {number} width photo width in px
* @param {number} height photo height in px
* @param {{origin:{x:number,y:number}, dir:{x:number,y:number}, lengthPx:number}} axis
* origin = a point on the axis (e.g. the rectangle corner the long side
* starts at), dir = unit vector along the slab length, lengthPx = its
* pixel length.
* @param {number} pxPerIn scale
* @param {number} [everyIn=6] sampling interval in inches
* @returns {number[]} widths in px, one per sample (t = 0, everyIn, ... in px
* at or under lengthPx)
*/
export function sampleWidthsPx(mask, width, height, axis, pxPerIn, everyIn = 6) {
const stepPx = everyIn * pxPerIn;
const ux = axis.dir.x;
const uy = axis.dir.y;
// Perpendicular unit vector (left normal): (-uy, ux).
const perpX = -uy;
const perpY = ux;
const out = [];
for (let t = 0; t <= axis.lengthPx; t += stepPx) {
// Sample point.
const sx = axis.origin.x + ux * t;
const sy = axis.origin.y + uy * t;
// Find the contiguous kept run along the perpendicular line that
// covers (sx, sy), walking out from the sample point in both
// perpendicular directions.
const rx = Math.round(sx);
const ry = Math.round(sy);
if (rx < 0 || ry < 0 || rx >= width || ry >= height || !mask[ry * width + rx]) {
out.push(0);
continue;
}
let a = 0; // extent in the negative perpendicular direction
let b = 0; // extent in the positive perpendicular direction
for (;;) {
const nx = rx - Math.round(perpX * (a + 1));
const ny = ry - Math.round(perpY * (a + 1));
if (nx < 0 || ny < 0 || nx >= width || ny >= height) break;
if (!mask[ny * width + nx]) break;
a += 1;
}
for (;;) {
const nx = rx + Math.round(perpX * (b + 1));
const ny = ry + Math.round(perpY * (b + 1));
if (nx < 0 || ny < 0 || nx >= width || ny >= height) break;
if (!mask[ny * width + nx]) break;
b += 1;
}
out.push(a + b + 1);
}
return out;
}
+72
View File
@@ -0,0 +1,72 @@
<script>
import '../app.css';
import { browser } from '$app/environment';
import { checkBrowserGate } from '$lib/core/browserGate.js';
// UAT-3: hard-stop browser gate (CAPTURE.md §Browsers).
// Client-side only: the gate must be invisible to crawlers/bots
// and to any non-JS context, and the app must not render in an
// unsupported browser.
const gate = browser ? checkBrowserGate(navigator.userAgent) : null;
let unsupported = $state(gate ? !gate.supported : false);
let browserName = $state(gate ? gate.browser.name : 'this browser');
</script>
<svelte:head>
<title>SlabUploader — Milestone 1</title>
</svelte:head>
{#if unsupported}
<!-- Hard stop: the app does NOT render in an unsupported browser. -->
<div class="gate">
<h1>Unsupported browser</h1>
<p>SlabUploader does not work in <strong>{browserName}</strong>.</p>
<p class="sub">Use one of the supported clients:</p>
<ul>
<li>Phone — iOS Safari, Android Chrome</li>
<li>PC — Chrome, Edge, Firefox</li>
</ul>
</div>
{:else}
<main>
<h1>SlabUploader</h1>
<p>Milestone 1 — Capture (browser-only)</p>
<slot />
</main>
{/if}
<style>
.gate {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
max-width: 32rem;
margin: 1rem auto;
padding: 2rem;
border: 2px solid #b00020;
border-radius: 0.5rem;
text-align: center;
}
.gate h1 {
color: #b00020;
margin: 0 0 1rem;
}
.gate .sub {
margin: 1rem 0 0.5rem;
}
.gate ul {
text-align: left;
margin: 0;
padding: 0;
list-style: none;
}
.gate li {
margin: 0.25rem 0;
}
</style>
+593
View File
@@ -0,0 +1,593 @@
<script>
/**
* M1-5: Full capture flow — Add photo → Mask → Length axis → Measure.
* Drives the pure core modules (mask.js, maskState.js, axis.js, measure.js,
* math.js, inventory.js) with canvas overlays.
*/
import { keySheet, maskStats } from '$lib/core/mask.js';
import {
createMaskState,
reduceMaskState,
computeMask,
RECOMPUTE_DELAY_MS
} from '$lib/core/maskState.js';
import {
minAreaRect,
axisFromRect,
createAxisState,
reduceAxisState,
rotateAxis
} from '$lib/core/axis.js';
import { sampleWidthsPx } from '$lib/core/measure.js';
import { scale as calcScale, sqft, bdft, sampledWidthsFrom } from '$lib/core/math.js';
import { prepareInventoryMetadata } from '$lib/core/inventory.js';
// --- state ----------------------------------------------------------
let state = $state('add_photo'); // add_photo | mask | axis | measure
let photo = $state(null); // ImageBitmap
let photoData = $state(null); // Uint8ClampedArray RGBA
let photoW = $state(0);
let photoH = $state(0);
let maskState = $state(null);
let maskResult = $state(null); // {mask, weights, stats}
let axisState = $state(null);
let rect = $state(null);
let heicNotice = $state(false);
let busy = $state(false);
let debounceTimer = $state(null);
// measure
let lengthIn = $state(48);
let thicknessIn = $state(1);
let result = $state(null);
let pngUrl = $state(null);
// canvas refs
let overlayCanvas = $state(null);
let photoCanvas = $state(null);
// --- photo input ----------------------------------------------------
async function onFileChange(e) {
const file = e.target.files?.[0];
if (!file) return;
heicNotice = false;
const isHeic =
file.type === 'image/heic' || file.type === 'image/heif' ||
file.name.toLowerCase().match(/\.(heic|heif)$/);
let bitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
// Browser cannot decode (HEIC on Chrome/FF)
if (isHeic) {
heicNotice = true;
} else {
heicNotice = true; // undecodable file of any type
}
return;
}
photo = bitmap;
photoW = bitmap.width;
photoH = bitmap.height;
// Draw to canvas to get RGBA data
const c = document.createElement('canvas');
c.width = photoW;
c.height = photoH;
const ctx = c.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
photoData = ctx.getImageData(0, 0, photoW, photoH).data;
// Initialize mask state
maskState = createMaskState({ width: photoW, height: photoH });
maskResult = null;
state = 'mask';
// Trigger initial recompute
setTimeout(() => doRecompute(), 100);
}
function dismissHeic() {
heicNotice = false;
}
// --- mask recompute --------------------------------------------------
function doRecompute() {
if (!photoData || !maskState) return;
busy = true;
// Run in a microtask to let UI paint
setTimeout(() => {
const { mask, weights, stats } = computeMask(photoData, {
width: photoW,
height: photoH,
sheet: maskState.sheet,
sensitivity: maskState.sensitivity,
edgeOffset: maskState.edgeOffset,
feather: maskState.feather
});
maskResult = { mask, weights, stats };
maskState = reduceMaskState(maskState, { type: 'recompute', stats, at: Date.now() });
busy = false;
drawOverlay();
}, 30);
}
function scheduleRecompute() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doRecompute(), RECOMPUTE_DELAY_MS);
}
function onSheetChange(sheet) {
maskState = reduceMaskState(maskState, { type: 'set_sheet', sheet });
// Sheet change may recompute immediately
doRecompute();
}
function onSensitivityChange(val) {
maskState = reduceMaskState(maskState, { type: 'set_sensitivity', value: Number(val) });
scheduleRecompute();
}
function nudgeSensitivity(delta) {
maskState = reduceMaskState(maskState, { type: 'nudge_sensitivity', delta });
scheduleRecompute();
}
function approveMask() {
if (!maskState || maskState.stats.empty) return;
maskState = reduceMaskState(maskState, { type: 'approve' });
// Compute bounding rect + axis
rect = minAreaRect(maskResult.mask, photoW, photoH);
if (rect) {
axisState = createAxisState(rect);
}
state = 'axis';
}
// --- axis ------------------------------------------------------------
function rotateAxisBy(deg) {
if (!axisState) return;
const rad = (deg * Math.PI) / 180;
axisState = reduceAxisState(axisState, { type: 'rotate', angle: rad });
}
function onRotateSlider(deg) {
if (!axisState) return;
const currentDeg = axisState.rect.angle * 180 / Math.PI;
const deltaDeg = deg - currentDeg;
if (deltaDeg === 0) return;
const rad = (deltaDeg * Math.PI) / 180;
axisState = reduceAxisState(axisState, { type: 'rotate', angle: rad });
}
function approveAxis() {
if (!axisState) return;
axisState = reduceAxisState(axisState, { type: 'approve' });
state = 'measure';
}
// --- measure ---------------------------------------------------------
function calculate() {
if (!rect || !axisState || !maskResult) return;
const axisLenPx = rect.width >= rect.height ? rect.width : rect.height;
const pxPerIn = calcScale(axisLenPx, lengthIn);
const axis = axisState.axis;
const origin = { x: rect.center.x - axis.x * axisLenPx / 2, y: rect.center.y - axis.y * axisLenPx / 2 };
const axisObj = { origin, dir: axis, lengthPx: axisLenPx };
const widthsPx = sampleWidthsPx(maskResult.mask, photoW, photoH, axisObj, pxPerIn);
const { min, max, avg } = sampledWidthsFrom(widthsPx, pxPerIn);
const sqftVal = sqft(maskResult.stats.kept, pxPerIn);
const bdftVal = bdft(sqftVal, thicknessIn);
const meta = prepareInventoryMetadata(
maskResult.stats.kept, pxPerIn, lengthIn, thicknessIn, widthsPx
);
result = { ...meta, sqft: sqftVal, bdft: bdftVal, min, max, avg };
// Build inventory PNG: slab centered on transparent bg, 3:4 portrait, 80% fill
generatePng();
}
function generatePng() {
const c = document.createElement('canvas');
// 3:4 portrait, shorter side >= 1600 if source allows
const srcMin = Math.min(photoW, photoH);
const shorter = Math.max(1600, Math.min(srcMin, 1600));
const longer = Math.round((shorter * 4) / 3);
c.width = shorter;
c.height = longer;
const ctx = c.getContext('2d');
ctx.clearRect(0, 0, c.width, c.height);
// Scale slab to 80% fill
const slabW = photoW;
const slabH = photoH;
const scaleW = (c.width * 0.8) / slabW;
const scaleH = (c.height * 0.8) / slabH;
const s = Math.min(scaleW, scaleH);
const dw = slabW * s;
const dh = slabH * s;
const dx = (c.width - dw) / 2;
const dy = (c.height - dh) / 2;
// Draw photo, then apply mask as alpha
ctx.drawImage(photo, dx, dy, dw, dh);
const imgData = ctx.getImageData(dx, dy, dw, dh);
// Scale mask to dw x dh
const maskData = maskResult.mask;
for (let y = 0; y < dh; y++) {
for (let x = 0; x < dw; x++) {
const sx = Math.floor((x / dw) * photoW);
const sy = Math.floor((y / dh) * photoH);
const mIdx = sy * photoW + sx;
const a = maskData[mIdx] ? 255 : 0;
const i = (y * dw + x) * 4;
imgData.data[i + 3] = a;
}
}
ctx.putImageData(imgData, dx, dy);
c.toBlob((blob) => {
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(blob);
}, 'image/png');
}
// --- overlay drawing -------------------------------------------------
function drawOverlay() {
if (!overlayCanvas || !photo) return;
const canvas = overlayCanvas;
const w = canvas.width;
const h = canvas.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
// Draw photo
ctx.drawImage(photo, 0, 0, w, h);
if ((state === 'mask' || state === 'axis' || state === 'measure') && maskResult) {
// Checkerboard on removed pixels
const weights = maskResult.weights;
const mask = maskResult.mask;
const sx = w / photoW;
const sy = h / photoH;
// Draw checkerboard pattern for removed areas
const checker = 8;
for (let y = 0; y < h; y += checker) {
for (let x = 0; x < w; x += checker) {
const px = Math.floor((x / w) * photoW);
const py = Math.floor((y / h) * photoH);
const idx = py * photoW + px;
if (!mask[idx]) {
const shade = ((Math.floor(x / checker) + Math.floor(y / checker)) % 2 === 0)
? 'rgba(200,200,200,0.6)'
: 'rgba(220,220,220,0.6)';
ctx.fillStyle = shade;
ctx.fillRect(x, y, checker, checker);
}
}
}
}
if ((state === 'axis' || state === 'measure') && axisState) {
// Draw bounding rectangle — uses the ROTATED rect from axisState.
// The rect's long side IS the length axis (no separate line).
const r = axisState.rect;
const cx = (r.center.x / photoW) * w;
const cy = (r.center.y / photoH) * h;
const rw = (r.width / photoW) * w;
const rh = (r.height / photoH) * h;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(r.angle);
ctx.strokeStyle = '#00ff88';
ctx.lineWidth = 2;
ctx.strokeRect(-rw / 2, -rh / 2, rw, rh);
ctx.restore();
}
}
// --- back navigation -------------------------------------------------
function goBack() {
if (state === 'measure') state = 'axis';
else if (state === 'axis') state = 'mask';
else if (state === 'mask') state = 'add_photo';
}
function reset() {
if (photo) photo.close();
photo = null;
photoData = null;
maskState = null;
maskResult = null;
axisState = null;
rect = null;
result = null;
if (pngUrl) { URL.revokeObjectURL(pngUrl); pngUrl = null; }
state = 'add_photo';
}
// Resize overlay canvas when photo changes
$effect(() => {
if (photoW && photoH && overlayCanvas) {
overlayCanvas.width = photoW;
overlayCanvas.height = photoH;
drawOverlay();
}
});
// Redraw when state or mask changes
$effect(() => {
drawOverlay();
});
</script>
<div class="capture">
<h1>SlabUploader</h1>
{#if heicNotice}
<div class="notice" role="alert">
<p>Cannot decode this photo. Please retake as JPEG.</p>
<button onclick={dismissHeic}>OK</button>
</div>
{/if}
{#if state === 'add_photo'}
<section class="step add-photo">
<h2>Add Photo</h2>
<p class="setup-rules">
Photograph the slab on a green or black sheet.<br>
Sheet fills the frame. Slab fully on sheet.<br>
Top-down, lens parallel to the face.<br>
Length aligned with the long side of the picture.
</p>
<input type="file" accept="image/jpeg,image/png" onchange={onFileChange} />
</section>
{:else}
<div class="preview-wrap">
<canvas bind:this={overlayCanvas} class="preview"></canvas>
{#if busy}<div class="busy">Processing…</div>{/if}
</div>
{#if state === 'mask' && maskState}
<section class="step mask-step">
<h2>Mask</h2>
<div class="controls">
<div class="control-row">
<label>Sheet</label>
<div class="seg">
<button class:active={maskState.sheet === 'auto'} onclick={() => onSheetChange('auto')}>Auto</button>
<button class:active={maskState.sheet === 'green'} onclick={() => onSheetChange('green')}>Green</button>
<button class:active={maskState.sheet === 'black'} onclick={() => onSheetChange('black')}>Black</button>
</div>
</div>
<div class="control-row">
<label>Sensitivity</label>
<button class="nudge" onclick={() => nudgeSensitivity(-5)}>−</button>
<input type="range" min="0" max="100" step="1" value={maskState.sensitivity} oninput={onSensitivityChange} />
<span class="val">{maskState.sensitivity}</span>
<button class="nudge" onclick={() => nudgeSensitivity(5)}>+</button>
</div>
</div>
<div class="actions">
<button class="back" onclick={goBack}>Back</button>
<button class="approve" disabled={maskState.stats.empty} onclick={approveMask}>
Approve Mask
</button>
</div>
{#if maskState.stats.empty}
<p class="warn">No slab detected — adjust sensitivity or retake.</p>
{/if}
</section>
{/if}
{#if state === 'axis' && axisState}
<section class="step axis-step">
<h2>Length Axis</h2>
<p>Rotate the axis to follow the slab length, then approve.</p>
<div class="controls">
<div class="control-row">
<label>Rotate</label>
<button class="nudge" onclick={() => rotateAxisBy(-5)}>−</button>
<input type="range" min="-180" max="180" step="1"
value={Math.round(axisState.rect.angle * 180 / Math.PI)}
oninput={(e) => onRotateSlider(Number(e.target.value))}
aria-label="Rotate axis" />
<span class="val">{Math.round(axisState.rect.angle * 180 / Math.PI)}°</span>
<button class="nudge" onclick={() => rotateAxisBy(5)}>+</button>
</div>
</div>
<div class="actions">
<button class="back" onclick={goBack}>Back</button>
<button class="approve" onclick={approveAxis}>Approve Axis</button>
</div>
</section>
{/if}
{#if state === 'measure'}
<section class="step measure-step">
<h2>Measure</h2>
<div class="controls">
<div class="control-row">
<label>Length (in)</label>
<input type="number" min="1" step="0.125" value={lengthIn} oninput={(e) => lengthIn = parseFloat(e.target.value) || 0} />
</div>
<div class="control-row">
<label>Thickness (in)</label>
<input type="number" min="0.125" step="0.125" value={thicknessIn} oninput={(e) => thicknessIn = parseFloat(e.target.value) || 0} />
</div>
</div>
<div class="actions">
<button class="back" onclick={goBack}>Back</button>
<button class="approve" onclick={calculate}>Calculate</button>
</div>
{#if result}
<div class="results">
<h3>Results</h3>
<dl>
<dt>Width (min)</dt><dd>{result.min?.toFixed(2)} in</dd>
<dt>Width (max)</dt><dd>{result.max?.toFixed(2)} in</dd>
<dt>Width (avg)</dt><dd>{result.avg?.toFixed(2)} in</dd>
<dt>Length</dt><dd>{result.lengthIn} in</dd>
<dt>Thickness</dt><dd>{result.thicknessIn} in</dd>
<dt>Scale</dt><dd>{result.scale?.toFixed(2)} px/in</dd>
<dt>Sq Ft</dt><dd>{result.sqft} ft²</dd>
<dt>Board Feet</dt><dd>{result.bdft} bdft</dd>
</dl>
{#if pngUrl}
<h3>Inventory PNG</h3>
<img src={pngUrl} alt="Inventory PNG" class="png-preview" />
{/if}
<button class="reset" onclick={reset}>Start New Slab</button>
</div>
{/if}
</section>
{/if}
{/if}
</div>
<style>
.capture {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
font-family: system-ui, sans-serif;
}
h1 { text-align: center; font-size: 1.4rem; margin-bottom: 1rem; }
h2 { font-size: 1.1rem; margin-bottom: 0.5rem; }
.preview-wrap {
position: relative;
width: 100%;
max-height: 50vh;
overflow: hidden;
border-radius: 8px;
border: 1px solid #ddd;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
}
.preview {
max-width: 100%;
max-height: 50vh;
width: auto;
height: auto;
display: block;
object-fit: contain;
}
.busy {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.6);
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8rem;
}
.step { margin-top: 1rem; }
.setup-rules { font-size: 0.9rem; color: #555; margin-bottom: 1rem; line-height: 1.5; }
.controls { display: flex; flex-direction: column; gap: 0.75rem; margin: 0.5rem 0; }
.control-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.control-row label { min-width: 100px; font-weight: 600; font-size: 0.9rem; }
.control-row input[type="range"] { flex: 1; min-width: 120px; }
.control-row input[type="number"] { width: 80px; padding: 6px; }
.val { min-width: 40px; text-align: center; font-size: 0.9rem; }
.seg { display: flex; gap: 0; }
.seg button {
padding: 8px 16px;
border: 1px solid #ccc;
background: #fff;
cursor: pointer;
font-size: 0.9rem;
}
.seg button:first-child { border-radius: 6px 0 0 6px; }
.seg button:last-child { border-radius: 0 6px 6px 0; }
.seg button.active { background: #333; color: #fff; border-color: #333; }
.nudge {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid #ccc;
background: #fff;
font-size: 1rem;
cursor: pointer;
}
.nudge:active { background: #eee; }
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.actions button {
padding: 12px 24px;
border-radius: 8px;
border: none;
font-size: 1rem;
cursor: pointer;
}
.back { background: #eee; }
.approve { background: #22c55e; color: #fff; }
.approve:disabled { background: #aaa; cursor: not-allowed; }
.warn { color: #dc2626; font-size: 0.9rem; margin-top: 0.5rem; }
.notice {
background: #fef3cd;
border: 1px solid #ffc107;
padding: 1rem;
border-radius: 8px;
margin: 1rem 0;
}
.notice button {
margin-top: 0.5rem;
padding: 8px 16px;
border: none;
border-radius: 6px;
background: #333;
color: #fff;
}
.results { margin-top: 1.5rem; }
.results h3 { font-size: 1rem; margin-top: 1rem; }
.results dl {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem 1rem;
}
.results dt { font-weight: 600; font-size: 0.9rem; }
.results dd { font-size: 0.9rem; color: #333; }
.png-preview {
max-width: 100%;
max-height: 400px;
border: 1px solid #ddd;
border-radius: 8px;
margin-top: 0.5rem;
background: repeating-conic-gradient(#f0f0f0 0% 25%, #fff 0% 50%) 0 0 / 20px 20px;
}
.reset {
margin-top: 1rem;
padding: 12px 24px;
border-radius: 8px;
border: none;
background: #6366f1;
color: #fff;
font-size: 1rem;
cursor: pointer;
}
</style>
@@ -0,0 +1,6 @@
// Minimal health route so the UAT container has a non-SPA probe target.
import { json } from '@sveltejs/kit';
export function GET() {
return json({ ok: true, milestone: 1, app: 'slab-uploader' });
}
+10
View File
@@ -0,0 +1,10 @@
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
// M1-4: Playwright tests for the Length axis state.
// RED first — the live UI lands in M1-5. These tests describe the
// contract the UI must honour once the Mask state approves.
//
// Spec ground truth (docs/CAPTURE.md §Length axis):
// - min-area bounding rectangle of the mask
// - length axis on the long side of that rectangle
// - user rotates the axis if it does not follow the slab length
// - approve the axis
const SLAB_FIXTURE = 'tests/fixtures/slab-green.jpg';
test.describe('Length axis state (M1-5 live UI)', () => {
test('advances to Length axis after approving the mask', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
// Wait for the initial mask recompute.
await page.waitForTimeout(2200);
// Approve the mask.
await page.getByRole('button', { name: /approve/i }).tap();
// The Length axis state is now visible.
await expect(page.getByText(/length axis/i)).toBeVisible();
});
test('shows the bounding rectangle and the proposed length axis', async ({
page,
}) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
await page.waitForTimeout(2200);
await page.getByRole('button', { name: /approve/i }).tap();
// The bounding rectangle is drawn on the photo.
await expect(
page.locator('[data-testid="bounding-rect"]'),
).toBeVisible();
// The proposed length axis is drawn along the long side.
await expect(
page.locator('[data-testid="length-axis"]'),
).toBeVisible();
});
test('rotate control adjusts the axis', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
await page.waitForTimeout(2200);
await page.getByRole('button', { name: /approve/i }).tap();
// The rotate control is present.
const rotate = page.getByRole('slider', { name: /rotate axis/i });
await expect(rotate).toBeVisible();
// Rotating changes the axis angle.
const before = await page
.locator('[data-testid="length-axis"]')
.getAttribute('data-angle');
await rotate.fill('90');
const after = await page
.locator('[data-testid="length-axis"]')
.getAttribute('data-angle');
expect(before).not.toBe(after);
});
test('approve the axis advances to Measure', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
await page.waitForTimeout(2200);
// Approve the mask -> Length axis.
await page.getByRole('button', { name: /approve/i }).tap();
// Approve the axis -> Measure.
const approveAxis = page.getByRole('button', {
name: /approve axis/i,
});
await expect(approveAxis).toBeVisible();
await approveAxis.tap();
await expect(page.getByText(/measure/i)).toBeVisible();
});
});
+109
View File
@@ -0,0 +1,109 @@
import { test, expect, devices } from '@playwright/test';
// UAT-3: Browser gate (hard stop) — e2e.
// Spec (CAPTURE.md §Browsers):
// Phone: iOS Safari, Android Chrome.
// PC: Chrome, Edge, Firefox.
// Other clients get a hard stop.
//
// Acceptance: opening in an unsupported browser (e.g. Opera, Samsung
// Internet) shows a hard-stop message, NOT the app.
//
// The app itself must not render (no <slot> content, no "Milestone 1
// — Capture" text) in an unsupported browser; the hard-stop page lists
// the spec browsers instead.
const DESKTOP_CHROME_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
const DESKTOP_OPERA_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0';
const DESKTOP_EDGE_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0';
const DESKTOP_FIREFOX_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0';
const IOS_SAFARI_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/605.1.15';
const ANDROID_CHROME_UA =
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6331.115 Mobile Safari/537.36';
const SAMSUNG_INTERNET_UA =
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36';
async function openApp(page, ua) {
await page.setExtraHTTPHeaders({ 'User-Agent': ua });
await page.goto('/');
// The gate runs on client hydration; wait a beat for the DOM to
// settle on whichever branch rendered.
await page.waitForTimeout(300);
}
// The app renders when the gate passes: the layout's <main> is present
// and the capture UI (Add Photo heading) is inside it.
async function expectApp(page) {
await expect(page.locator('main')).toBeVisible();
await expect(page.locator('main')).toContainText('Add Photo');
// No hard-stop page.
await expect(page.locator('.gate')).toHaveCount(0);
}
// The hard stop renders when the gate fails: the .gate box is present,
// the app's <main> is NOT, and the spec browsers are listed.
async function expectHardStop(page) {
await expect(page.locator('.gate h1')).toHaveText('Unsupported browser');
await expect(page.locator('main')).toHaveCount(0);
const gate = page.locator('.gate');
await expect(gate).toContainText('iOS Safari');
await expect(gate).toContainText('Android Chrome');
await expect(gate).toContainText('Chrome');
await expect(gate).toContainText('Edge');
await expect(gate).toContainText('Firefox');
}
test('UAT-3: app renders in desktop Chrome (supported)', async ({ page }) => {
await openApp(page, DESKTOP_CHROME_UA);
await expectApp(page);
});
test('UAT-3: app renders in desktop Edge (supported)', async ({ page }) => {
await openApp(page, DESKTOP_EDGE_UA);
await expectApp(page);
});
test('UAT-3: app renders in desktop Firefox (supported)', async ({ page }) => {
await openApp(page, DESKTOP_FIREFOX_UA);
await expectApp(page);
});
test('UAT-3: app renders in iOS Safari (supported)', async ({ page, context }) => {
await context.setExtraHTTPHeaders({ 'User-Agent': IOS_SAFARI_UA });
await page.goto('/');
await page.waitForTimeout(300);
await expectApp(page);
});
test('UAT-3: app renders in Android Chrome (supported)', async ({ page, context }) => {
await context.setExtraHTTPHeaders({ 'User-Agent': ANDROID_CHROME_UA });
await page.goto('/');
await page.waitForTimeout(300);
await expectApp(page);
});
test('UAT-3: hard stop in desktop Opera (unsupported)', async ({ page }) => {
await openApp(page, DESKTOP_OPERA_UA);
await expect(page.locator('.gate h1')).toHaveText('Unsupported browser');
// The app must NOT render.
await expect(page.locator('main')).toHaveCount(0);
// The hard-stop page lists the spec browsers.
await expect(page.locator('.gate')).toContainText('iOS Safari');
await expect(page.locator('.gate')).toContainText('Android Chrome');
await expect(page.locator('.gate')).toContainText('Chrome');
await expect(page.locator('.gate')).toContainText('Edge');
await expect(page.locator('.gate')).toContainText('Firefox');
});
test('UAT-3: hard stop in Samsung Internet (unsupported)', async ({ page, context }) => {
await context.setExtraHTTPHeaders({ 'User-Agent': SAMSUNG_INTERNET_UA });
await page.goto('/');
await page.waitForTimeout(300);
await expect(page.locator('.gate h1')).toHaveText('Unsupported browser');
await expect(page.locator('main')).toHaveCount(0);
});
+93
View File
@@ -0,0 +1,93 @@
import { test, expect } from '@playwright/test';
// M1-3: Playwright tests for the Mask state (RED first — the live Mask UI
// lands in M1-5; the pure state machine + geometry are unit-tested in
// tests/unit/maskState.test.js).
//
// Spec ground truth (docs/CAPTURE.md):
// - "Approve is blocked when the mask has no slab pixels. User retakes."
// - "If the file is HEIC and the browser cannot decode it, show a one-tap
// notice to retake the photo as JPEG."
// - 2 knobs: sheet Auto/Green/Black, sensitivity 0–100 (slider + minus/plus).
// Edge offset and feather are NOT user-exposed in M1 (core params only).
// - Sensitivity slider updates immediately; overlay recomputes 2.0 s after
// the last change.
const SLAB_FIXTURE = 'tests/fixtures/slab-green.jpg';
const SHEET_ONLY_FIXTURE = 'tests/fixtures/sheet-only-green.jpg';
const HEIC_FIXTURE = 'tests/fixtures/slab.heic';
test.describe('Mask state (M1-5 live UI)', () => {
test('HEIC input that the browser cannot decode shows a one-tap retake notice', async ({ page }) => {
// 1. Land on Add photo.
await page.goto('/');
// 2. Provide an HEIC file the browser cannot decode.
const input = page.locator('input[type="file"]');
await input.setInputFiles(HEIC_FIXTURE);
// 3. The app must surface a one-tap 'retake as JPEG' notice — never a
// silent crash, never a server-side decode.
const notice = page.getByRole('alert', { name: /retake/i });
await expect(notice).toBeVisible();
await expect(notice).toHaveText(/retake as JPEG/i);
// 4. Tapping it returns to Add photo.
await notice.tap();
await expect(page.getByRole('button', { name: /add photo/i })).toBeVisible();
});
test('Approve is blocked while the mask has no slab pixels', async ({ page }) => {
// 1. Load a photo of the sheet only (no slab).
await page.goto('/');
const input = page.locator('input[type="file"]');
await input.setInputFiles(SHEET_ONLY_FIXTURE);
// 2. The Mask state opens with the overlay visible.
await expect(page.getByText(/mask/i)).toBeVisible();
// 3. Approve must be disabled — the mask has no slab pixels.
const approve = page.getByRole('button', { name: /approve/i });
await expect(approve).toBeDisabled();
// 4. Retake and load a photo with a real slab -> approve enables.
await page.getByRole('button', { name: /retake/i }).tap();
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
await expect(approve).toBeEnabled();
});
test('keys the green sheet and shows the 2 spec knobs', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
// Sheet control with Auto/Green/Black.
const sheet = page.getByRole('radiogroup', { name: /sheet/i });
await expect(sheet.getByRole('radio', { name: /auto/i })).toBeChecked();
await expect(sheet.getByRole('radio', { name: /green/i })).toBeVisible();
await expect(sheet.getByRole('radio', { name: /black/i })).toBeVisible();
// Sensitivity slider 0–100 with minus/plus.
const slider = page.getByRole('slider', { name: /sensitivity/i });
await expect(slider).toBeVisible();
await expect(slider).toHaveAttribute('min', '0');
await expect(slider).toHaveAttribute('max', '100');
await expect(page.getByRole('button', { name: /decrease sensitivity/i })).toBeVisible();
await expect(page.getByRole('button', { name: /increase sensitivity/i })).toBeVisible();
// Edge offset and feather are NOT in the spec — they must not be
// user-exposed in the Mask UI.
await expect(page.getByRole('slider', { name: /edge offset/i })).not.toBeVisible();
await expect(page.getByRole('slider', { name: /feather/i })).not.toBeVisible();
// Overlay visible (photo with checkerboard on removed px).
await expect(page.locator('[data-testid="mask-overlay"]')).toBeVisible();
});
test('sensitivity slider updates the control immediately', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
const slider = page.getByRole('slider', { name: /sensitivity/i });
await slider.fill('80');
// The control value is reflected immediately, not after the 2 s debounce.
await expect(slider).toHaveValue('80');
});
test('approve advances to Length axis after a non-empty mask', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
// Let the initial recompute land so stats are non-empty.
await page.waitForTimeout(2200);
await page.getByRole('button', { name: /approve/i }).tap();
await expect(page.getByText(/length axis/i)).toBeVisible();
});
});
+45
View File
@@ -0,0 +1,45 @@
import { test, expect } from '@playwright/test';
const SLAB_FIXTURE = 'tests/fixtures/slab-green.jpg';
// E2E smoke: the app serves and the health route responds.
test('M1-1 harness smoke: app serves + health ok', async ({ page, request }) => {
await page.goto('/');
await expect(page).toHaveTitle(/SlabUploader/);
const res = await request.get('/api/health');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.ok).toBe(true);
});
// Regression: the preview canvas must fit the full photo within the
// viewport without cropping. Spec: "Preview fits the device width.
// Height is capped so Mask and Measure controls stay on screen."
test('preview canvas fits full photo without cropping', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
await page.waitForTimeout(2500); // mask recompute
const canvas = page.locator('canvas.preview');
await expect(canvas).toBeVisible();
// Get the canvas's rendered (CSS) size and its intrinsic (pixel) size.
const box = await canvas.boundingBox();
const intrinsic = await canvas.evaluate((c) => ({ w: c.width, h: c.height }));
// The rendered size must fit within the viewport.
const vp = page.viewportSize();
expect(box.width).toBeLessThanOrEqual(vp.width);
expect(box.height).toBeLessThanOrEqual(vp.height * 0.55); // 50vh cap + tolerance
// Aspect ratio must be preserved (no distortion).
const renderedRatio = box.width / box.height;
const intrinsicRatio = intrinsic.w / intrinsic.h;
expect(renderedRatio).toBeCloseTo(intrinsicRatio, 1);
// The canvas must not be clipped by its container.
const wrap = page.locator('.preview-wrap');
const wrapBox = await wrap.boundingBox();
expect(box.width).toBeLessThanOrEqual(wrapBox.width + 1);
expect(box.height).toBeLessThanOrEqual(wrapBox.height + 1);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.
+377
View File
@@ -0,0 +1,377 @@
import { describe, it, expect } from 'vitest';
import {
minAreaRect,
axisFromRect,
rotateAxis,
axisAngle,
axisUnit,
createAxisState,
reduceAxisState,
} from '../../src/lib/core/axis.js';
// M1-4 Length axis — unit tests (RED first, per strict TDD).
//
// Ground truth: docs/CAPTURE.md §Length axis
// - min-area bounding rectangle of the mask
// - length axis on the long side of that rectangle
// - user rotates the axis if it does not follow the slab length
// - approve the axis
//
// All functions are pure (no DOM) so the state is unit-testable in Node.
// --- helpers --------------------------------------------------------------------
/**
* Build a binary mask (Uint8Array) of w x h with a filled axis-aligned
* rectangle of size rw x rh at (rx, ry).
*/
function rectMask(w, h, rx, ry, rw, rh) {
const mask = new Uint8Array(w * h);
for (let y = ry; y < ry + rh; y++) {
for (let x = rx; x < rx + rw; x++) {
mask[y * w + x] = 1;
}
}
return mask;
}
/**
* Build a binary mask of a rotated rectangle.
* Center at (cx, cy), half-lengths (hl, hw), rotation in radians.
*/
function rotatedRectMask(w, h, cx, cy, hl, hw, rot) {
const mask = new Uint8Array(w * h);
const cos = Math.cos(rot);
const sin = Math.sin(rot);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const dx = x - cx;
const dy = y - cy;
// Transform to rect-local coords
const lx = cos * dx + sin * dy;
const ly = -sin * dx + cos * dy;
if (Math.abs(lx) <= hl && Math.abs(ly) <= hw) {
mask[y * w + x] = 1;
}
}
}
return mask;
}
// --- minAreaRect ------------------------------------------------------------------
describe('minAreaRect', () => {
it('returns a tight axis-aligned rectangle for an axis-aligned mask', () => {
// 8x4 rectangle at (2,2) on a 12x8 canvas.
// Pixels at x=2..9 (8 cols), y=2..5 (4 rows).
// Center-to-center span: width 7, height 3. Center (5.5, 3.5).
const mask = rectMask(12, 8, 2, 2, 8, 4);
const rect = minAreaRect(mask, 12, 8);
// The min-area rect of an axis-aligned pixel block is tight:
// width = (cols-1), height = (rows-1) in center-to-center units.
expect(rect.width).toBeCloseTo(7, 2);
expect(rect.height).toBeCloseTo(3, 2);
expect(rect.angle).toBeCloseTo(0, 3);
expect(rect.center.x).toBeCloseTo(5.5, 2); // (2+9)/2
expect(rect.center.y).toBeCloseTo(3.5, 2); // (2+5)/2
});
it('handles a single pixel', () => {
const mask = new Uint8Array(10 * 10);
mask[5 * 10 + 5] = 1; // pixel at (5,5)
const rect = minAreaRect(mask, 10, 10);
expect(rect.area).toBe(1);
});
it('returns null for an empty mask', () => {
const mask = new Uint8Array(10 * 10);
const rect = minAreaRect(mask, 10, 10);
expect(rect).toBeNull();
});
it('detects a rotated rectangle', () => {
// 45-degree rotated rectangle: 20 units long, 4 units wide, centered
const w = 40,
h = 40,
cx = 20,
cy = 20,
hl = 10,
hw = 2;
const mask = rotatedRectMask(w, h, cx, cy, hl, hw, Math.PI / 4);
const rect = minAreaRect(mask, w, h);
// Pixel rasterization of a 20x4 rect at 45 degrees: the convex hull
// of the kept pixels approximates the true rect within ~1px per side.
// The angle should be close to 45 degrees.
expect(rect.angle).toBeCloseTo(Math.PI / 4, 1);
// Long side ~20 (within 2px for rasterization)
const longSide = Math.max(rect.width, rect.height);
expect(longSide).toBeGreaterThanOrEqual(18);
expect(longSide).toBeLessThanOrEqual(22);
});
it('handles a horizontal bar (wider than tall)', () => {
// 16x2 bar on a 20x10 canvas
const mask = rectMask(20, 10, 2, 4, 16, 2);
const rect = minAreaRect(mask, 20, 10);
// 16 cols x 2 rows: width ~15-16, height ~1-2 (pixel rasterization)
expect(rect.width).toBeGreaterThanOrEqual(14);
expect(rect.width).toBeLessThanOrEqual(17);
expect(rect.height).toBeGreaterThanOrEqual(1);
expect(rect.height).toBeLessThanOrEqual(3);
expect(rect.angle).toBeCloseTo(0, 1);
});
it('handles a vertical bar (taller than wide)', () => {
// 2x16 bar on a 10x20 canvas
const mask = rectMask(10, 20, 4, 2, 2, 16);
const rect = minAreaRect(mask, 10, 20);
// Long side ~16, short side ~2
const longSide = Math.max(rect.width, rect.height);
const shortSide = Math.min(rect.width, rect.height);
expect(longSide).toBeGreaterThanOrEqual(14);
expect(longSide).toBeLessThanOrEqual(17);
expect(shortSide).toBeGreaterThanOrEqual(1);
expect(shortSide).toBeLessThanOrEqual(3);
});
});
// --- axisFromRect ------------------------------------------------------------------
describe('axisFromRect', () => {
it('returns a unit vector along the long side of an axis-aligned rect', () => {
// 8x4 rect: long side is horizontal (width=8 > height=4)
const rect = {
center: { x: 6, y: 4 },
width: 8,
height: 4,
angle: 0,
area: 32,
};
const axis = axisFromRect(rect);
// Long side is width (8), so axis is along +x
expect(axis.x).toBeCloseTo(1, 6);
expect(axis.y).toBeCloseTo(0, 6);
});
it('returns a unit vector along the long side when height > width', () => {
// 4x8 rect: long side is vertical (height=8 > width=4)
const rect = {
center: { x: 6, y: 4 },
width: 4,
height: 8,
angle: 0,
area: 32,
};
const axis = axisFromRect(rect);
// Long side is height, so axis is along +y
expect(axis.x).toBeCloseTo(0, 6);
expect(axis.y).toBeCloseTo(1, 6);
});
it('rotates the axis with the rect angle', () => {
// 8x4 rect rotated 90 degrees: long side becomes vertical
const rect = {
center: { x: 6, y: 4 },
width: 8,
height: 4,
angle: Math.PI / 2,
area: 32,
};
const axis = axisFromRect(rect);
// The long side (width=8) is now along +y after 90-degree rotation
expect(axis.x).toBeCloseTo(0, 6);
expect(axis.y).toBeCloseTo(1, 6);
});
it('returns the axis at 45 degrees for a rotated rect', () => {
const rect = {
center: { x: 20, y: 20 },
width: 20,
height: 4,
angle: Math.PI / 4,
area: 80,
};
const axis = axisFromRect(rect);
// Long side at 45 degrees
expect(axis.x).toBeCloseTo(Math.SQRT1_2, 3);
expect(axis.y).toBeCloseTo(Math.SQRT1_2, 3);
});
});
// --- rotateAxis --------------------------------------------------------------------
describe('rotateAxis', () => {
it('rotates by 0 degrees is a no-op', () => {
const axis = { x: 1, y: 0 };
const rot = rotateAxis(axis, 0);
expect(rot.x).toBeCloseTo(1, 6);
expect(rot.y).toBeCloseTo(0, 6);
});
it('rotates by 90 degrees', () => {
const axis = { x: 1, y: 0 };
const rot = rotateAxis(axis, Math.PI / 2);
expect(rot.x).toBeCloseTo(0, 6);
expect(rot.y).toBeCloseTo(1, 6);
});
it('rotates by -90 degrees', () => {
const axis = { x: 1, y: 0 };
const rot = rotateAxis(axis, -Math.PI / 2);
expect(rot.x).toBeCloseTo(0, 6);
expect(rot.y).toBeCloseTo(-1, 6);
});
it('rotates a 45-degree axis by 45 degrees -> 90 degrees (vertical)', () => {
const axis = { x: Math.SQRT1_2, y: Math.SQRT1_2 };
const rot = rotateAxis(axis, Math.PI / 4);
expect(rot.x).toBeCloseTo(0, 3);
expect(rot.y).toBeCloseTo(1, 3);
});
});
// --- axisAngle -------------------------------------------------------------------
describe('axisAngle', () => {
it('returns 0 for +x axis', () => {
expect(axisAngle({ x: 1, y: 0 })).toBeCloseTo(0, 6);
});
it('returns PI/2 for +y axis', () => {
expect(axisAngle({ x: 0, y: 1 })).toBeCloseTo(Math.PI / 2, 6);
});
it('returns -PI/2 for -y axis', () => {
expect(axisAngle({ x: 0, y: -1 })).toBeCloseTo(-Math.PI / 2, 6);
});
it('returns PI for -x axis', () => {
expect(axisAngle({ x: -1, y: 0 })).toBeCloseTo(Math.PI, 6);
});
it('returns 45 degrees for diagonal', () => {
expect(axisAngle({ x: Math.SQRT1_2, y: Math.SQRT1_2 })).toBeCloseTo(
Math.PI / 4,
3,
);
});
});
// --- axisUnit --------------------------------------------------------------------
describe('axisUnit', () => {
it('creates a unit vector from an angle', () => {
expect(axisUnit(0).x).toBeCloseTo(1, 6);
expect(axisUnit(0).y).toBeCloseTo(0, 6);
expect(axisUnit(Math.PI / 2).x).toBeCloseTo(0, 6);
expect(axisUnit(Math.PI / 2).y).toBeCloseTo(1, 6);
expect(axisUnit(-Math.PI / 2).x).toBeCloseTo(0, 6);
expect(axisUnit(-Math.PI / 2).y).toBeCloseTo(-1, 6);
expect(axisUnit(Math.PI).x).toBeCloseTo(-1, 6);
expect(axisUnit(Math.PI).y).toBeCloseTo(0, 6);
});
});
// --- createAxisState -----------------------------------------------------------------
describe('createAxisState', () => {
it('starts with the proposed axis from the rect, not approved', () => {
const rect = {
center: { x: 6, y: 4 },
width: 8,
height: 4,
angle: 0,
area: 32,
};
const state = createAxisState(rect);
expect(state.rect).toBe(rect);
expect(state.axis.x).toBeCloseTo(1, 6); // long side is horizontal
expect(state.axis.y).toBeCloseTo(0, 6);
expect(state.approved).toBe(false);
});
it('proposed axis follows the rect angle', () => {
const rect = {
center: { x: 20, y: 20 },
width: 20,
height: 4,
angle: Math.PI / 4,
area: 80,
};
const state = createAxisState(rect);
expect(state.axis.x).toBeCloseTo(Math.SQRT1_2, 3);
expect(state.axis.y).toBeCloseTo(Math.SQRT1_2, 3);
});
});
// --- reduceAxisState -----------------------------------------------------------------
describe('reduceAxisState', () => {
const rect = {
center: { x: 6, y: 4 },
width: 8,
height: 4,
angle: 0,
area: 32,
};
it('rotate updates the axis immediately', () => {
let s = createAxisState(rect);
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
// +x rotated by 90 degrees -> +y
expect(s.axis.x).toBeCloseTo(0, 6);
expect(s.axis.y).toBeCloseTo(1, 6);
});
it('rotate changes the rect angle (not just a separate axis vector)', () => {
let s = createAxisState(rect);
const originalAngle = s.rect.angle;
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 4 });
// The rect itself must have rotated
expect(s.rect.angle).toBeCloseTo(originalAngle + Math.PI / 4, 6);
// And the axis is re-derived from the rotated rect
expect(s.axis).toEqual(axisFromRect(s.rect));
});
it('rotate by 90 deg: rect angle changes, axis is perpendicular to original', () => {
let s = createAxisState(rect);
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
expect(s.rect.angle).toBeCloseTo(Math.PI / 2, 6);
// Original axis was +x (1,0). After 90 deg rect rotation, axis is +y (0,1)
expect(s.axis.x).toBeCloseTo(0, 6);
expect(s.axis.y).toBeCloseTo(1, 6);
});
it('rotate composes (rotates from current axis)', () => {
let s = createAxisState(rect);
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
// Two 90-degree rotations -> -x
expect(s.axis.x).toBeCloseTo(-1, 6);
expect(s.axis.y).toBeCloseTo(0, 6);
});
it('rotate by 0 is a no-op', () => {
let s = createAxisState(rect);
const before = s;
s = reduceAxisState(s, { type: 'rotate', angle: 0 });
expect(s).toBe(before);
});
it('approve sets approved=true', () => {
let s = createAxisState(rect);
s = reduceAxisState(s, { type: 'approve' });
expect(s.approved).toBe(true);
});
it('retract approval on rotate after approval', () => {
let s = createAxisState(rect);
s = reduceAxisState(s, { type: 'approve' });
expect(s.approved).toBe(true);
// Rotate after approval: axis changes, approval is retracted
// (user is adjusting the axis, so it is no longer approved)
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 4 });
expect(s.approved).toBe(false);
});
it('unknown actions return the same state', () => {
const s = createAxisState(rect);
expect(reduceAxisState(s, { type: 'bogus' })).toBe(s);
});
});
+201
View File
@@ -0,0 +1,201 @@
/**
* UAT-3: Browser gate (hard stop) — unit tests for the pure logic in
* src/lib/core/browserGate.js.
*
* CAPTURE.md §Browsers:
* Phone: iOS Safari, Android Chrome.
* PC: Chrome, Edge, Firefox.
* Other clients get a hard stop.
*/
import { describe, it, expect } from 'vitest';
import { detectBrowser, isAllowed, checkBrowserGate } from '$lib/core/browserGate.js';
// --- representative user agents ------------------------------------
const UAs = {
iosSafari:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/605.1.15',
ipadSafari:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',
iosChrome:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6336.69 Mobile/15E148 Safari/605.1.15',
androidChrome:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6331.115 Mobile Safari/537.36',
desktopChrome:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
desktopEdge:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0',
desktopFirefox:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0',
desktopOpera:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0',
androidOpera:
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 OPR/110.0.0.0',
androidSamsung:
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36',
iosSamsung:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.0.0 Mobile/15E148 SamsungBrowser/124.0.0.0',
androidBrave:
'Mozilla/5.0 (Linux; Android 14; SM-A546E) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.0.0.5336 Mobile Safari/537.36',
unknown:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) CustomBrowser/9.9.0 Safari/537.36',
blank: '',
};
describe('detectBrowser', () => {
it('identifies iOS Safari on iPhone', () => {
const b = detectBrowser(UAs.iosSafari);
expect(b.name).toBe('iOS Safari');
expect(b.platform).toBe('phone');
});
it('identifies iPad Safari (iPadOS 13+ reports as Macintosh)', () => {
const b = detectBrowser(UAs.ipadSafari);
expect(b.name).toBe('Safari');
expect(b.platform).toBe('pc');
});
it('identifies Android Chrome', () => {
const b = detectBrowser(UAs.androidChrome);
expect(b.name).toBe('Android Chrome');
expect(b.platform).toBe('phone');
});
it('identifies desktop Chrome', () => {
const b = detectBrowser(UAs.desktopChrome);
expect(b.name).toBe('Chrome');
expect(b.platform).toBe('pc');
});
it('identifies desktop Edge (not Chrome)', () => {
const b = detectBrowser(UAs.desktopEdge);
expect(b.name).toBe('Microsoft Edge');
expect(b.platform).toBe('pc');
});
it('identifies desktop Firefox', () => {
const b = detectBrowser(UAs.desktopFirefox);
expect(b.name).toBe('Firefox');
expect(b.platform).toBe('pc');
});
it('identifies desktop Opera as its own browser (not Chrome)', () => {
const b = detectBrowser(UAs.desktopOpera);
expect(b.name).toBe('Opera');
expect(b.platform).toBe('pc');
});
it('identifies Android Opera (OPR beats Chrome)', () => {
const b = detectBrowser(UAs.androidOpera);
expect(b.name).toBe('Opera');
expect(b.platform).toBe('phone');
});
it('identifies Samsung Internet (SamsungBrowser beats Chrome)', () => {
const b = detectBrowser(UAs.androidSamsung);
expect(b.name).toBe('Samsung Internet');
expect(b.platform).toBe('phone');
});
it('identifies iOS Samsung Internet as unsupported', () => {
const b = detectBrowser(UAs.iosSamsung);
expect(b.name).toBe('Samsung Internet');
expect(b.platform).toBe('phone');
});
it('treats an unrecognized UA as Unknown', () => {
const b = detectBrowser(UAs.unknown);
expect(b.name).toBe('Unknown');
expect(b.platform).toBe('pc');
});
it('handles blank / undefined user agents without throwing', () => {
expect(detectBrowser(UAs.blank).name).toBe('Unknown');
expect(detectBrowser(undefined).name).toBe('Unknown');
});
});
describe('isAllowed', () => {
it('allows iOS Safari on phone', () => {
expect(isAllowed({ name: 'iOS Safari', platform: 'phone' })).toBe(true);
});
it('allows Android Chrome on phone', () => {
expect(isAllowed({ name: 'Android Chrome', platform: 'phone' })).toBe(true);
});
it('allows Chrome on PC', () => {
expect(isAllowed({ name: 'Chrome', platform: 'pc' })).toBe(true);
});
it('allows Edge on PC', () => {
expect(isAllowed({ name: 'Microsoft Edge', platform: 'pc' })).toBe(true);
});
it('allows Firefox on PC', () => {
expect(isAllowed({ name: 'Firefox', platform: 'pc' })).toBe(true);
});
it('rejects Opera', () => {
expect(isAllowed({ name: 'Opera', platform: 'pc' })).toBe(false);
});
it('rejects Android Opera', () => {
expect(isAllowed({ name: 'Opera', platform: 'phone' })).toBe(false);
});
it('rejects Samsung Internet', () => {
expect(isAllowed({ name: 'Samsung Internet', platform: 'phone' })).toBe(false);
});
it('rejects Samsung Internet on iOS', () => {
expect(isAllowed({ name: 'Samsung Internet', platform: 'phone' })).toBe(false);
});
it('rejects Brave', () => {
expect(isAllowed({ name: 'Brave', platform: 'phone' })).toBe(false);
});
it('rejects Unknown', () => {
expect(isAllowed({ name: 'Unknown', platform: 'pc' })).toBe(false);
});
it('rejects phone browsers on PC (impossible but rejected)', () => {
expect(isAllowed({ name: 'iOS Safari', platform: 'pc' })).toBe(false);
expect(isAllowed({ name: 'Android Chrome', platform: 'pc' })).toBe(false);
});
});
describe('checkBrowserGate', () => {
it('supports exactly the five spec browsers + Safari (iPad)', () => {
for (const ua of [
UAs.iosSafari,
UAs.ipadSafari,
UAs.androidChrome,
UAs.desktopChrome,
UAs.desktopEdge,
UAs.desktopFirefox,
]) {
expect(checkBrowserGate(ua).supported).toBe(true);
}
});
it('hard-stops Opera, Samsung Internet, unknown, blank', () => {
for (const ua of [
UAs.desktopOpera,
UAs.androidOpera,
UAs.androidSamsung,
UAs.iosSamsung,
UAs.androidBrave,
UAs.unknown,
UAs.blank,
]) {
expect(checkBrowserGate(ua).supported).toBe(false);
}
});
it('returns the detected browser for the UI', () => {
const r = checkBrowserGate(UAs.desktopOpera);
expect(r.browser.name).toBe('Opera');
expect(r.browser.platform).toBe('pc');
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import * as m from '../../src/lib/core/math.js';
import { prepareInventoryMetadata } from '../../src/lib/core/inventory.js';
describe('prepareInventoryMetadata', () => {
it('calculates correct metadata for a 21in x 12in slab at 1.75in thick', () => {
const Lpx = 3360, L = 21, scale = 160, thickness = 1.75;
const widthPx = 1920; // 12in
const maskPx = Lpx * widthPx; // 6,451,200
const result = prepareInventoryMetadata(
maskPx,
scale,
L,
thickness,
[1600, 1920, 2080, 1600]
);
// sqft = (6,451,200 / 160^2) / 144 = 1.75
expect(result.sqft).toBe(1.75);
// bdft = 1.75 * 1.75 = 3.0625 -> 3.06
expect(result.bdft).toBe(3.06);
expect(result.lengthIn).toBe(21);
expect(result.thicknessIn).toBe(1.75);
expect(result.minWidth).toBe(10);
expect(result.maxWidth).toBe(13);
expect(result.avgWidth).toBe(11.25);
});
it('handles zero area', () => {
const result = prepareInventoryMetadata(0, 160, 21, 1.75, []);
expect(result.sqft).toBe(0);
expect(result.bdft).toBe(0);
});
});
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import {
keySheet,
maskStats,
} from '../../src/lib/core/mask.js';
// M1-3 mask key geometry — unit tests (RED first, per strict TDD).
//
// Ground truth: docs/CAPTURE.md §Mask
// - 4 knobs: sheet Auto/Green/Black, sensitivity 0–100, edge offset, feather
// - Control value updates IMMEDIATELY; overlay recomputes 2.0 s after the
// last sensitivity change (debounce).
// - Approve blocked when maskStats is empty.
//
// All functions here are pure (no DOM) so the state is unit-testable in Node;
// the live UI (M1-5) drives this machine.
//
// --- synthetic photo helpers (no DOM) -----------------------------------------\n//
function solid(w, h, r, g, b) {
const data = new Uint8ClampedArray(w * h * 4);
for (let i = 0; i < w * h; i++) {
data[i * 4 + 0] = r;
data[i * 4 + 1] = g;
data[i * 4 + 2] = b;
data[i * 4 + 3] = 255;
}
return data;
}
function withRect(data, width, x0, y0, w, h, r, g, b) {
for (let y = y0; y < y0 + h; y++) {
for (let x = x0; x < x0 + w; x++) {
const i = (y * width + x) * 4;
data[i + 0] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = 255;
}
}
return data;
}
// 12 x 8 green sheet with an 8 x 4 brown slab at (2,2).
const GREEN_PHOTO = () => withRect(solid(12, 8, 0, 200, 0), 12, 2, 2, 8, 4, 140, 90, 40);
const DIMS = { width: 12, height: 8 };
// --- keySheet ---------------------------------------------------------------------
//
describe('keySheet', () => {
it('keeps the slab in a green sheet photo', () => {
const mask = keySheet(GREEN_PHOTO(), {
sheet: 'green',
sensitivity: 50,
});
// 8x4 = 32 pixels
expect(maskStats(mask).kept).toBe(32);
});
it('keeps the slab in a black sheet photo', () => {
const blackPhoto = withRect(solid(12, 8, 10, 10, 10), 12, 2, 2, 8, 4, 220, 210, 190);
const mask = keySheet(blackPhoto, {
sheet: 'black',
sensitivity: 50,
});
expect(maskStats(mask).kept).toBe(32);
});
it('auto-selects the better key', () => {
// A photo that works well for both should return the green one
const mask = keySheet(GREEN_PHOTO(), {
sheet: 'auto',
sensitivity: 50,
});
expect(maskStats(mask).kept).toBe(32);
});
it('handles sensitivity 0 (tightest)', () => {
const mask = keySheet(GREEN_PHOTO(), {
sheet: 'green',
sensitivity: 0,
});
// At s=0, threshold is 150.
// brown wood (140,90,40) dist = sqrt(140^2 + (90-255)^2 + 40^2) = sqrt(19600 + 27225 + 1600) = ~218.
// 218 > 150, so kept.
expect(maskStats(mask).kept).toBe(32);
});
it('handles sensitivity 100 (loosest)', () => {
const mask = keySheet(GREEN_PHOTO(), {
sheet: 'green',
sensitivity: 100,
});
// At s=100, threshold is 50.
// Sheet green (0,200,0) dist = 55.
// 55 > 50, so sheet is kept too!
// 12x8 = 96 pixels
expect(maskStats(mask).kept).toBe(96);
});
it('green key passes non-green pixels through (black key handles them)', () => {
// Real slab photos (sample_01..06) have a dark blue-gray background
// (e.g. rgb 26,27,29) with a bright warm slab (e.g. rgb 250,200,140).
// The green key is designed to key out a GREEN sheet only. On a dark
// (non-green) surface it passes pixels through (keeps them) so that
// the black key can remove them. The green key alone does NOT remove
// a dark background — that is the black key's job. Auto selects the
// black key in this case (see the auto test below).
const darkBg = solid(12, 8, 26, 27, 29);
withRect(darkBg, 12, 2, 2, 8, 4, 250, 200, 140);
const mask = keySheet(darkBg, { sheet: 'green', sensitivity: 50 });
const stats = maskStats(mask);
// Green key on dark bg: all pixels are non-greenish, so all are kept.
// This is BY DESIGN — the green key is a no-op on non-green surfaces,
// and auto will select the black key instead.
expect(stats.kept).toBe(96);
});
it('auto selects black key over green key on a dark-background photo', () => {
// Real photo: dark bg + bright slab. Green key keeps ~everything (bad).
// Black key keeps only the slab (good). Auto must pick black.
const darkBg = solid(12, 8, 26, 27, 29);
withRect(darkBg, 12, 2, 2, 8, 4, 250, 200, 140);
const mask = keySheet(darkBg, { sheet: 'auto', sensitivity: 50 });
const stats = maskStats(mask);
// Should keep only the slab (32 px), not the whole frame (96 px).
expect(stats.kept).toBe(32);
});
it('auto selects green key over black key on a green-sheet photo', () => {
// Green sheet + brown slab. Green key keeps slab only. Black key may
// keep slab + some sheet. Auto must pick green (fewer kept = better).
const photo = GREEN_PHOTO();
const mask = keySheet(photo, { sheet: 'auto', sensitivity: 50 });
const stats = maskStats(mask);
expect(stats.kept).toBe(32);
});
});
// --- maskStats -------------------------------------------------------------------
//
describe('maskStats', () => {
it('correctly reports kept count and empty status', () => {
const mask = new Uint8Array([1, 0, 1, 0]);
const stats = maskStats(mask);
expect(stats.kept).toBe(2);
expect(stats.total).toBe(4);
expect(stats.empty).toBe(false);
});
it('reports empty=true when no pixels kept', () => {
const mask = new Uint8Array([0, 0, 0, 0]);
const stats = maskStats(mask);
expect(stats.kept).toBe(0);
expect(stats.empty).toBe(true);
});
});
+316
View File
@@ -0,0 +1,316 @@
import { describe, it, expect, vi } from 'vitest';
import {
offsetMask,
featherMask,
computeMask,
createMaskState,
reduceMaskState,
dueForRecompute,
RECOMPUTE_DELAY_MS,
} from '../../src/lib/core/maskState.js';
// M1-3 Mask state — RED first (strict TDD).
//
// Ground truth: docs/CAPTURE.md §Mask
// - 4 knobs: sheet Auto/Green/Black, sensitivity 0–100, edge offset, feather
// - Control value updates IMMEDIATELY; overlay recomputes 2.0 s after the
// last sensitivity change (debounce).
// - Approve blocked when maskStats is empty.
//
// All functions here are pure (no DOM) so the state is unit-testable in Node;
// the live UI (M1-5) drives this machine.
//
// --- synthetic photo helpers (no DOM) -----------------------------------------\
//
function solid(w, h, r, g, b) {
const data = new Uint8ClampedArray(w * h * 4);
for (let i = 0; i < w * h; i++) {
data[i * 4 + 0] = r;
data[i * 4 + 1] = g;
data[i * 4 + 2] = b;
data[i * 4 + 3] = 255;
}
return data;
}
function withRect(data, width, x0, y0, w, h, r, g, b) {
for (let y = y0; y < y0 + h; y++) {
for (let x = x0; x < x0 + w; x++) {
const i = (y * width + x) * 4;
data[i + 0] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = 255;
}
}
return data;
}
// 12 x 8 green sheet with an 8 x 4 brown slab at (2,2).
const GREEN_PHOTO = () => withRect(solid(12, 8, 0, 200, 0), 12, 2, 2, 8, 4, 140, 90, 40);
const DIMS = { width: 12, height: 8 };
// --- offsetMask ------------------------------------------------------------------
//
describe('offsetMask (edge offset)', () => {
it('offset 0 is a no-op', () => {
const mask = new Uint8Array([1, 1, 1, 0, 0, 0]);
expect(offsetMask(mask, 3, 2, 0)).toEqual(new Uint8Array([1, 1, 1, 0, 0, 0]));
});
it('positive offset erodes the mask boundary', () => {
// 1 kept ring of 1s on a 5x1 row: erode 1 -> the single kept pixel
// loses its kept neighbours' protection and is removed.
const mask = new Uint8Array([0, 0, 1, 0, 0]);
const eroded = offsetMask(mask, 5, 1, 1);
expect(eroded).toEqual(new Uint8Array([0, 0, 0, 0, 0]));
});
it('positive offset erodes a filled block by 1 px ring', () => {
// 4x4 all-kept block -> 2x2 after one erosion.
const mask = new Uint8Array(16).fill(1);
const eroded = offsetMask(mask, 4, 4, 1);
// corners + outer ring gone, inner 2x2 kept.
expect(eroded[0]).toBe(0);
expect(eroded[3]).toBe(0);
expect(eroded[4]).toBe(0);
expect(eroded[7]).toBe(0);
expect(eroded[5]).toBe(1);
expect(eroded[6]).toBe(1);
expect(eroded[9]).toBe(1);
expect(eroded[10]).toBe(1);
});
it('negative offset dilates the mask', () => {
// single kept pixel grows to a plus shape after one dilation.
const mask = new Uint8Array(9);
mask[4] = 1;
const dilated = offsetMask(mask, 3, 3, -1);
expect(dilated[1]).toBe(1); // up
expect(dilated[3]).toBe(1); // left
expect(dilated[4]).toBe(1); // center
expect(dilated[5]).toBe(1); // right
expect(dilated[7]).toBe(1); // down
expect(dilated[0]).toBe(0); // corners untouched by 4-neighbour dilate
});
it('multiple iterations compound', () => {
// 6x6 block eroded twice -> 2x2 core.
const mask = new Uint8Array(36).fill(1);
const twice = offsetMask(mask, 6, 6, 2);
let kept = 0;
for (let i = 0; i < 36; i++) kept += twice[i];
expect(kept).toBe(4);
});
});
// --- featherMask ------------------------------------------------------------------
//
describe('featherMask (feather)', () => {
it('radius 0 returns identity weights (1 kept, 0 removed)', () => {
const mask = new Uint8Array([1, 0, 1]);
expect(featherMask(mask, 3, 1, 0)).toEqual(new Float32Array([1, 0, 1]));
});
it('interior pixels away from the edge stay fully weighted', () => {
// 5x1 row, all kept, feather 1: interior pixel (2,0) has min distance
// 2 to any 0 (out-of-bounds is not 0, but row ends are boundary —
// boundary pixels have no removed neighbour, so weight stays 1).
const mask = new Uint8Array([1, 1, 1, 1, 1]);
const w = featherMask(mask, 5, 1, 1);
expect(w[2]).toBe(1);
});
it('attenuates kept pixels adjacent to removed pixels', () => {
// 3x1 row: [0,1,0], feather 1: the kept middle pixel is 1 px from a
// removed pixel -> weight (r - dist + 1)/(r + 1) = (1-1+1)/(1+1) = 0.5.
const mask = new Uint8Array([0, 1, 0]);
const w = featherMask(mask, 3, 1, 1);
expect(w[1]).toBeCloseTo(0.5);
});
it('attenuation scales with distance (farther = heavier)', () => {
// 5x1: [0,1,1,1,0], feather 2:
// x=1 dist 1 -> (2-1+1)/3 = 2/3
// x=2 dist 2 -> (2-2+1)/3 = 1/3
// x=3 dist 1 -> 2/3
const mask = new Uint8Array([0, 1, 1, 1, 0]);
const w = featherMask(mask, 5, 1, 2);
expect(w[1]).toBeCloseTo(2 / 3);
expect(w[2]).toBeCloseTo(1 / 3);
expect(w[3]).toBeCloseTo(2 / 3);
});
});
// --- computeMask --------------------------------------------------------------------
//
describe('computeMask (pipeline)', () => {
it('keys green and black sheets end-to-end', () => {
const green = computeMask(GREEN_PHOTO(), {
...DIMS,
sheet: 'green',
sensitivity: 50,
});
expect(green.stats.kept).toBe(32);
expect(green.stats.empty).toBe(false);
const blackPhoto = withRect(solid(12, 8, 10, 10, 10), 12, 2, 2, 8, 4, 220, 210, 190);
const black = computeMask(blackPhoto, {
...DIMS,
sheet: 'black',
sensitivity: 50,
});
expect(black.stats.kept).toBe(32);
});
it('edge offset erodes the kept count', () => {
const base = computeMask(GREEN_PHOTO(), {
...DIMS,
sheet: 'green',
sensitivity: 50,
});
const eroded = computeMask(GREEN_PHOTO(), {
...DIMS,
sheet: 'green',
sensitivity: 50,
edgeOffset: 1,
});
expect(eroded.stats.kept).toBeLessThan(base.stats.kept);
});
it('feather does not change the approve stats', () => {
const base = computeMask(GREEN_PHOTO(), {
...DIMS,
sheet: 'green',
sensitivity: 50,
});
const feath = computeMask(GREEN_PHOTO(), {
...DIMS,
sheet: 'green',
sensitivity: 50,
feather: 2,
});
expect(feath.stats).toEqual(base.stats);
});
it('reports empty stats for a sheet-only photo (approve must block)', () => {
const res = computeMask(solid(12, 8, 0, 200, 0), {
...DIMS,
sheet: 'green',
sensitivity: 50,
});
expect(res.stats.empty).toBe(true);
});
});
// --- state machine --------------------------------------------------------------------
//
describe('mask state machine', () => {
const fresh = () => createMaskState(DIMS);
it('starts with sheet auto, sensitivity 50, knobs 0, not approved, empty stats', () => {
const s = fresh();
expect(s.sheet).toBe('auto');
expect(s.sensitivity).toBe(50);
expect(s.edgeOffset).toBe(0);
expect(s.feather).toBe(0);
expect(s.approved).toBe(false);
expect(s.stats.empty).toBe(true);
});
it('set_sheet updates immediately and flags a pending recompute', () => {
let s = fresh();
s = reduceMaskState(s, { type: 'set_sheet', sheet: 'green' });
expect(s.sheet).toBe('green');
expect(s.pendingRecompute).toBe(true);
});
it('sheet change to the same value is a no-op', () => {
const s = fresh();
expect(reduceMaskState(s, { type: 'set_sheet', sheet: 'auto' })).toBe(s);
});
it('set_sensitivity updates immediately, clamps 0–100, flags recompute', () => {
let s = fresh();
s = reduceMaskState(s, { type: 'set_sensitivity', value: 80 });
expect(s.sensitivity).toBe(80);
expect(s.pendingRecompute).toBe(true);
s = reduceMaskState(s, { type: 'set_sensitivity', value: 250 });
expect(s.sensitivity).toBe(100);
s = reduceMaskState(s, { type: 'set_sensitivity', value: -10 });
expect(s.sensitivity).toBe(0);
});
it('nudge_sensitivity steps by delta and clamps', () => {
let s = fresh();
s = reduceMaskState(s, { type: 'nudge_sensitivity', delta: -5 });
expect(s.sensitivity).toBe(45);
s = reduceMaskState(s, { type: 'nudge_sensitivity', delta: 5 });
expect(s.sensitivity).toBe(50);
s = reduceMaskState(s, { type: 'nudge_sensitivity', delta: -999 });
expect(s.sensitivity).toBe(0);
});
it('edge offset and feather update immediately and flag recompute', () => {
let s = fresh();
s = reduceMaskState(s, { type: 'set_edge_offset', value: 2 });
expect(s.edgeOffset).toBe(2);
expect(s.pendingRecompute).toBe(true);
s = reduceMaskState(s, { type: 'set_feather', value: 3 });
expect(s.feather).toBe(3);
expect(s.pendingRecompute).toBe(true);
});
it('recompute stores stats, clears pending, records time', () => {
let s = fresh();
s = reduceMaskState(s, { type: 'set_sensitivity', value: 60 });
s = reduceMaskState(s, {
type: 'recompute',
stats: { kept: 32, total: 96, empty: false },
at: 1234,
});
expect(s.stats).toEqual({ kept: 32, total: 96, empty: false });
expect(s.pendingRecompute).toBe(false);
expect(s.lastRecomputeAt).toBe(1234);
});
it('approve is blocked while the mask is empty', () => {
let s = fresh(); // stats empty by default
const before = s;
s = reduceMaskState(s, { type: 'approve' });
expect(s.approved).toBe(false);
expect(s).toBe(before); // no change at all
});
it('approve succeeds once the mask has slab pixels', () => {
let s = fresh();
s = reduceMaskState(s, {
type: 'recompute',
stats: { kept: 32, total: 96, empty: false },
at: 1,
});
s = reduceMaskState(s, { type: 'approve' });
expect(s.approved).toBe(true);
});
it('debounce: recompute is due only after 2.0 s of idle', () => {
let s = fresh();
s = reduceMaskState(s, { type: 'set_sensitivity', value: 51 });
expect(dueForRecompute(s, s.lastRecomputeAt)).toBe(false);
expect(dueForRecompute(s, s.lastRecomputeAt + 1999)).toBe(false);
expect(RECOMPUTE_DELAY_MS).toBe(2000);
expect(dueForRecompute(s, s.lastRecomputeAt + 2000)).toBe(true);
});
it('debounce resets on every sensitivity change (timer restarts)', () => {
let s = fresh();
s = reduceMaskState(s, { type: 'set_sensitivity', value: 51 });
// User keeps nudging: at t=1999 the earlier change would be due, but
// a new change at t=1999 resets the window.
s = reduceMaskState(s, { type: 'recompute', stats: s.stats, at: 0 });
s = reduceMaskState(s, { type: 'set_sensitivity', value: 52 });
// 1.9 s after the latest change -> not due; 2.0 s -> due.
expect(dueForRecompute(s, 1999)).toBe(false);
expect(dueForRecompute(s, 2000)).toBe(true);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import * as m from '../../src/lib/core/math.js';
// M1 deterministic core — unit tests (RED first, per TDD).
// Ground truth for the math identities is the spec (docs/CAPTURE.md):
// scale = longest_axis_px / L
// sqft = (mask_px / scale^2) / 144
// bdft = sqft * thickness_in
// Widths are sampled every `everyInches` along the approved axis.
describe('scale', () => {
it('scale = longest_axis_px / L', () => {
expect(m.scale(3360, 84)).toBe(40);
});
it('scale for fixture-b geometry (21 in)', () => {
// 21 in represented as 3360 px -> 160 px/in
expect(m.scale(3360, 21)).toBeCloseTo(160, 6);
});
});
describe('sqft', () => {
it('sqft = (mask_px / scale^2) / 144, 2 decimals', () => {
// scale=40 -> scale^2=1600; 1,000,000/1600=625; /144=4.3403 -> 4.34
expect(m.sqft(1_000_000, 40)).toBe(4.34);
});
it('zero-area mask -> 0', () => {
expect(m.sqft(0, 40)).toBe(0);
});
});
describe('bdft', () => {
it('bdft = sqft * thickness_in, 2 decimals (identity 1 sqft @ 1in = 1 bdft)', () => {
expect(m.bdft(4.34, 1)).toBeCloseTo(4.34, 6);
expect(m.bdft(4.34, 2.5)).toBe(10.85);
});
});
describe('e2e fixture math (cand_b: 21in x 1.75in)', () => {
// If the slab were a clean rectangle exactly 21in long, at 160px/in that is
// 3360 px long. Pick a width of 12in -> 1920px -> area 6,451,200 px.
const Lpx = 3360, L = 21, scale = 160, thickness = 1.75;
const widthPx = 1920; // 12in
const maskPx = Lpx * widthPx; // 6,451,200
it('sqft of a 21in x 12in slab = 1.75', () => {
expect(m.sqft(maskPx, scale)).toBe(1.75);
});
it('bdft of that slab at 1.75in = 3.06', () => {
const sqft = m.sqft(maskPx, scale);
expect(m.bdft(sqft, thickness)).toBe(3.06); // 1.75*1.75=3.0625 -> 3.06 (half-up)
});
});
describe('width sampling', () => {
it('samples perpendicular widths at 0,6,12,... in along the axis', () => {
// pxPerIn=160. axisLengthPx = 3360 (21in). widthsAt samples every 6in.
// Provide a constant width of 1920px (12in) at every sample point.
const pxPerIn = 160;
const axisLengthPx = 3360;
const constantWidthPx = 1920;
const out = m.sampledWidths(constantWidthPx, pxPerIn, axisLengthPx, 6);
// 21in / 6 = 3.5 -> sample points at 0,6,12,18 (and not 24>21). 4 samples.
expect(out.length).toBe(4);
for (const w of out) expect(w).toBe(12); // 1920px/160 = 12in
});
it('min/max/avg of sampled widths', () => {
const pxPerIn = 160;
const widths = m.sampledWidthsFrom(
// widths at px positions 0,960,1920,2880 (=0,6,12,18 in)
[1600, 1920, 2080, 1600],
pxPerIn
);
expect(widths.min).toBeCloseTo(10, 6);
expect(widths.max).toBeCloseTo(13, 6);
expect(widths.avg).toBeCloseTo(11.25, 6); // (10+12+13+10)/4 = 11.25
});
});
describe('1/8 inch stepping + rounding', () => {
it('snaps a length to the nearest 1/8 inch', () => {
expect(m.snapToEighth(10.1)).toBeCloseTo(10.125, 6); // 10 1/8
expect(m.snapToEighth(10.2)).toBeCloseTo(10.25, 6); // 10 2/8=1/4
expect(m.snapToEighth(10.0)).toBe(10);
});
it('rounds half-up to 2 decimals', () => {
expect(m.roundHalfUp(30.625, 2)).toBe(30.63);
expect(m.roundHalfUp(4.345, 2)).toBe(4.35);
expect(m.roundHalfUp(1.005, 2)).toBe(1.01);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { sampleWidthsPx } from '../../src/lib/core/measure.js';
// M1-5 Measure — pure geometry tests.
//
// Ground truth: docs/CAPTURE.md §Measure
// - Widths: min, max, avg, sampled every 6 in along the approved length
// axis, perpendicular to it
// - scale = longest_axis_px / L
//
// sampleWidthsPx runs the real scanline mask sampling that the UI uses on
// the approved mask, so these tests pin the exact pixel widths the e2e
// fixture math is computed from.
/**
* Build a binary mask directly (no keySheet dependency): 1 inside the slab
* rectangle, 0 elsewhere. This keeps the test self-contained and focused on
* sampleWidthsPx geometry, which is what the test suite is for.
*/
function buildMask(width, height, slabW, slabH, ox, oy) {
const mask = new Uint8Array(width * height);
for (let y = 0; y < height; y++)
for (let x = 0; x < width; x++)
mask[y * width + x] =
x >= ox && x < ox + slabW && y >= oy && y < oy + slabH ? 1 : 0;
return { mask, width, height };
}
describe('sampleWidthsPx (M1-5 Measure geometry)', () => {
it('samples perpendicular widths every 6in along a horizontal axis', () => {
// 16 px/in. Photo 400x300. Slab 288x96 px (18in x 6in) at (0,0).
// Axis along x at slab center y=48, from x=0, length 288 px.
// stepPx = 6*16 = 96. Samples at t=0,96,192,288.
// t=288 lands on the slab boundary (x=288, slab is x<288) -> width 0.
const { mask, width, height } = buildMask(400, 300, 288, 96, 0, 0);
const axis = { origin: { x: 0, y: 48 }, dir: { x: 1, y: 0 }, lengthPx: 288 };
const widths = sampleWidthsPx(mask, width, height, axis, 16);
expect(widths.length).toBe(4);
expect(widths[0]).toBeCloseTo(96, 0);
expect(widths[1]).toBeCloseTo(96, 0);
expect(widths[2]).toBeCloseTo(96, 0);
expect(widths[3]).toBe(0);
});
it('returns widths perpendicular to a vertical axis', () => {
// Slab 96x288 px (6in x 18in) at (0,0). Axis along y at x=48, length 288.
const { mask, width, height } = buildMask(400, 300, 96, 288, 0, 0);
const axis = { origin: { x: 48, y: 0 }, dir: { x: 0, y: 1 }, lengthPx: 288 };
const widths = sampleWidthsPx(mask, width, height, axis, 16);
expect(widths.length).toBe(4);
expect(widths[0]).toBeCloseTo(96, 0);
expect(widths[1]).toBeCloseTo(96, 0);
expect(widths[2]).toBeCloseTo(96, 0);
expect(widths[3]).toBe(0);
});
it('samples only up to the axis length, not beyond', () => {
// Slab 80 px (5in) wide: 2nd sample at 96 px > lengthPx=80.
const { mask, width, height } = buildMask(200, 150, 80, 80, 0, 0);
const axis = { origin: { x: 0, y: 0 }, dir: { x: 1, y: 0 }, lengthPx: 80 };
const widths = sampleWidthsPx(mask, width, height, axis, 16);
expect(widths.length).toBe(1);
});
it('reports the real mask extent, not the photo extent', () => {
// Slab 80 px tall at y=35: the perpendicular sample must be 80,
// never the 150 px photo height.
const { mask, width, height } = buildMask(200, 150, 80, 80, 0, 35);
const axis = { origin: { x: 0, y: 35 }, dir: { x: 1, y: 0 }, lengthPx: 80 };
const widths = sampleWidthsPx(mask, width, height, axis, 16);
expect(widths.length).toBe(1);
expect(widths[0]).toBeCloseTo(80, 0);
});
});
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest';
// Smoke test for the M1-1 harness: proves the JS unit runner works end-to-end.
// This is deliberately trivial — later cards (M1-2+) replace it with the
// deterministic-core suite. It must pass to certify the harness is GREEN.
describe('M1 harness smoke', () => {
it('adds numbers (harness is live)', () => {
expect(1 + 1).toBe(2);
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": false,
"moduleResolution": "bundler"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
test: {
environment: 'node',
include: ['tests/unit/**/*.test.js'],
exclude: ['tests/e2e/**', 'node_modules/**', 'build/**']
}
});
+6
View File
@@ -0,0 +1,6 @@
{
"name": "SlabUploader",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

@@ -0,0 +1,11 @@
{
"fixture": "dji_mimo_20260109_152422_20260109152421_1771310617476_photo.JPG",
"label": "cand_a",
"source": "\\\\truenas.local\\Business\\Samples",
"orientation": "irregular",
"sheet": "black",
"image_px": [4000, 2256],
"length_in": 28.0,
"thickness_in": 2.25,
"notes": "Organic/irregular slab shape; stress-test for min-area bounding rectangle + length-axis. Length is the longest axis of the slab."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

@@ -0,0 +1,11 @@
{
"fixture": "dji_mimo_20260109_155754_20260109155755_1771310617107_photo.JPG",
"label": "cand_c",
"source": "\\\\truenas.local\\Business\\Samples",
"orientation": "horizontal",
"sheet": "black",
"image_px": [4000, 2256],
"length_in": 21.0,
"thickness_in": 1.75,
"notes": "Clean elongated slab, length runs horizontally. Second clean-case fixture."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

@@ -0,0 +1,11 @@
{
"fixture": "dji_mimo_20260109_155818_20260109155817_1771310617082_photo.JPG",
"label": "cand_b",
"source": "\\\\truenas.local\\Business\\Samples",
"orientation": "horizontal",
"sheet": "black",
"image_px": [4000, 2256],
"length_in": 21.0,
"thickness_in": 1.75,
"notes": "Clean elongated slab, length runs horizontally. Primary clean-case fixture for expected sqft/bdft/widths."
}