diff --git a/.svelte-kit/ambient.d.ts b/.svelte-kit/ambient.d.ts
new file mode 100644
index 0000000..d5eec2a
--- /dev/null
+++ b/.svelte-kit/ambient.d.ts
@@ -0,0 +1,454 @@
+
+// this file is generated — do not edit it
+
+
+///
+
+/**
+ * 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;
+ }
+}
diff --git a/.svelte-kit/env.d.ts b/.svelte-kit/env.d.ts
new file mode 100644
index 0000000..d7f6c97
--- /dev/null
+++ b/.svelte-kit/env.d.ts
@@ -0,0 +1 @@
+// See https://svelte.dev/docs/kit/environment-variables for more information
\ No newline at end of file
diff --git a/.svelte-kit/generated/client/app.js b/.svelte-kit/generated/client/app.js
new file mode 100644
index 0000000..ceb58ac
--- /dev/null
+++ b/.svelte-kit/generated/client/app.js
@@ -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);
\ No newline at end of file
diff --git a/.svelte-kit/generated/client/matchers.js b/.svelte-kit/generated/client/matchers.js
new file mode 100644
index 0000000..f6bd30a
--- /dev/null
+++ b/.svelte-kit/generated/client/matchers.js
@@ -0,0 +1 @@
+export const matchers = {};
\ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/0.js b/.svelte-kit/generated/client/nodes/0.js
new file mode 100644
index 0000000..790fa02
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/0.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../frontend/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/layout.svelte";
\ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/1.js b/.svelte-kit/generated/client/nodes/1.js
new file mode 100644
index 0000000..8a020ca
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/1.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../frontend/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte";
\ No newline at end of file
diff --git a/.svelte-kit/generated/shared/error-template.js b/.svelte-kit/generated/shared/error-template.js
new file mode 100644
index 0000000..f66e595
--- /dev/null
+++ b/.svelte-kit/generated/shared/error-template.js
@@ -0,0 +1 @@
+export default ({ status, message }) => "\n\n\t
\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t\n\t\t\t
" + status + "\n\t\t\t
\n\t\t\t\t
" + message + "
\n\t\t\t\n\t\t
\n\t\n\n";
\ No newline at end of file
diff --git a/.svelte-kit/tsconfig.json b/.svelte-kit/tsconfig.json
new file mode 100644
index 0000000..f727b03
--- /dev/null
+++ b/.svelte-kit/tsconfig.json
@@ -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"
+ ]
+}
\ No newline at end of file
diff --git a/WORKER-CONTEXT.md b/WORKER-CONTEXT.md
new file mode 100644
index 0000000..f07c094
--- /dev/null
+++ b/WORKER-CONTEXT.md
@@ -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.
diff --git a/frontend/src/lib/core/axis.js b/frontend/src/lib/core/axis.js
new file mode 100644
index 0000000..97f0dc2
--- /dev/null
+++ b/frontend/src/lib/core/axis.js
@@ -0,0 +1,286 @@
+// 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;
+ const newAxis = rotateAxis(state.axis, angle);
+ return { ...state, axis: newAxis, approved: false };
+ }
+ case 'approve': {
+ return { ...state, approved: true };
+ }
+ default:
+ return state;
+ }
+}
\ No newline at end of file
diff --git a/frontend/tests/e2e/axis.spec.js b/frontend/tests/e2e/axis.spec.js
new file mode 100644
index 0000000..f399e14
--- /dev/null
+++ b/frontend/tests/e2e/axis.spec.js
@@ -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();
+ });
+});
diff --git a/frontend/tests/unit/axis.test.js b/frontend/tests/unit/axis.test.js
new file mode 100644
index 0000000..8ac01af
--- /dev/null
+++ b/frontend/tests/unit/axis.test.js
@@ -0,0 +1,358 @@
+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 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);
+ });
+});