mirror: fieldlens d38e0922441a (1/2)
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
Admin 2026-09-23 01:14:28 +00:00
parent 442e3e4139
commit da34221539
100 changed files with 9430 additions and 2 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
**/node_modules
**/dist
.git
.env
.env.*
!.env.example
*.log

11
.env.example Normal file
View File

@ -0,0 +1,11 @@
FIELDLENS_ENV=development
LOG_LEVEL=info
PORT=3000
DATABASE_URL=postgresql://user:password@host:5432/database
KEYCLOAK_ISSUER=https://auth.example.invalid/realms/example
KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs
KEYCLOAK_AUDIENCE=fieldlens-api
KEYCLOAK_BROWSER_CLIENT_ID=fieldlens
KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration
FIELDLENS_TENANT_ID=fieldlens-production
WEB_ORIGIN=https://fieldlens.example.invalid

21
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,21 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm install
- run: npm run test
- run: npm run lint
- run: npm run migration:validate
- run: npm run build
- run: docker build -t fieldlens-ci .

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
coverage/
.env
.env.*
!.env.example
*.log
.DS_Store

47
Dockerfile Normal file
View File

@ -0,0 +1,47 @@
FROM node:24-alpine AS build
WORKDIR /workspace
ARG VITE_API_BASE_URL=""
ARG VITE_KEYCLOAK_ISSUER=""
ARG VITE_KEYCLOAK_CLIENT_ID="fieldlens"
ARG FIELDLENS_VERSION="dev"
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_KEYCLOAK_ISSUER=$VITE_KEYCLOAK_ISSUER
ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID
ENV VITE_APP_VERSION=$FIELDLENS_VERSION
COPY package.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/contracts/package.json packages/contracts/package.json
COPY packages/fieldlens-core/package.json packages/fieldlens-core/package.json
RUN npm install
COPY . .
RUN npm run verify
RUN npm prune --omit=dev
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV STATIC_DIR=/app/web
COPY --from=build /workspace/package.json /app/package.json
COPY --from=build /workspace/node_modules /app/node_modules
COPY --from=build /workspace/apps/api/package.json /app/apps/api/package.json
COPY --from=build /workspace/apps/api/dist /app/apps/api/dist
COPY --from=build /workspace/packages/contracts/package.json /app/packages/contracts/package.json
COPY --from=build /workspace/packages/contracts/dist /app/packages/contracts/dist
COPY --from=build /workspace/apps/web/dist /app/web
USER node
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=5 \
CMD wget -qO- http://127.0.0.1:3000/api/health/live >/dev/null || exit 1
CMD ["node", "apps/api/dist/server.js"]

View File

@ -1,3 +1,35 @@
# fieldlens-deploy
# FieldLens
Credential-free deployment mirror of private Admin/fieldlens. No secrets.
FieldLens is the Arrowstack-hosted production application around the reusable, local-first FieldLens Core.
## Repository boundary
- `apps/web` owns Arrowstack browser authentication, sync, station/device status and the production PWA shell.
- `apps/api` owns JWT/RBAC, sync ingestion, PostgreSQL, device/station registration and server-side provider adapters.
- `packages/contracts` is the versioned network contract shared by the browser and API.
- `packages/fieldlens-core` is a pinned vendor copy of the public reusable core.
- `migrations` contains versioned PostgreSQL schema changes.
The public source of truth is `dmparrow/feildlens-core` (spelling intentional). The initial vendor import is pinned to commit:
`90d38751eea2bed79f4c9f54c75687ea9aba350a`
The private prototype `dmparrow/net` is not a production dependency and is not modified by this repository.
## Local-first invariant
Capture writes to IndexedDB first. Remote authentication, API availability, PostgreSQL availability and reference-data availability are not prerequisites for scanner, EAN pallet, OCR/conformity or local review workflows.
## Configuration
See the root and app-specific `.env.example` files. Never commit production secrets.
## Validation
```bash
npm install
npm run verify
docker build -t fieldlens .
```
Production migrations are applied explicitly before application deployment. The API runtime account is not expected to perform schema DDL on startup.

12
apps/api/.env.example Normal file
View File

@ -0,0 +1,12 @@
PORT=3000
DATABASE_URL=postgresql://user:password@host:5432/database
KEYCLOAK_ISSUER=https://auth.example.invalid/realms/example
KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs
KEYCLOAK_AUDIENCE=fieldlens-api
KEYCLOAK_BROWSER_CLIENT_ID=fieldlens
KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration
FIELDLENS_TENANT_ID=fieldlens-production
FIELDLENS_ENV=development
LOG_LEVEL=info
WEB_ORIGIN=http://localhost:5173
STATIC_DIR=

29
apps/api/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "@fieldlens/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/server.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "oxlint src",
"test": "DATABASE_URL=postgresql://test:test@localhost/test KEYCLOAK_ISSUER=https://auth.example.invalid/realms/test vitest run"
},
"dependencies": {
"@fieldlens/contracts": "1.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"jose": "^6.1.0",
"pg": "^8.16.3"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.13.3",
"@types/pg": "^8.15.5",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vitest": "^4.1.11"
}
}

15
apps/api/src/auth.test.ts Normal file
View File

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { rolesFromPayload } from './auth.js'
describe('role extraction', () => {
it('collects FieldLens resource and realm roles without treating authentication as authorization', () => {
const roles = rolesFromPayload({
resource_access: {
'fieldlens-api': { roles: ['fieldlens-operator'] },
},
realm_access: { roles: ['offline_access'] },
})
expect(roles).toContain('fieldlens-operator')
expect(roles).toContain('offline_access')
})
})

90
apps/api/src/auth.ts Normal file
View File

@ -0,0 +1,90 @@
import type { NextFunction, Request, Response } from 'express'
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
import { config } from './config.js'
export type AuthIdentity = {
subject: string
username?: string
roles: string[]
clientId?: string
}
declare global {
namespace Express {
interface Request {
identity?: AuthIdentity
}
}
}
const jwks = createRemoteJWKSet(new URL(
config.jwksUri || `${config.issuer}/protocol/openid-connect/certs`,
))
function strings(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}
export function rolesFromPayload(payload: JWTPayload) {
const resourceAccess = payload.resource_access as Record<string, { roles?: unknown }> | undefined
const realmAccess = payload.realm_access as { roles?: unknown } | undefined
return [...new Set([
...strings(resourceAccess?.[config.audience]?.roles),
...strings(resourceAccess?.[config.browserClientId]?.roles),
...strings(resourceAccess?.[config.machineClientId]?.roles),
...strings(realmAccess?.roles),
])]
}
function intendedForFieldLens(payload: JWTPayload) {
const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []
const azp = typeof payload.azp === 'string' ? payload.azp : ''
return audience.includes(config.audience)
|| azp === config.browserClientId
|| azp === config.machineClientId
}
export async function authenticate(req: Request, res: Response, next: NextFunction) {
const header = req.header('authorization')
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'authentication_required' })
return
}
try {
const token = header.slice('Bearer '.length)
const { payload } = await jwtVerify(token, jwks, { issuer: config.issuer })
if (!intendedForFieldLens(payload) || !payload.sub) {
res.status(401).json({ error: 'invalid_token_target' })
return
}
req.identity = {
subject: payload.sub,
username: typeof payload.preferred_username === 'string' ? payload.preferred_username : undefined,
roles: rolesFromPayload(payload),
clientId: typeof payload.azp === 'string' ? payload.azp : undefined,
}
next()
} catch {
res.status(401).json({ error: 'invalid_token' })
}
}
const access: Record<string, string[]> = {
operator: ['fieldlens-operator', 'fieldlens-supervisor', 'fieldlens-admin', 'operator', 'admin', 'owner'],
supervisor: ['fieldlens-supervisor', 'fieldlens-admin', 'admin', 'owner'],
admin: ['fieldlens-admin', 'admin', 'owner'],
integration: ['fieldlens-integration', 'fieldlens-admin', 'admin', 'owner'],
}
export function requireAccess(level: keyof typeof access) {
return (req: Request, res: Response, next: NextFunction) => {
const roles = req.identity?.roles ?? []
const exactMachineIdentity = level === 'integration' && req.identity?.clientId === config.machineClientId
if (!exactMachineIdentity && !access[level].some((role) => roles.includes(role))) {
res.status(403).json({ error: 'forbidden' })
return
}
next()
}
}

20
apps/api/src/config.ts Normal file
View File

@ -0,0 +1,20 @@
function required(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`Missing required environment variable ${name}`)
return value
}
export const config = {
port: Number(process.env.PORT ?? '3000'),
databaseUrl: required('DATABASE_URL'),
issuer: required('KEYCLOAK_ISSUER').replace(/\/$/, ''),
jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(),
audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api',
browserClientId: process.env.KEYCLOAK_BROWSER_CLIENT_ID?.trim() || 'fieldlens',
machineClientId: process.env.KEYCLOAK_MACHINE_CLIENT_ID?.trim() || 'fieldlens-integration',
tenantId: process.env.FIELDLENS_TENANT_ID?.trim() || 'fieldlens-production',
environment: process.env.FIELDLENS_ENV?.trim() || 'development',
logLevel: process.env.LOG_LEVEL?.trim() || 'info',
webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173',
staticDir: process.env.STATIC_DIR?.trim() || '',
}

31
apps/api/src/context.ts Normal file
View File

@ -0,0 +1,31 @@
import type { PoolClient } from 'pg'
import { config } from './config.js'
export type DeviceContext = {
deviceId: string
stationId: string
siteId: string
tenantId: string
}
export async function loadDeviceContext(client: PoolClient, deviceId: string): Promise<DeviceContext | null> {
const result = await client.query<{
device_id: string
station_id: string
site_id: string
tenant_id: string
}>(`
SELECT d.id AS device_id, s.id AS station_id, si.id AS site_id, si.tenant_id
FROM devices d
JOIN stations s ON s.id = d.station_id
JOIN sites si ON si.id = s.site_id
WHERE d.id = $1 AND si.tenant_id = $2 AND s.active = true
`, [deviceId, config.tenantId])
const row = result.rows[0]
return row ? {
deviceId: row.device_id,
stationId: row.station_id,
siteId: row.site_id,
tenantId: row.tenant_id,
} : null
}

20
apps/api/src/db.ts Normal file
View File

@ -0,0 +1,20 @@
import pg from 'pg'
import { config } from './config.js'
const { Pool } = pg
export const pool = new Pool({
connectionString: config.databaseUrl,
max: 10,
application_name: 'fieldlens-api',
})
export async function databaseReady() {
const client = await pool.connect()
try {
await client.query('SELECT 1')
return true
} finally {
client.release()
}
}

11
apps/api/src/log.ts Normal file
View File

@ -0,0 +1,11 @@
export function log(level: 'info' | 'warn' | 'error', event: string, fields: Record<string, unknown> = {}) {
const safe = Object.fromEntries(Object.entries(fields).filter(([key]) =>
!/authorization|token|secret|password|database_url/i.test(key),
))
process.stdout.write(`${JSON.stringify({
time: new Date().toISOString(),
level,
event,
...safe,
})}\n`)
}

283
apps/api/src/server.ts Normal file
View File

@ -0,0 +1,283 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import cors from 'cors'
import express from 'express'
import helmet from 'helmet'
import { FIELDLENS_SCHEMA_VERSION, isSyncRequest, type DeviceRegistration } from '@fieldlens/contracts'
import { authenticate, requireAccess } from './auth.js'
import { config } from './config.js'
import { loadDeviceContext } from './context.js'
import { databaseReady, pool } from './db.js'
import { log } from './log.js'
import { processSyncEvent } from './sync.js'
const app = express()
app.disable('x-powered-by')
const keycloakOrigin = new URL(config.issuer).origin
app.use(helmet({
crossOriginEmbedderPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
mediaSrc: ["'self'", 'blob:'],
connectSrc: ["'self'", keycloakOrigin],
workerSrc: ["'self'", 'blob:'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
},
}))
app.use((_req, res, next) => {
res.setHeader('Permissions-Policy', 'camera=(self), microphone=(), geolocation=()')
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
next()
})
app.use(cors({
origin(origin, callback) {
if (!origin || origin === config.webOrigin) callback(null, true)
else callback(new Error('cors_origin_rejected'))
},
credentials: false,
}))
app.use(express.json({ limit: '1mb' }))
app.use((req, res, next) => {
const started = performance.now()
res.on('finish', () => log('info', 'http_request', {
method: req.method,
path: req.path,
status: res.statusCode,
durationMs: Math.round(performance.now() - started),
}))
next()
})
app.get('/api/health/live', (_req, res) => {
res.json({ status: 'live', version: process.env.FIELDLENS_VERSION ?? 'dev' })
})
app.get('/api/health/ready', async (_req, res) => {
try {
await databaseReady()
res.json({ status: 'ready', database: 'ready', version: process.env.FIELDLENS_VERSION ?? 'dev' })
} catch {
log('error', 'database_readiness_failed')
res.status(503).json({ status: 'not-ready', database: 'unavailable' })
}
})
app.use('/api/v1', authenticate)
app.get('/api/v1/me', requireAccess('operator'), (req, res) => {
res.json({
subject: req.identity!.subject,
username: req.identity!.username,
roles: req.identity!.roles.filter((role) => role.startsWith('fieldlens-')),
clientId: req.identity!.clientId,
})
})
app.get('/api/v1/stations', requireAccess('operator'), async (_req, res) => {
const result = await pool.query(`
SELECT s.id, s.site_id AS "siteId", s.name, s.station_type AS "stationType", s.active
FROM stations s
JOIN sites si ON si.id = s.site_id
WHERE si.tenant_id = $1 AND s.active = true
ORDER BY s.name
`, [config.tenantId])
res.json({ stations: result.rows })
})
app.post('/api/v1/devices/register', requireAccess('operator'), async (req, res) => {
const body = req.body as Partial<DeviceRegistration>
if (typeof body.installationId !== 'string' || body.schemaVersion !== FIELDLENS_SCHEMA_VERSION) {
res.status(400).json({ error: 'invalid_device_registration' })
return
}
const client = await pool.connect()
try {
const requestedStation = typeof body.stationId === 'string' ? body.stationId : null
const station = await client.query<{
id: string
siteId: string
name: string
stationType: string
active: boolean
}>(`
SELECT s.id, s.site_id AS "siteId", s.name, s.station_type AS "stationType", s.active
FROM stations s
JOIN sites si ON si.id = s.site_id
WHERE si.tenant_id = $1 AND s.active = true
AND ($2::uuid IS NULL OR s.id = $2::uuid)
ORDER BY CASE WHEN $2::uuid IS NOT NULL AND s.id = $2::uuid THEN 0 ELSE 1 END, s.created_at
LIMIT 1
`, [config.tenantId, requestedStation])
if (!station.rows[0]) {
res.status(409).json({ error: 'no_active_station' })
return
}
const deviceId = crypto.randomUUID()
const result = await client.query<{ id: string }>(`
INSERT INTO devices (id, station_id, installation_id, label, last_seen_at, app_version)
VALUES ($1,$2,$3,$4,now(),$5)
ON CONFLICT (installation_id) DO UPDATE SET
station_id = EXCLUDED.station_id,
label = COALESCE(EXCLUDED.label, devices.label),
last_seen_at = now(),
app_version = EXCLUDED.app_version
RETURNING id
`, [deviceId, station.rows[0].id, body.installationId, body.label ?? null, body.appVersion ?? null])
log('info', 'device_registered', { deviceId: result.rows[0]!.id, stationId: station.rows[0].id })
res.json({ id: result.rows[0]!.id, station: station.rows[0] })
} finally {
client.release()
}
})
app.post('/api/v1/sync', requireAccess('operator'), async (req, res) => {
if (!isSyncRequest(req.body)) {
res.status(400).json({ error: 'invalid_sync_request' })
return
}
const client = await pool.connect()
try {
const context = await loadDeviceContext(client, req.body.deviceId)
if (!context) {
res.status(403).json({ error: 'device_not_registered_for_tenant' })
return
}
const acknowledgements = []
for (const event of req.body.events) {
acknowledgements.push(await processSyncEvent(client, event, context, req.identity!))
}
await client.query('UPDATE devices SET last_seen_at = now() WHERE id = $1', [context.deviceId])
const accepted = acknowledgements.filter((ack) => ack.status === 'accepted').length
const duplicate = acknowledgements.filter((ack) => ack.status === 'duplicate').length
const rejected = acknowledgements.filter((ack) => ack.status === 'rejected').length
log('info', 'sync_batch', { deviceId: context.deviceId, size: acknowledgements.length, accepted, duplicate, rejected })
res.json({ schemaVersion: FIELDLENS_SCHEMA_VERSION, acknowledgements })
} finally {
client.release()
}
})
app.get('/api/v1/scans', requireAccess('supervisor'), async (req, res) => {
const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100)))
const result = await pool.query(`
SELECT id, station_id AS "stationId", device_id AS "deviceId", raw_value AS "rawValue",
format, duplicate, captured_at AS "capturedAt", received_at AS "receivedAt"
FROM scans WHERE tenant_id = $1
ORDER BY captured_at DESC LIMIT $2
`, [config.tenantId, limit])
res.json({ scans: result.rows })
})
app.get('/api/v1/pallet-sessions/:id', requireAccess('supervisor'), async (req, res) => {
const session = await pool.query(`
SELECT ps.* FROM pallet_sessions ps
WHERE ps.id = $1 AND ps.tenant_id = $2
`, [req.params.id, config.tenantId])
if (!session.rows[0]) {
res.status(404).json({ error: 'not_found' })
return
}
const observations = await pool.query(
'SELECT * FROM pallet_observations WHERE pallet_session_id = $1 ORDER BY carton_index',
[req.params.id],
)
const exceptions = await pool.query(
'SELECT * FROM pallet_exceptions WHERE pallet_session_id = $1 ORDER BY created_at',
[req.params.id],
)
res.json({ session: session.rows[0], observations: observations.rows, exceptions: exceptions.rows })
})
app.get('/api/v1/reference/labels/:ean', requireAccess('operator'), async (req, res) => {
const started = performance.now()
try {
const result = await pool.query<{ fields_json: Record<string, unknown> }>(`
SELECT fields_json FROM reference_label_specs
WHERE tenant_id = $1 AND ean = $2 AND active = true
ORDER BY updated_at DESC
`, [config.tenantId, req.params.ean])
res.json({ rows: result.rows.map((row) => row.fields_json) })
log('info', 'reference_lookup', {
ean: req.params.ean,
rows: result.rowCount,
durationMs: Math.round(performance.now() - started),
})
} catch {
log('error', 'reference_lookup_failed', {
ean: req.params.ean,
durationMs: Math.round(performance.now() - started),
})
res.status(503).json({ error: 'reference_provider_unavailable' })
}
})
app.get('/api/v1/ops/summary', requireAccess('supervisor'), async (_req, res) => {
const [stations, devices, scans, sessions, exceptions] = await Promise.all([
pool.query('SELECT count(*)::int AS count FROM stations s JOIN sites si ON si.id=s.site_id WHERE si.tenant_id=$1 AND s.active=true', [config.tenantId]),
pool.query('SELECT count(*)::int AS count, max(last_seen_at) AS last_seen FROM devices d JOIN stations s ON s.id=d.station_id JOIN sites si ON si.id=s.site_id WHERE si.tenant_id=$1', [config.tenantId]),
pool.query("SELECT count(*)::int AS count FROM scans WHERE tenant_id=$1 AND captured_at > now() - interval '24 hours'", [config.tenantId]),
pool.query("SELECT count(*)::int AS count FROM pallet_sessions WHERE tenant_id=$1 AND started_at > now() - interval '24 hours'", [config.tenantId]),
pool.query('SELECT count(*)::int AS count FROM pallet_exceptions pe JOIN pallet_sessions ps ON ps.id=pe.pallet_session_id WHERE ps.tenant_id=$1', [config.tenantId]),
])
res.json({
stations: stations.rows[0],
devices: devices.rows[0],
scans24h: scans.rows[0],
palletSessions24h: sessions.rows[0],
conformityExceptions: exceptions.rows[0],
})
})
if (config.staticDir) {
const staticDir = path.resolve(config.staticDir)
app.use(express.static(staticDir, {
etag: true,
maxAge: '1h',
setHeaders(res, filename) {
if (filename.endsWith('sw.js') || filename.includes('workbox-')) {
res.setHeader('Cache-Control', 'no-cache')
}
},
}))
app.use((req, res, next) => {
if (req.method !== 'GET' || req.path.startsWith('/api/')) return next()
res.setHeader('Cache-Control', 'no-cache')
res.sendFile(path.join(staticDir, 'index.html'))
})
}
const server = app.listen(config.port, '0.0.0.0', () => {
log('info', 'server_started', {
port: config.port,
environment: config.environment,
version: process.env.FIELDLENS_VERSION ?? 'dev',
})
})
async function shutdown(signal: string) {
log('info', 'shutdown_started', { signal })
server.close(async () => {
await pool.end()
process.exit(0)
})
setTimeout(() => process.exit(1), 10_000).unref()
}
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
export { app }

174
apps/api/src/sync.ts Normal file
View File

@ -0,0 +1,174 @@
import { randomUUID } from 'node:crypto'
import type { PoolClient } from 'pg'
import type { OutboxEvent, SyncAcknowledgement } from '@fieldlens/contracts'
import type { AuthIdentity } from './auth.js'
import type { DeviceContext } from './context.js'
async function insertScan(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) {
const payload = event.payload as Record<string, unknown>
await client.query(`
INSERT INTO scans (
id, tenant_id, site_id, station_id, device_id, operator_subject,
raw_value, format, duplicate, captured_at, schema_version
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (id) DO NOTHING
`, [
payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId,
identity.subject, payload.rawValue, payload.format, Boolean(payload.duplicate),
payload.capturedAt, event.schemaVersion,
])
}
async function startPallet(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) {
const payload = event.payload as Record<string, unknown>
await client.query(`
INSERT INTO pallet_sessions (
id, tenant_id, site_id, station_id, device_id, operator_subject,
workflow_type, target_count, started_at, status, schema_version
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'active',$10)
ON CONFLICT (id) DO NOTHING
`, [
payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId,
identity.subject, payload.workflowType, payload.targetCount, payload.startedAt, event.schemaVersion,
])
}
async function insertObservation(client: PoolClient, event: OutboxEvent) {
const payload = event.payload as Record<string, unknown>
await client.query(`
INSERT INTO pallet_observations (
id, pallet_session_id, carton_index, ean, fields_json, raw_text,
confidence, captured_at, schema_version
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (id) DO NOTHING
`, [
payload.id,
payload.palletSessionId,
payload.cartonIndex,
payload.ean ?? null,
payload.fieldsJson ? JSON.stringify(payload.fieldsJson) : null,
payload.rawText ?? null,
payload.confidence ?? null,
payload.capturedAt,
event.schemaVersion,
])
}
async function writeExceptions(client: PoolClient, event: OutboxEvent) {
const payload = event.payload as Record<string, unknown>
const sessionId = String(payload.id)
const workflow = String(payload.workflowType ?? '')
if (workflow === 'ean' && typeof payload.consensusEan === 'string') {
const observations = await client.query<{ id: string; ean: string | null }>(
'SELECT id, ean FROM pallet_observations WHERE pallet_session_id = $1',
[sessionId],
)
for (const observation of observations.rows) {
if (observation.ean && observation.ean !== payload.consensusEan) {
await client.query(`
INSERT INTO pallet_exceptions (
id, pallet_session_id, observation_id, field, expected_value, actual_value, exception_type
) VALUES ($1,$2,$3,'ean',$4,$5,'mismatch')
`, [randomUUID(), sessionId, observation.id, payload.consensusEan, observation.ean])
}
}
}
if (workflow === 'carton-label' && Array.isArray(payload.majorityFields)) {
const observations = await client.query<{ id: string; fields_json: Record<string, unknown> | null }>(
'SELECT id, fields_json FROM pallet_observations WHERE pallet_session_id = $1',
[sessionId],
)
const majorities = payload.majorityFields as Array<Record<string, unknown>>
for (const observation of observations.rows) {
for (const majority of majorities) {
const field = typeof majority.field === 'string' ? majority.field : ''
const expected = typeof majority.value === 'string' ? majority.value : ''
if (!field || !expected) continue
const actualValue = observation.fields_json?.[field]
const actual = typeof actualValue === 'string' ? actualValue : ''
if (actual.trim().toLocaleLowerCase() !== expected.trim().toLocaleLowerCase()) {
await client.query(`
INSERT INTO pallet_exceptions (
id, pallet_session_id, observation_id, field, expected_value, actual_value, exception_type
) VALUES ($1,$2,$3,$4,$5,$6,$7)
`, [randomUUID(), sessionId, observation.id, field, expected, actual || null, actual ? 'mismatch' : 'unread'])
}
}
}
}
}
async function completePallet(client: PoolClient, event: OutboxEvent) {
const payload = event.payload as Record<string, unknown>
const result = await client.query(`
UPDATE pallet_sessions SET
completed_at = $2,
status = 'complete',
conforms = $3,
result_status = $4
WHERE id = $1
`, [
payload.id,
payload.completedAt,
typeof payload.conforms === 'boolean' ? payload.conforms : null,
payload.resultStatus ?? null,
])
if (result.rowCount !== 1) throw new Error('pallet_session_not_found')
await writeExceptions(client, event)
}
async function applyDomainEvent(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) {
switch (event.eventType) {
case 'scan.captured':
await insertScan(client, event, context, identity)
return
case 'pallet.session.started':
await startPallet(client, event, context, identity)
return
case 'pallet.observation.captured':
await insertObservation(client, event)
return
case 'pallet.session.completed':
await completePallet(client, event)
return
default:
throw new Error('unsupported_event_type')
}
}
export async function processSyncEvent(
client: PoolClient,
event: OutboxEvent,
context: DeviceContext,
identity: AuthIdentity,
): Promise<SyncAcknowledgement> {
await client.query('BEGIN')
try {
const inserted = await client.query(
`INSERT INTO sync_events (id, aggregate_id, event_type, schema_version, payload, status)
VALUES ($1,$2,$3,$4,$5,'processing')
ON CONFLICT (id) DO NOTHING
RETURNING id`,
[event.id, event.aggregateId, event.eventType, event.schemaVersion, JSON.stringify(event.payload)],
)
if (inserted.rowCount === 0) {
await client.query('COMMIT')
return { eventId: event.id, status: 'duplicate' }
}
await applyDomainEvent(client, event, context, identity)
await client.query("UPDATE sync_events SET status = 'accepted' WHERE id = $1", [event.id])
await client.query('COMMIT')
return { eventId: event.id, status: 'accepted' }
} catch (error) {
await client.query('ROLLBACK')
return {
eventId: event.id,
status: 'rejected',
error: error instanceof Error ? error.message : 'sync_event_failed',
}
}
}

14
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

4
apps/web/.env.example Normal file
View File

@ -0,0 +1,4 @@
VITE_API_BASE_URL=
VITE_KEYCLOAK_ISSUER=https://auth.example.invalid/realms/example
VITE_KEYCLOAK_CLIENT_ID=fieldlens
VITE_APP_VERSION=dev

15
apps/web/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#17211f" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>FieldLens</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
apps/web/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "@fieldlens/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit -p tsconfig.json && vite build",
"lint": "oxlint src",
"test": "vitest run"
},
"dependencies": {
"@fieldlens/contracts": "1.0.0",
"@fieldlens/core": "0.1.0",
"dexie-react-hooks": "^4.4.0",
"keycloak-js": "^26.2.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.11"
}
}

View File

@ -0,0 +1,34 @@
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect, useState } from 'react'
import { database, getLocalMetadata, type OutboxRecord } from '../../../packages/fieldlens-core/src/lib/database'
import { initializeAuth, keycloak, login, logout } from './auth'
import type { RegisteredDevice } from './sync'
export function HostStatus() {
const pending = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt).count(), []) ?? 0
const errors = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt && Boolean(event.lastError)).count(), []) ?? 0
const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated))
const [station, setStation] = useState('Local / unassigned')
useEffect(() => {
void initializeAuth().then((ok) => setAuthenticated(ok))
const refreshStation = () => {
void getLocalMetadata<RegisteredDevice>('registeredDevice').then((device) => {
if (device?.station?.name) setStation(device.station.name)
})
}
refreshStation()
const timer = window.setInterval(refreshStation, 5000)
return () => window.clearInterval(timer)
}, [])
return (
<aside className="host-status" aria-label="Arrowstack synchronization status">
<span><strong>{station}</strong><small>{pending} pending{errors ? ` · ${errors} retrying` : ''}</small></span>
<button type="button" onClick={() => void (authenticated ? logout() : login())}>
{authenticated ? 'Sign out' : 'Sign in to sync'}
</button>
</aside>
)
}

54
apps/web/src/auth.ts Normal file
View File

@ -0,0 +1,54 @@
import Keycloak from 'keycloak-js'
function parseIssuer(issuer: string) {
const marker = '/realms/'
const index = issuer.lastIndexOf(marker)
if (index < 0) throw new Error('VITE_KEYCLOAK_ISSUER must contain /realms/')
return {
url: issuer.slice(0, index),
realm: issuer.slice(index + marker.length).replace(/\/$/, ''),
}
}
const issuer = import.meta.env.VITE_KEYCLOAK_ISSUER ?? ''
const clientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'fieldlens'
const parsed = issuer ? parseIssuer(issuer) : null
export const keycloak = parsed
? new Keycloak({ url: parsed.url, realm: parsed.realm, clientId })
: null
let initialization: Promise<boolean> | null = null
export function initializeAuth() {
if (!keycloak) return Promise.resolve(false)
initialization ??= keycloak.init({
onLoad: 'check-sso',
pkceMethod: 'S256',
checkLoginIframe: false,
}).catch(() => false)
return initialization
}
export async function getAccessToken() {
if (!keycloak) return null
await initializeAuth()
if (!keycloak.authenticated) return null
try {
await keycloak.updateToken(30)
return keycloak.token ?? null
} catch {
return null
}
}
export async function login() {
if (!keycloak) return
await initializeAuth()
await keycloak.login({ redirectUri: window.location.href })
}
export async function logout() {
if (!keycloak) return
await keycloak.logout({ redirectUri: window.location.origin })
}

1
apps/web/src/host.css Normal file
View File

@ -0,0 +1 @@
.host-status{position:fixed;z-index:80;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;align-items:center;gap:10px;padding:7px 8px 7px 11px;border:1px solid #314039;border-radius:10px;background:#17211fe8;color:#fff;box-shadow:0 5px 20px #0003;backdrop-filter:blur(10px);font:11px/1.2 system-ui,sans-serif}.host-status span{display:grid;gap:2px;min-width:0}.host-status strong{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.host-status small{color:#aebbb6}.host-status button{border:0;border-radius:7px;padding:7px 9px;background:#f4d35e;color:#17211f;font-weight:800}@media(max-width:760px){.host-status{top:max(7px,env(safe-area-inset-top));right:max(7px,env(safe-area-inset-right));max-width:75vw}.host-status strong{max-width:100px}.host-status button{padding:6px 8px;font-size:9px}}

26
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '../../../packages/fieldlens-core/src/index.css'
import '../../../packages/fieldlens-core/src/ui-polish.css'
import '../../../packages/fieldlens-core/src/ios-safe-area.css'
import '../../../packages/fieldlens-core/src/phone-landscape.css'
import '../../../packages/fieldlens-core/src/detection-rotation.css'
import './host.css'
import CoreApp from '../../../packages/fieldlens-core/src/App'
import { startDeviceHoldTracking } from '../../../packages/fieldlens-core/src/lib/deviceOrientation'
import { HostStatus } from './HostStatus'
import { registerArrowstackReferenceProvider } from './referenceProvider'
import { installSyncTriggers } from './sync'
startDeviceHoldTracking()
registerArrowstackReferenceProvider()
installSyncTriggers()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<CoreApp />
<HostStatus />
</StrictMode>,
)

View File

@ -0,0 +1,15 @@
import { registerLabelExpectationProvider } from '../../../packages/fieldlens-core/src/lib/labelExpectationProviders'
import { authenticatedFetch } from './sync'
export function registerArrowstackReferenceProvider() {
return registerLabelExpectationProvider({
id: 'arrowstack-api',
label: 'Arrowstack reference data',
isAvailable: () => true,
async lookup(ean) {
const response = await authenticatedFetch(`/api/v1/reference/labels/${encodeURIComponent(ean)}`)
const data = await response.json() as { rows?: unknown[] }
return Array.isArray(data.rows) ? data.rows as never[] : []
},
})
}

View File

@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
describe('sync contract', () => {
it('keeps capture independent from browser connectivity state', () => {
expect(typeof navigator === 'undefined' || typeof navigator.onLine === 'boolean').toBe(true)
})
})

117
apps/web/src/sync.ts Normal file
View File

@ -0,0 +1,117 @@
import {
FIELDLENS_SCHEMA_VERSION,
type DeviceRegistration,
type Station,
type SyncResponse,
} from '@fieldlens/contracts'
import {
acknowledgeOutboxEvent,
failOutboxEvent,
getInstallationId,
getLocalMetadata,
getPendingOutbox,
setLocalMetadata,
} from '../../../packages/fieldlens-core/src/lib/database'
import { getAccessToken } from './auth'
const apiBase = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
export type RegisteredDevice = {
id: string
station: Station
}
async function authenticatedFetch(path: string, init: RequestInit = {}) {
const token = await getAccessToken()
if (!token) throw new Error('Authentication unavailable')
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${token}`)
if (init.body) headers.set('Content-Type', 'application/json')
const response = await fetch(`${apiBase}${path}`, { ...init, headers })
if (!response.ok) throw new Error(`API ${response.status}`)
return response
}
export async function ensureDeviceRegistered(): Promise<RegisteredDevice | null> {
const existing = await getLocalMetadata<RegisteredDevice>('registeredDevice')
const token = await getAccessToken()
if (!token) return existing ?? null
const installationId = await getInstallationId()
const registration: DeviceRegistration = {
installationId,
appVersion: import.meta.env.VITE_APP_VERSION ?? 'dev',
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}
try {
const response = await authenticatedFetch('/api/v1/devices/register', {
method: 'POST',
body: JSON.stringify(registration),
})
const next = await response.json() as RegisteredDevice
await setLocalMetadata('registeredDevice', next)
return next
} catch {
return existing ?? null
}
}
let syncInFlight: Promise<void> | null = null
export function syncNow() {
if (syncInFlight) return syncInFlight
syncInFlight = runSync().finally(() => { syncInFlight = null })
return syncInFlight
}
async function runSync() {
const device = await ensureDeviceRegistered()
if (!device) return
const events = await getPendingOutbox(50)
if (events.length === 0) return
try {
const response = await authenticatedFetch('/api/v1/sync', {
method: 'POST',
body: JSON.stringify({
deviceId: device.id,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
events: events.map(({ attempts: _attempts, nextAttemptAt: _next, syncedAt: _synced, lastError: _error, ...event }) => event),
}),
})
const body = await response.json() as SyncResponse
const acknowledgements = new Map(body.acknowledgements.map((ack) => [ack.eventId, ack]))
for (const event of events) {
const acknowledgement = acknowledgements.get(event.id)
if (acknowledgement?.status === 'accepted' || acknowledgement?.status === 'duplicate') {
await acknowledgeOutboxEvent(event.id)
} else {
await failOutboxEvent(event.id, acknowledgement?.error ?? 'Event was not acknowledged')
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Sync request failed'
await Promise.all(events.map((event) => failOutboxEvent(event.id, message)))
}
}
export function installSyncTriggers() {
const attempt = () => { void syncNow() }
const onVisible = () => {
if (document.visibilityState === 'visible') attempt()
}
window.addEventListener('online', attempt)
document.addEventListener('visibilitychange', onVisible)
const interval = window.setInterval(attempt, 30_000)
attempt()
return () => {
window.removeEventListener('online', attempt)
document.removeEventListener('visibilitychange', onVisible)
window.clearInterval(interval)
}
}
export { authenticatedFetch }

20
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "../../packages/fieldlens-core/src"]
}

34
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,34 @@
import { fileURLToPath, URL } from 'node:url'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
publicDir: fileURLToPath(new URL('../../packages/fieldlens-core/public', import.meta.url)),
plugins: [
react(),
VitePWA({
registerType: 'prompt',
includeAssets: ['fieldlens-mark.svg', 'apple-touch-icon.png', 'pwa-192.png', 'pwa-512.png'],
manifest: {
name: 'FieldLens Capture',
short_name: 'FieldLens',
description: 'Local-first barcode, pallet and carton-label capture.',
theme_color: '#17211f',
background_color: '#f7f7f2',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
workbox: {
cleanupOutdatedCaches: true,
navigateFallback: '/index.html',
globPatterns: ['**/*.{js,css,html,svg,wasm,png}']
}
})
]
})

42
docker-compose.yml Normal file
View File

@ -0,0 +1,42 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: fieldlens
POSTGRES_USER: fieldlens
POSTGRES_PASSWORD: fieldlens-local-only
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fieldlens"]
interval: 5s
timeout: 3s
retries: 10
volumes:
- fieldlens-pg:/var/lib/postgresql/data
fieldlens:
build:
context: .
args:
VITE_API_BASE_URL: ""
VITE_KEYCLOAK_ISSUER: ${VITE_KEYCLOAK_ISSUER:-https://auth.example.invalid/realms/example}
VITE_KEYCLOAK_CLIENT_ID: fieldlens
FIELDLENS_VERSION: local
ports:
- "3000:3000"
environment:
PORT: "3000"
DATABASE_URL: postgresql://fieldlens:fieldlens-local-only@postgres:5432/fieldlens
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER:-https://auth.example.invalid/realms/example}
KEYCLOAK_AUDIENCE: fieldlens-api
KEYCLOAK_BROWSER_CLIENT_ID: fieldlens
KEYCLOAK_MACHINE_CLIENT_ID: fieldlens-integration
FIELDLENS_TENANT_ID: fieldlens-production
FIELDLENS_ENV: local
WEB_ORIGIN: http://localhost:3000
STATIC_DIR: /app/web
depends_on:
postgres:
condition: service_healthy
volumes:
fieldlens-pg:

122
migrations/001_initial.sql Normal file
View File

@ -0,0 +1,122 @@
CREATE TABLE IF NOT EXISTS tenants (
id text PRIMARY KEY,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sites (
id uuid PRIMARY KEY,
tenant_id text NOT NULL REFERENCES tenants(id),
name text NOT NULL,
timezone text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS stations (
id uuid PRIMARY KEY,
site_id uuid NOT NULL REFERENCES sites(id),
name text NOT NULL,
station_type text NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS devices (
id uuid PRIMARY KEY,
station_id uuid NOT NULL REFERENCES stations(id),
installation_id uuid NOT NULL UNIQUE,
label text,
last_seen_at timestamptz,
app_version text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS scans (
id uuid PRIMARY KEY,
tenant_id text NOT NULL REFERENCES tenants(id),
site_id uuid NOT NULL REFERENCES sites(id),
station_id uuid NOT NULL REFERENCES stations(id),
device_id uuid NOT NULL REFERENCES devices(id),
operator_subject text NOT NULL,
raw_value text NOT NULL,
format text NOT NULL,
duplicate boolean NOT NULL DEFAULT false,
captured_at timestamptz NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
schema_version integer NOT NULL
);
CREATE TABLE IF NOT EXISTS pallet_sessions (
id uuid PRIMARY KEY,
tenant_id text NOT NULL REFERENCES tenants(id),
site_id uuid NOT NULL REFERENCES sites(id),
station_id uuid NOT NULL REFERENCES stations(id),
device_id uuid NOT NULL REFERENCES devices(id),
operator_subject text NOT NULL,
workflow_type text NOT NULL,
target_count integer NOT NULL,
started_at timestamptz NOT NULL,
completed_at timestamptz,
status text NOT NULL,
conforms boolean,
result_status text,
schema_version integer NOT NULL DEFAULT 1,
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS pallet_observations (
id uuid PRIMARY KEY,
pallet_session_id uuid NOT NULL REFERENCES pallet_sessions(id) ON DELETE CASCADE,
carton_index integer NOT NULL,
ean text,
fields_json jsonb,
raw_text text,
confidence double precision,
captured_at timestamptz NOT NULL,
schema_version integer NOT NULL DEFAULT 1,
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS pallet_exceptions (
id uuid PRIMARY KEY,
pallet_session_id uuid NOT NULL REFERENCES pallet_sessions(id) ON DELETE CASCADE,
observation_id uuid REFERENCES pallet_observations(id) ON DELETE SET NULL,
field text NOT NULL,
expected_value text,
actual_value text,
exception_type text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sync_events (
id uuid PRIMARY KEY,
aggregate_id uuid NOT NULL,
event_type text NOT NULL,
schema_version integer NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS reference_label_specs (
id uuid PRIMARY KEY,
tenant_id text NOT NULL REFERENCES tenants(id),
ean text NOT NULL,
fields_json jsonb NOT NULL,
active boolean NOT NULL DEFAULT true,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sites_tenant ON sites(tenant_id);
CREATE INDEX IF NOT EXISTS idx_stations_site ON stations(site_id);
CREATE INDEX IF NOT EXISTS idx_devices_station ON devices(station_id);
CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_scans_tenant_captured ON scans(tenant_id, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_scans_station_captured ON scans(station_id, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_scans_device_captured ON scans(device_id, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_pallet_sessions_tenant_started ON pallet_sessions(tenant_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_pallet_sessions_station_started ON pallet_sessions(station_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_pallet_observations_session ON pallet_observations(pallet_session_id, carton_index);
CREATE INDEX IF NOT EXISTS idx_pallet_exceptions_session ON pallet_exceptions(pallet_session_id);
CREATE INDEX IF NOT EXISTS idx_sync_events_aggregate ON sync_events(aggregate_id);
CREATE INDEX IF NOT EXISTS idx_reference_label_specs_ean ON reference_label_specs(tenant_id, ean) WHERE active = true;

View File

@ -0,0 +1,11 @@
INSERT INTO tenants (id, name)
VALUES ('fieldlens-production', 'FieldLens Production')
ON CONFLICT (id) DO NOTHING;
INSERT INTO sites (id, tenant_id, name, timezone)
VALUES ('00000000-0000-4000-8000-000000000001', 'fieldlens-production', 'Production Site', 'Pacific/Auckland')
ON CONFLICT (id) DO NOTHING;
INSERT INTO stations (id, site_id, name, station_type, active)
VALUES ('00000000-0000-4000-8000-000000000101', '00000000-0000-4000-8000-000000000001', 'Unassigned', 'capture', true)
ON CONFLICT (id) DO NOTHING;

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "fieldlens",
"version": "0.1.0",
"private": true,
"type": "module",
"workspaces": [
"apps/*",
"packages/contracts",
"packages/fieldlens-core"
],
"scripts": {
"test": "npm run test --workspace @fieldlens/core && npm run test --workspace @fieldlens/web && npm run test --workspace @fieldlens/api",
"lint": "npm run lint --workspace @fieldlens/core && npm run lint --workspace @fieldlens/web && npm run lint --workspace @fieldlens/api",
"build": "npm run build --workspace @fieldlens/contracts && npm run build --workspace @fieldlens/core && npm run build --workspace @fieldlens/web && npm run build --workspace @fieldlens/api",
"migration:validate": "node scripts/validate-migrations.mjs",
"verify": "npm run test && npm run lint && npm run migration:validate && npm run build"
},
"engines": {
"node": ">=24"
}
}

View File

@ -0,0 +1,20 @@
{
"name": "@fieldlens/contracts",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json"
},
"devDependencies": {
"typescript": "6.0.2"
}
}

View File

@ -0,0 +1,116 @@
export const FIELDLENS_SCHEMA_VERSION = 1 as const
export type SchemaVersion = typeof FIELDLENS_SCHEMA_VERSION
export type ScanRecord = {
id: string
rawValue: string
format: string
duplicate: boolean
capturedAt: string
schemaVersion: SchemaVersion
}
export type PalletSession = {
id: string
workflowType: 'ean' | 'carton-label'
targetCount: number
startedAt: string
completedAt?: string
status: 'active' | 'complete'
conforms?: boolean
resultStatus?: 'pass' | 'fail' | 'review'
schemaVersion: SchemaVersion
}
export type PalletObservation = {
id: string
palletSessionId: string
cartonIndex: number
ean?: string
fieldsJson?: Record<string, unknown>
rawText?: string
confidence?: number
capturedAt: string
schemaVersion: SchemaVersion
}
export type PalletException = {
id: string
palletSessionId: string
observationId?: string
field: string
expectedValue?: string
actualValue?: string
exceptionType: string
schemaVersion: SchemaVersion
}
export type OutboxEvent = {
id: string
aggregateId: string
eventType:
| 'scan.captured'
| 'pallet.session.started'
| 'pallet.observation.captured'
| 'pallet.session.completed'
schemaVersion: SchemaVersion
payload: unknown
createdAt: string
}
export type DeviceRegistration = {
installationId: string
stationId?: string
label?: string
appVersion?: string
schemaVersion: SchemaVersion
}
export type Station = {
id: string
siteId: string
name: string
stationType: string
active: boolean
}
export type SyncRequest = {
deviceId: string
schemaVersion: SchemaVersion
events: OutboxEvent[]
}
export type SyncAcknowledgement = {
eventId: string
status: 'accepted' | 'duplicate' | 'rejected'
error?: string
}
export type SyncResponse = {
schemaVersion: SchemaVersion
acknowledgements: SyncAcknowledgement[]
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function isOutboxEvent(value: unknown): value is OutboxEvent {
if (!isObject(value)) return false
return typeof value.id === 'string'
&& typeof value.aggregateId === 'string'
&& typeof value.eventType === 'string'
&& value.schemaVersion === FIELDLENS_SCHEMA_VERSION
&& typeof value.createdAt === 'string'
&& 'payload' in value
}
export function isSyncRequest(value: unknown): value is SyncRequest {
if (!isObject(value)) return false
return typeof value.deviceId === 'string'
&& value.schemaVersion === FIELDLENS_SCHEMA_VERSION
&& Array.isArray(value.events)
&& value.events.length <= 100
&& value.events.every(isOutboxEvent)
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,2 @@
# FieldLens Core requires no environment variables by default.
# Host applications can define API/auth/provider configuration.

View File

@ -0,0 +1,19 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm install
- run: npm test
- run: npm run lint
- run: npm run build

24
packages/fieldlens-core/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

View File

@ -0,0 +1,545 @@
# Arrowstack Integration Plan
This document describes the planned path from the public, local-first FieldLens Core to a production FieldLens application running on Arrowstack.
The core repository stays reusable and infrastructure-neutral. Arrowstack-specific authentication, remote persistence, tenancy, integrations, deployment configuration, and operational tooling should live in the host application or deployment layer rather than being coupled into capture logic.
## Target architecture
```text
Arrowstack
│
Keycloak OIDC
│
▼
┌──────────────────────────────────────────────┐
│ FieldLens PWA │
│ │
│ React / Vite │
│ barcode scanner │
│ EAN pallet verification │
│ carton-label OCR / conformity │
│ IndexedDB │
│ │
│ local records ───────► durable sync outbox │
└──────────────────────────┬───────────────────┘
│ HTTPS
▼
┌──────────────────────────────────────────────┐
│ FieldLens API │
│ │
│ JWT validation / RBAC │
│ sync ingestion │
│ pallet sessions │
│ stations / devices │
│ reference-data provider boundary │
│ integration adapters │
└──────────────────────────┬───────────────────┘
│
┌─────────┴─────────┐
▼ ▼
PostgreSQL integrations
│
┌─────────┼─────────┐
▼ ▼ ▼
ERP reporting MCP
```
## Design principles
1. **Offline capture remains authoritative during operation.** Scanner, EAN, and OCR workflows must continue functioning without network access.
2. **Remote persistence is additive.** A failed login refresh, unavailable API, database outage, or integration outage must not stop local capture.
3. **No infrastructure secrets reach the browser.** Database, ERP, MCP, deployment, and provider credentials remain server-side.
4. **Sync is idempotent.** Replaying an outbox item must never create duplicate server records.
5. **The core stays portable.** Arrowstack integration should wrap or extend FieldLens Core rather than fork scanner logic.
6. **Human and machine authentication stay separate.** Browser sessions use OIDC Authorization Code + PKCE; service-to-service integrations use dedicated machine identities.
7. **Production deployment stays portable.** Container images should work under Arrowstack's current application platform and a later Kubernetes deployment without application redesign.
## Repository boundary
FieldLens Core should continue to contain:
- barcode decoding and validation
- camera/device handling
- EAN pallet logic
- label detection and OCR
- conformity calculations
- IndexedDB persistence
- offline UX
- provider interfaces
- pure tests
The Arrowstack host application should own:
- Keycloak configuration
- API implementation
- PostgreSQL persistence
- tenancy and RBAC
- sync/outbox transport
- station/device registration
- server-side integrations
- Docker/runtime configuration
- deployment manifests
- secrets
- monitoring and audit data
A likely production repository layout is:
```text
fieldlens/
├── apps/
│ ├── web/
│ │ └── consumes FieldLens Core
│ └── api/
│ ├── auth/
│ ├── sync/
│ ├── scans/
│ ├── pallets/
│ ├── stations/
│ └── integrations/
├── packages/
│ ├── contracts/
│ └── fieldlens-core/
├── migrations/
├── Dockerfile
└── README.md
```
The core can initially be consumed from Git and later published as a package if that becomes useful.
## Phase 1 — Stable core boundary
Goal: make the current browser application explicitly integration-neutral.
### Work
- Keep capture records written locally first.
- Preserve generated UUIDs as stable record identifiers.
- Remove direct service-specific sync from scanner code.
- Keep external reference lookups behind the provider registry.
- Define shared serializable contracts for scans, pallet sessions, OCR observations, and conformity results.
- Add schema/version information to records that will cross the network boundary.
### Acceptance criteria
- All existing capture workflows work with no backend configured.
- Tests, lint, and build pass.
- No remote provider is required to start or complete a scan.
- Public core contains no deployment credentials or account-specific integration secrets.
## Phase 2 — Durable sync outbox
Goal: make browser-to-server synchronization reliable and retry-safe.
The local model should evolve from a simple local/synced flag into an explicit outbox.
```text
capture
│
▼
IndexedDB transaction
├── domain record
└── outbox event
│
▼
connectivity available
│
▼
batch sync request
│
▼
idempotent API write
│
▼
acknowledge event IDs
│
▼
mark local events sent
```
### Suggested outbox record
```ts
type OutboxRecord = {
id: string
aggregateId: string
eventType: string
schemaVersion: number
payload: unknown
createdAt: string
attempts: number
nextAttemptAt?: string
syncedAt?: string
}
```
### API shape
```http
POST /api/v1/sync
Authorization: Bearer <access-token>
Content-Type: application/json
```
Example request:
```json
{
"deviceId": "device-uuid",
"events": [
{
"id": "event-uuid",
"eventType": "scan.captured",
"schemaVersion": 1,
"payload": {}
}
]
}
```
The server should use the event ID or domain record UUID as an idempotency key.
### Acceptance criteria
- Offline scans queue without error.
- Reconnecting automatically resumes sync.
- Replaying the same batch is safe.
- Partial batch failure does not lose successful acknowledgements.
- Operators can see pending/synced/error state without being blocked by it.
## Phase 3 — Arrowstack authentication
Goal: authenticate humans and machines without changing offline capture behavior.
### Browser authentication
Use Keycloak:
- Authorization Code flow
- PKCE
- short-lived access tokens
- refresh/session handling outside scanner logic
- API validates issuer, signature, expiry, and intended audience/client
The PWA should be able to launch and operate locally even when an existing authenticated session cannot immediately refresh. Remote sync waits until authentication is available.
### Machine authentication
Server-side integrations should use dedicated Keycloak service accounts and `client_credentials`.
Examples:
- FieldLens API to another Arrowstack service
- Arrowstack MCP to FieldLens API
- background integration workers
Machine credentials must never be exposed to the PWA.
### Initial roles
A minimal role model could be:
```text
fieldlens-operator
fieldlens-supervisor
fieldlens-admin
fieldlens-integration
```
Role enforcement belongs at the API boundary.
## Phase 4 — PostgreSQL data model
Goal: make synchronized capture data centrally queryable and auditable.
### Core hierarchy
```text
tenant
└── site
└── station
└── device
└── scan events
```
### Suggested entities
#### tenants
- id
- name
- created_at
#### sites
- id
- tenant_id
- name
- timezone
- created_at
#### stations
- id
- site_id
- name
- station_type
- active
- created_at
#### devices
- id
- station_id
- installation_id
- label
- last_seen_at
- app_version
#### scans
- id
- tenant_id
- site_id
- station_id
- device_id
- operator_subject
- raw_value
- format
- duplicate
- captured_at
- received_at
- schema_version
#### pallet_sessions
- id
- tenant_id
- site_id
- station_id
- device_id
- operator_subject
- workflow_type
- target_count
- started_at
- completed_at
- status
- conforms
- result_status
#### pallet_observations
- id
- pallet_session_id
- carton_index
- ean
- fields_json
- raw_text
- confidence
- captured_at
#### pallet_exceptions
- id
- pallet_session_id
- observation_id
- field
- expected_value
- actual_value
- exception_type
### Data ownership
Tenant/site/station identity should be derived from trusted server-side registration and token context where possible, rather than accepting arbitrary tenant identifiers from the browser.
## Phase 5 — Device and station registration
Goal: make a PWA installation identifiable without turning device identity into a secret.
On first run:
1. Generate a persistent installation UUID.
2. Store it in IndexedDB.
3. After login, register it with the API.
4. An authorized user assigns or confirms its station.
5. The API returns non-secret station metadata.
6. Every synced event includes the installation UUID.
A reset browser/device can register as a new installation without affecting historical records.
## Phase 6 — Reference-data providers
Goal: move environment-specific expected-label lookups behind server-side adapters.
FieldLens Core already exposes a provider boundary. The Arrowstack host can supply a provider backed by:
- PostgreSQL
- ERP API
- sorter/packhouse database
- Google Sheets
- another Arrowstack service
- a cached reference dataset
Preferred production flow:
```text
FieldLens Core
│
▼
host provider
│
▼
FieldLens API
│
├── cached reference data
├── ERP adapter
└── other server-side provider
```
The browser should not need provider credentials.
Reference lookup failure must degrade to local OCR/conformity rather than block the workflow.
## Phase 7 — API surface
Initial endpoints:
```http
GET /api/health/live
GET /api/health/ready
POST /api/v1/sync
GET /api/v1/me
GET /api/v1/stations
POST /api/v1/devices/register
GET /api/v1/scans
GET /api/v1/pallet-sessions/:id
GET /api/v1/reference/labels/:ean
```
Administrative write endpoints should be added only when needed.
## Phase 8 — Containerisation and Arrowstack deployment
Goal: make the application deployable as a normal Arrowstack workload.
### Web
Build the Vite application into static assets and serve them from a small web image or through the API image if a single-container deployment is preferred initially.
### API
Container requirements:
- non-root runtime user
- health endpoints
- graceful shutdown
- configuration through environment variables
- no baked-in secrets
- structured logs
- database migrations as an explicit deployment step
### Database
Provision PostgreSQL through Arrowstack's tenant/database tooling.
The runtime database role should have only the permissions needed by the FieldLens database. Schema migrations should use a migration role rather than requiring broad privileges from the normal API runtime identity.
### Deployment stages
1. Build image.
2. Run tests.
3. Run migrations.
4. Deploy API.
5. Verify readiness.
6. Deploy web.
7. Run a smoke sync from a test device.
8. Promote/retain the healthy revision.
The first production target can use Arrowstack's current application deployment platform. The container contract should stay compatible with a later Kubernetes/K3s deployment.
## Phase 9 — Observability and audit
Capture at least:
- API request/error rate
- sync batch success/failure
- pending client outbox count where available
- database health
- active installations
- last-seen station/device timestamps
- application version
- reference-provider latency/failures
Do not log:
- access tokens
- authorization headers
- Keycloak client secrets
- database URLs with passwords
- provider credentials
- complete environment dumps
Audit-sensitive actions such as station reassignment, administrative corrections, and integration changes should have actor, timestamp, and before/after metadata.
## Phase 10 — Arrowstack MCP integration
FieldLens can later expose a narrow machine-facing API or MCP surface for operational tooling.
Useful read operations:
- list active stations
- summarize recent scans
- show unsynced/error conditions
- fetch pallet conformity results
- list recent exceptions
Possible controlled write operations:
- register/disable a station
- update reference data
- acknowledge or annotate an exception
Machine access should use a dedicated Keycloak client and explicit FieldLens roles. MCP access must not imply unrestricted database access.
## Delivery sequence
Recommended implementation order:
```text
1. shared contracts
2. IndexedDB outbox
3. sync client
4. API skeleton + health
5. PostgreSQL migrations
6. idempotent /sync endpoint
7. Keycloak browser auth
8. device/station registration
9. reference provider API
10. Arrowstack container deployment
11. admin/operations views
12. MCP integration
```
This sequence gets durable central data working before adding secondary integrations or dashboards.
## First production milestone
The first Arrowstack-backed release is complete when:
- the PWA installs and works offline
- scans and pallet sessions persist locally first
- users authenticate through Keycloak when online
- queued data syncs idempotently to PostgreSQL
- a device is associated with a station
- the API enforces tenant/station access
- reference lookups can be supplied by the API but are optional
- no server credentials are present in browser bundles
- application images deploy through Arrowstack
- health checks, logs, and database migrations are operational
- CI runs tests, lint, and build before deployment
At that point FieldLens is no longer a standalone scanner with optional integrations; it is a local-first Arrowstack application whose capture path remains resilient when the platform or network is unavailable.

View File

@ -0,0 +1,62 @@
# FieldLens Core
FieldLens Core is the reusable, local-first capture engine extracted from the FieldLens prototype.
It provides browser-based barcode capture, EAN pallet verification, carton-label OCR/conformity workflows, offline IndexedDB storage, device/orientation handling, and PWA support without requiring a hosted backend.
## Goals
- Keep capture workflows usable offline.
- Keep scanner, OCR, validation, and persistence logic independent from any specific backend.
- Expose clear integration seams for APIs, ERPs, databases, and other providers.
- Avoid shipping service credentials or infrastructure-specific configuration in the browser.
## Stack
- React + TypeScript + Vite
- Dexie / IndexedDB
- ZXing
- Three.js
- Vite PWA
- Vitest
## Development
```bash
npm install
npm run dev
```
Camera access requires HTTPS except on `localhost`.
## Quality checks
```bash
npm test
npm run lint
npm run build
```
## Local-first model
```text
capture workflow
↓
IndexedDB
↓
integration boundary
├─ API / database
├─ ERP
├─ reporting
└─ custom provider
```
The public core intentionally contains no deployment-specific configuration and no direct account integration. Production hosts can add authentication, remote persistence, sync/outbox processing, tenancy, and provider adapters without making capture dependent on network availability.
## Arrowstack plan
See [ARROWSTACK_PLAN.md](./ARROWSTACK_PLAN.md) for the production integration plan covering Keycloak authentication, offline sync/outbox, PostgreSQL, station/device registration, deployment, observability, and MCP integration.
## Origin
Extracted from the private `dmparrow/net` FieldLens prototype. That repository remains unchanged and separate from this public core.

View File

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/fieldlens-mark.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#17211f" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="FieldLens" />
<meta name="description" content="Offline barcode capture and duplicate detection." />
<title>FieldLens Capture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,39 @@
{
"name": "@fieldlens/core",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@zxing/browser": "^0.2.1",
"@zxing/library": "^0.23.0",
"bwip-js": "^4.11.4",
"dexie": "^4.4.5",
"dexie-react-hooks": "^4.4.0",
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"three": "^0.180.0"
},
"devDependencies": {
"@types/bwip-js": "^3.2.3",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.180.0",
"@vitejs/plugin-react": "^6.0.4",
"fake-indexeddb": "^6.2.5",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.11"
},
"description": "Vendored production copy of the local-first FieldLens capture core"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#17211f"/>
<g transform="translate(4 4)">
<path d="M7 19V11a4 4 0 0 1 4-4h8M37 7h8a4 4 0 0 1 4 4v8M49 37v8a4 4 0 0 1-4 4h-8M19 49h-8a4 4 0 0 1-4-4v-8" fill="none" stroke="#f4d35e" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="18" y="19" width="4" height="19" rx="1" fill="#f7f7f2"/>
<rect x="25" y="19" width="3" height="19" rx="1" fill="#f7f7f2"/>
<rect x="31" y="19" width="5" height="19" rx="1" fill="#f7f7f2"/>
<rect x="39" y="19" width="3" height="19" rx="1" fill="#f7f7f2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 652 B

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 96">
<rect width="420" height="96" fill="#f7f7f2"/>
<g transform="translate(16 16)">
<path d="M7 21V11a4 4 0 0 1 4-4h10M43 7h10a4 4 0 0 1 4 4v10M57 43v10a4 4 0 0 1-4 4H43M21 57H11a4 4 0 0 1-4-4V43" fill="none" stroke="#f4d35e" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="19" y="20" width="4" height="24" rx="1" fill="#17211f"/>
<rect x="26" y="20" width="3" height="24" rx="1" fill="#17211f"/>
<rect x="32" y="20" width="5" height="24" rx="1" fill="#17211f"/>
<rect x="40" y="20" width="3" height="24" rx="1" fill="#17211f"/>
</g>
<text x="98" y="61" font-family="Inter,Arial,sans-serif" font-size="45" font-weight="800" fill="#17211f">Field<tspan fill="#f4d35e">Lens</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 792 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#17211f"/><path d="M12 24V16a4 4 0 0 1 4-4h8M40 12h8a4 4 0 0 1 4 4v8M52 40v8a4 4 0 0 1-4 4h-8M24 52h-8a4 4 0 0 1-4-4v-8" fill="none" stroke="#f4d35e" stroke-width="6"/><path d="M23 23v18M29 23v18M36 23v18M42 23v18" stroke="#f7f7f2" stroke-width="4"/></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 72"><text x="2" y="52" font-family="Inter,Arial,sans-serif" font-size="52" font-weight="800" fill="#17211f">Field<tspan fill="#f4d35e">Lens</tspan></text></svg>

After

Width:  |  Height:  |  Size: 218 B

View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,223 @@
:root {
--ink: #17211f;
--paper: #f7f7f2;
--line: #d7dbd2;
--yellow: #f4d35e;
--red: #c43e35;
--red-soft: #f8e7e2;
--green: #25735b;
}
.app-shell { min-height: 100vh; min-height: 100dvh; display: grid; grid-template-columns: 248px minmax(0, 1fr); }
.sidebar { position: sticky; top: 0; height: 100vh; height: 100dvh; display: flex; flex-direction: column; padding: calc(24px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) calc(24px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); background: var(--ink); color: white; }
.brand { display: flex; align-items: center; gap: 12px; width: 100%; padding: 0 8px 26px; color: white; background: none; border: 0; text-align: left; }
.brand-mark { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 7px; color: var(--ink); background: var(--yellow); }
.brand strong, .brand small { display: block; letter-spacing: 0; }
.brand strong { font-size: 17px; }
.brand small { margin-top: 3px; color: #aeb8b4; font-size: 9px; font-weight: 700; }
.sidebar nav { display: grid; gap: 5px; }
.sidebar nav button { display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 10px; min-height: 46px; padding: 0 12px; border: 0; border-radius: 6px; color: #bfc7c4; background: transparent; text-align: left; font-weight: 700; }
.sidebar nav button:hover, .sidebar nav button.active { color: white; background: #2a3633; }
.sidebar nav button.active { box-shadow: inset 3px 0 var(--yellow); }
.sidebar nav b { display: grid; place-items: center; min-width: 24px; height: 22px; padding: 0 6px; border-radius: 11px; color: var(--ink); background: var(--yellow); font-size: 11px; }
.sidebar-status { display: flex; align-items: center; gap: 10px; margin-top: auto; padding: 14px 10px; border-top: 1px solid #394542; color: #c4cdca; }
.sidebar-status strong, .sidebar-status small { display: block; }
.sidebar-status strong { font-size: 12px; }
.sidebar-status small { margin-top: 2px; color: #899591; font-size: 10px; }
main { min-width: 0; min-height: 100vh; min-height: 100dvh; background: var(--paper); }
.loading-view { display: grid; min-height: 100vh; min-height: 100dvh; place-items: center; color: #68746f; font-size: 11px; font-weight: 900; text-transform: uppercase; }
.mobile-nav { display: none; }
.scanner-view { min-height: 100vh; min-height: 100dvh; display: grid; grid-template-rows: minmax(520px, 1fr) auto; background: var(--ink); }
.camera-stage { position: relative; min-height: 520px; overflow: hidden; background: #26312e; isolation: isolate; }
.camera-stage video { width: 100%; height: 100%; position: absolute; inset: 0; object-fit: cover; }
.camera-shade { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(12,18,17,.72), transparent 30%, transparent 65%, rgba(12,18,17,.72)); pointer-events: none; }
.camera-heading { position: absolute; z-index: 2; top: 0; left: 0; right: 0; display: flex; justify-content: space-between; align-items: flex-start; padding: calc(34px + env(safe-area-inset-top)) calc(40px + env(safe-area-inset-right)) 34px calc(40px + env(safe-area-inset-left)); color: white; }
.camera-heading h1 { margin: 5px 0 0; font-family: Georgia, serif; font-size: 37px; font-weight: 500; letter-spacing: 0; }
.eyebrow { color: #68746f; font-size: 10px; font-weight: 900; letter-spacing: 0; }
.camera-heading .eyebrow { color: var(--yellow); }
.camera-actions { display: flex; align-items: center; gap: 8px; }
.local-badge { display: flex; align-items: center; gap: 7px; padding: 7px 10px; border: 1px solid rgba(255,255,255,.24); border-radius: 4px; background: rgba(23,33,31,.6); font-size: 10px; font-weight: 900; }
.local-badge span { width: 7px; height: 7px; border-radius: 50%; background: #66cf9d; box-shadow: 0 0 0 3px rgba(102,207,157,.15); }
.scanner-detecting .local-badge span { animation: status-pulse 1.4s ease-out infinite; }
.scanner-duplicate .local-badge span, .scanner-permission-denied .local-badge span, .scanner-camera-error .local-badge span { background: #ed736a; box-shadow: 0 0 0 3px rgba(237,115,106,.16); }
.install-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 31px; padding: 0 10px; border: 1px solid var(--yellow); border-radius: 4px; color: var(--ink); background: var(--yellow); font-size: 10px; font-weight: 900; }
.camera-tools { position: absolute; z-index: 3; top: calc(112px + env(safe-area-inset-top)); right: max(40px, env(safe-area-inset-right)); display: flex; align-items: center; gap: 7px; }
.camera-select { display: flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 8px; border: 1px solid rgba(255,255,255,.24); border-radius: 4px; color: white; background: rgba(23,33,31,.72); }
.camera-select select { width: min(26vw, 180px); border: 0; outline: 0; color: white; background: transparent; font-size: 10px; font-weight: 800; }
.camera-select option { color: var(--ink); background: white; }
.camera-tool-button { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid rgba(255,255,255,.24); border-radius: 4px; color: white; background: rgba(23,33,31,.72); }
.camera-tool-button[aria-pressed="true"] { color: var(--ink); background: var(--yellow); border-color: var(--yellow); }
.camera-tool-button[aria-expanded="true"] { color: var(--ink); background: var(--yellow); border-color: var(--yellow); }
.camera-tool-button svg { width: 17px; }
.auto-toggle { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; padding: 0 10px; border: 1px solid rgba(255,255,255,.24); border-radius: 4px; color: white; background: rgba(23,33,31,.72); font-size: 10px; font-weight: 900; }
.auto-toggle span { width: 8px; height: 8px; border-radius: 50%; background: #78837f; }
.auto-toggle[aria-pressed="true"] span { background: #66cf9d; box-shadow: 0 0 0 3px rgba(102,207,157,.15); }
.quality-panel { position: fixed; z-index: 25; right: max(24px, env(safe-area-inset-right)); bottom: max(24px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: 1fr 1fr; gap: 12px; width: min(360px, calc(100% - 40px)); padding: 15px; border: 1px solid rgba(255,255,255,.24); border-radius: 6px; color: white; background: rgba(23,33,31,.94); box-shadow: 0 14px 36px rgba(0,0,0,.28); }
.quality-panel label { display: grid; min-width: 0; gap: 6px; }
.quality-panel label > span { color: #c9d1ce; font-size: 9px; font-weight: 900; text-transform: uppercase; }
.quality-panel input, .quality-panel select { min-width: 0; width: 100%; min-height: 38px; padding: 0 9px; border: 1px solid #53615d; border-radius: 4px; color: white; background: #2a3733; font-size: 11px; }
.quality-panel option { color: var(--ink); background: white; }
.quality-panel .quality-range, .quality-panel .quality-check { grid-column: 1 / -1; }
.quality-range span { display: flex; justify-content: space-between; }
.quality-range input { min-height: 28px; padding: 0; accent-color: var(--yellow); }
.quality-check { display: flex !important; min-height: 36px; grid-template-columns: auto 1fr; align-items: center; }
.quality-check input { width: 18px; min-height: 18px; margin: 0; accent-color: var(--yellow); }
.quality-check span { text-transform: none !important; }
.scan-frame { position: absolute; z-index: 2; top: 50%; left: 50%; width: min(82%, 620px); min-height: 132px; max-height: 205px; aspect-ratio: 3 / 1; transform: translate(-50%, -50%); }
.scan-frame.matrix { width: min(58%, 340px); min-height: 220px; max-height: 340px; aspect-ratio: 1; }
.scan-frame span { position: absolute; width: clamp(34px, 10%, 52px); height: clamp(34px, 24%, 52px); border-color: var(--yellow); border-style: solid; }
.scan-frame span:nth-child(1) { left: 0; top: 0; border-width: 4px 0 0 4px; }
.scan-frame span:nth-child(2) { right: 0; top: 0; border-width: 4px 4px 0 0; }
.scan-frame span:nth-child(3) { left: 0; bottom: 0; border-width: 0 0 4px 4px; }
.scan-frame span:nth-child(4) { right: 0; bottom: 0; border-width: 0 4px 4px 0; }
.scanner-detecting .scan-frame span { animation: rail-breathe 1.8s ease-in-out infinite; }
.scanner-captured .scan-frame span { border-color: #66cf9d; }
.scanner-duplicate .scan-frame span { border-color: #ed736a; }
.quality-settings-open .scan-frame { opacity: .18; }
.detection-point { position: absolute; z-index: 4; width: 13px; height: 13px; border: 2px solid white; border-radius: 50%; background: #66cf9d; box-shadow: 0 0 0 4px rgba(102,207,157,.24); transform: translate(-50%, -50%); pointer-events: none; animation: detection-pop .24s ease-out both; }
.camera-instruction { position: absolute; z-index: 2; left: 24px; right: 24px; bottom: 28px; margin: 0; color: white; text-align: center; font-size: 13px; font-weight: 800; }
.scan-result { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 16px; min-height: 116px; padding: 22px 40px; color: var(--ink); background: var(--paper); border-top: 5px solid var(--yellow); }
.scan-result.duplicate { background: var(--red-soft); border-color: var(--red); }
.result-icon { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 6px; color: var(--ink); background: var(--yellow); }
.duplicate .result-icon { color: white; background: var(--red); }
.result-copy { min-width: 0; }
.result-copy strong, .result-copy small { display: block; }
.result-copy strong { margin: 5px 0 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 17px/1.2 "IBM Plex Mono", "Courier New", monospace; }
.result-copy small { color: #65716d; font-size: 11px; }
.duplicate .eyebrow { color: var(--red); }
.result-actions { display: flex; align-items: center; gap: 8px; }
.primary-button, .quiet-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 16px; border-radius: 5px; font-size: 12px; font-weight: 900; letter-spacing: 0; }
.primary-button { color: var(--ink); background: var(--yellow); border: 1px solid #d7b842; box-shadow: 0 3px 0 #b99a2d; }
.primary-button:active { transform: translateY(2px); box-shadow: 0 1px 0 #b99a2d; }
.quiet-button { color: var(--ink); background: white; border: 1px solid var(--line); }
.workspace-view { max-width: 1180px; margin: 0 auto; padding: 48px clamp(22px, 5vw, 64px) 80px; }
.view-heading { display: flex; justify-content: space-between; align-items: flex-end; gap: 28px; margin-bottom: 34px; }
.view-actions { display: flex; gap: 8px; }
.view-heading h1 { margin: 7px 0 8px; font-family: Georgia, serif; font-size: clamp(32px, 4vw, 48px); font-weight: 500; letter-spacing: 0; }
.view-heading p { margin: 0; color: #66716d; font-size: 14px; }
.metric-strip { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 28px; border: 1px solid var(--line); background: white; }
.metric-strip div { padding: 20px 24px; border-right: 1px solid var(--line); }
.metric-strip div:last-child { border-right: 0; }
.metric-strip strong, .metric-strip span { display: block; }
.metric-strip strong { font-family: Georgia, serif; font-size: 30px; font-weight: 500; }
.metric-strip span { margin-top: 4px; color: #6d7773; font-size: 10px; font-weight: 800; text-transform: uppercase; }
.history-list { border-top: 1px solid var(--ink); }
.history-swipe-row { position: relative; overflow: hidden; border-bottom: 1px solid var(--line); background: var(--red); }
.history-actions { position: absolute; inset: 0 0 0 auto; display: grid; grid-template-columns: repeat(2, 74px); }
.history-action { display: grid; place-items: center; align-content: center; gap: 4px; width: 74px; border: 0; color: white; font-size: 9px; font-weight: 900; }
.history-action svg { width: 18px; height: 18px; }
.copy-action { background: var(--green); }
.delete-action { background: var(--red); }
.history-row { position: relative; display: grid; grid-template-columns: 42px minmax(0,1fr) auto 36px; align-items: center; gap: 16px; min-height: 76px; padding: 12px 14px; background: white; touch-action: pan-y; transition: transform .18s ease; }
.history-row-main { min-width: 0; }
.history-row-main strong, .history-row-main small { display: block; }
.history-row-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: "IBM Plex Mono", "Courier New", monospace; font-size: 12px; }
.history-row-main small { margin-top: 4px; color: #6d7773; font-size: 9px; }
.history-meta { display: grid; gap: 2px; color: #707a76; font-size: 9px; text-align: right; }
.history-meta b { color: var(--green); }
.history-delete-button { display: grid; width: 36px; height: 36px; place-items: center; border: 0; border-radius: 4px; color: #7b8581; background: transparent; }
.history-delete-button:hover { color: var(--red); background: var(--red-soft); }
.undo-toast { position: fixed; z-index: 35; left: calc(248px + 24px); bottom: 24px; display: flex; align-items: center; gap: 12px; padding: 11px 13px; border: 1px solid #40504b; border-radius: 6px; color: white; background: var(--ink); box-shadow: 0 10px 30px rgba(0,0,0,.24); font-size: 10px; }
.undo-toast button { border: 0; color: var(--yellow); background: transparent; font-size: 10px; font-weight: 900; }
.code-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.barcode-canvas { min-height: 190px; display: grid; place-items: center; overflow: auto; padding: 18px; border: 1px solid var(--line); background: white; }
.settings-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 24px; }
.settings-section { border: 1px solid var(--line); background: white; }
.settings-section h2 { margin: 0; padding: 16px 18px; border-bottom: 1px solid var(--line); font-size: 13px; }
.settings-icon svg { width: 18px; }
.settings-section:nth-child(2) { grid-column: 1; }
.settings-device { grid-column: 2; grid-row: 1 / span 2; }
.setting-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 22px; min-height: 76px; padding: 14px 18px; border-bottom: 1px solid var(--line); }
.setting-row:last-child { border-bottom: 0; }
.setting-row label strong, .setting-row label small, .setting-row > div strong, .setting-row > div small { display: block; }
.setting-row strong { font-size: 12px; }
.setting-row small { margin-top: 4px; color: #75807b; font-size: 10px; line-height: 1.35; }
.setting-row b { color: var(--green); font-size: 10px; }
.setting-row select, .compact-input { width: 190px; min-height: 38px; padding: 0 9px; border: 1px solid var(--line); border-radius: 4px; background: white; }
.setting-range { display: grid; gap: 7px; }
.setting-range input { width: 190px; accent-color: var(--yellow); }
.device-facts { display: grid; gap: 12px; padding: 18px; }
.device-facts div { padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.device-facts div:last-child { padding-bottom: 0; border-bottom: 0; }
.device-facts strong, .device-facts small { display: block; }
.device-facts strong { font-size: 11px; }
.device-facts small { margin-top: 3px; color: #77817d; font-size: 9px; }
.device-facts b { color: var(--green); font-size: 9px; text-transform: uppercase; }
.install-dialog-shell { width: 100%; max-width: 400px; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); border: 0; color: transparent; background: transparent; }
.install-dialog-shell::backdrop { background: rgba(10,16,15,.72); }
.install-dialog { position: relative; width: min(100%, 360px); padding: 30px 26px 24px; border: 1px solid var(--line); border-radius: 7px; color: var(--ink); background: var(--paper); box-shadow: 0 18px 60px rgba(0,0,0,.28); }
.dialog-close { position: absolute; top: 10px; right: 10px; display: grid; width: 34px; height: 34px; place-items: center; border: 0; color: #596460; background: transparent; }
.dialog-close svg { width: 18px; }
.install-dialog-icon { width: 30px; height: 30px; color: var(--green); }
.install-dialog h2 { margin: 13px 0 20px; font-family: Georgia, serif; font-size: 25px; font-weight: 500; }
.install-dialog p { display: flex; align-items: center; gap: 11px; margin: 11px 0; color: #56615d; font-size: 13px; }
.install-dialog p svg { flex: 0 0 auto; color: var(--green); }
.update-banner { position: fixed; z-index: 40; right: 24px; bottom: max(24px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: min(520px, calc(100vw - 48px)); padding: 12px; border: 1px solid #40504b; border-radius: 7px; color: white; background: var(--ink); box-shadow: 0 14px 42px rgba(0,0,0,.28); }
.update-banner.error { border-color: #e2837b; }
.update-icon { display: grid; width: 38px; height: 38px; place-items: center; border-radius: 5px; color: var(--ink); background: var(--yellow); }
.update-icon svg { width: 19px; }
.update-copy { min-width: 0; }
.update-copy strong, .update-copy small { display: block; }
.update-copy strong { font-size: 12px; }
.update-copy small { margin-top: 3px; color: #b8c2be; font-size: 10px; line-height: 1.35; }
.update-banner button { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 6px; padding: 0 12px; border: 1px solid #d7b842; border-radius: 5px; color: var(--ink); background: var(--yellow); font-size: 10px; font-weight: 900; }
.update-banner button svg { width: 15px; }
.update-banner .spinning { animation: update-spin .8s linear infinite; }
body:has(.update-banner) .undo-toast { bottom: 104px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
button:focus-visible, select:focus-visible { outline: 3px solid white; outline-offset: 2px; }
.primary-button:focus-visible, .quiet-button:focus-visible, .dialog-close:focus-visible { outline-color: var(--green); }
@keyframes status-pulse { 0% { box-shadow: 0 0 0 0 rgba(102,207,157,.5); } 70%, 100% { box-shadow: 0 0 0 7px rgba(102,207,157,0); } }
@keyframes rail-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
@keyframes detection-pop { from { opacity: 0; transform: translate(-50%, -50%) scale(.5); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }
@keyframes update-spin { to { transform: rotate(360deg); } }
@media (max-width: 760px) {
.app-shell { display: block; padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
.sidebar { display: none; }
.mobile-nav { position: fixed; z-index: 20; left: 0; right: 0; bottom: 0; display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); height: calc(70px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); border-top: 1px solid #34413d; background: var(--ink); }
.mobile-nav button { display: grid; min-width: 0; place-items: center; align-content: center; gap: 4px; padding: 0 2px; border: 0; color: #8f9a96; background: transparent; font-size: 8px; font-weight: 800; }
.mobile-nav button span { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mobile-nav button.active { color: var(--yellow); }
.scanner-view { min-height: calc(100vh - 70px - env(safe-area-inset-bottom)); min-height: calc(100dvh - 70px - env(safe-area-inset-bottom)); grid-template-rows: minmax(calc(430px + env(safe-area-inset-top)), 1fr) auto; }
.camera-stage { min-height: calc(430px + env(safe-area-inset-top)); }
.camera-heading { padding: calc(24px + env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) 24px max(20px, env(safe-area-inset-left)); }
.camera-heading h1 { font-size: 29px; }
.camera-actions { flex-direction: column; align-items: flex-end; }
.camera-tools { top: calc(126px + env(safe-area-inset-top)); left: max(20px, env(safe-area-inset-left)); right: max(20px, env(safe-area-inset-right)); flex-wrap: wrap; justify-content: flex-end; }
.quality-panel { left: max(20px, env(safe-area-inset-left)); right: max(20px, env(safe-area-inset-right)); bottom: calc(86px + env(safe-area-inset-bottom)); width: auto; }
.camera-select select { width: min(34vw, 140px); }
.scan-frame { width: 88%; }
.scan-frame.matrix { width: min(70%, 300px); min-height: 210px; }
.scan-result { grid-template-columns: 42px minmax(0, 1fr); padding: 17px 18px; }
.scan-result > .primary-button, .scan-result > .result-actions { grid-column: 1 / -1; width: 100%; }
.result-actions .primary-button, .result-actions .quiet-button { flex: 1; }
.workspace-view { padding: 30px 18px 50px; }
.view-heading { display: grid; align-items: start; margin-bottom: 24px; }
.view-actions { display: grid; grid-template-columns: 1fr 1fr; }
.view-heading .primary-button, .view-heading .quiet-button { width: 100%; }
.metric-strip { grid-template-columns: 1fr; }
.metric-strip div { display: flex; justify-content: space-between; align-items: center; padding: 13px 16px; border-right: 0; border-bottom: 1px solid var(--line); }
.metric-strip div:last-child { border-bottom: 0; }
.metric-strip strong { font-size: 23px; }
.history-row { grid-template-columns: 36px minmax(0, 1fr) 36px; gap: 12px; }
.history-meta { grid-column: 2 / 4; display: flex; justify-content: space-between; text-align: left; }
.undo-toast { left: 18px; right: 18px; bottom: calc(86px + env(safe-area-inset-bottom)); }
.update-banner { left: 18px; right: 18px; bottom: calc(86px + env(safe-area-inset-bottom)); grid-template-columns: 38px minmax(0, 1fr); width: auto; }
.update-banner button { grid-column: 1 / -1; width: 100%; }
body:has(.update-banner) .undo-toast { bottom: calc(198px + env(safe-area-inset-bottom)); }
.code-grid { grid-template-columns: 1fr; }
.barcode-canvas { min-height: 145px; }
.settings-layout { grid-template-columns: 1fr; gap: 18px; }
.settings-section:nth-child(2), .settings-device { grid-column: 1; grid-row: auto; }
.setting-row select, .compact-input, .setting-range input { width: 120px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

View File

@ -0,0 +1,244 @@
import { useLiveQuery } from 'dexie-react-hooks'
import { ClipboardCheck, CloudDownload, FlaskConical, History, LoaderCircle, ScanLine, Settings, Share, Smartphone, SquarePlus, Wifi, WifiOff, X } from 'lucide-react'
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
import './App.css'
import { database } from './lib/database'
const ScannerView = lazy(() => import('./features/scanner/ScannerView').then((module) => ({ default: module.ScannerView })))
const EanPalletView = lazy(() => import('./features/ean-pallet/EanPalletView').then((module) => ({ default: module.EanPalletView })))
const LabelCheckView = lazy(() => import('./features/label-check/LabelCheckView').then((module) => ({ default: module.LabelCheckView })))
const HistoryView = lazy(() => import('./features/history/HistoryView').then((module) => ({ default: module.HistoryView })))
const CodeLab = lazy(() => import('./features/codes/CodeLab').then((module) => ({ default: module.CodeLab })))
const SettingsView = lazy(() => import('./features/settings/SettingsView').then((module) => ({ default: module.SettingsView })))
type View = 'scanner' | 'ean-pallet' | 'label-check' | 'history' | 'lab' | 'settings'
type NavigationItem = { id: View; label: string; mobileLabel?: string; icon: typeof ScanLine }
const UPDATE_CHECK_INTERVAL = 60 * 60 * 1000
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
const navigation: NavigationItem[] = [
{ id: 'scanner', label: 'Scanner', icon: ScanLine },
{ id: 'ean-pallet', label: 'EAN pallet', mobileLabel: 'EAN', icon: ScanLine },
{ id: 'label-check', label: 'Carton labels', mobileLabel: 'OCR', icon: ClipboardCheck },
{ id: 'history', label: 'History', icon: History },
{ id: 'lab', label: 'Code lab', mobileLabel: 'Lab', icon: FlaskConical },
{ id: 'settings', label: 'Settings', icon: Settings },
]
function App() {
const [view, setView] = useState<View>('scanner')
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [showIosInstall, setShowIosInstall] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [storageStatus, setStorageStatus] = useState<'checking' | 'persistent' | 'best-effort' | 'unsupported'>('checking')
const [isInstalled, setIsInstalled] = useState(
() => window.matchMedia('(display-mode: standalone)').matches || Boolean((navigator as Navigator & { standalone?: boolean }).standalone),
)
const [isUpdating, setIsUpdating] = useState(false)
const [updateError, setUpdateError] = useState('')
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null)
const installButtonRef = useRef<HTMLButtonElement>(null)
const installDialogRef = useRef<HTMLDialogElement>(null)
const scanCount = useLiveQuery(() => database.scans.count(), []) ?? 0
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegisteredSW: (_serviceWorkerUrl, registration) => setSwRegistration(registration ?? null),
})
const applyUpdate = useCallback(async () => {
if (isUpdating) return
setIsUpdating(true)
setUpdateError('')
try {
await updateServiceWorker(true)
} catch {
setUpdateError('Update could not be installed. Check your connection and retry.')
setIsUpdating(false)
}
}, [isUpdating, updateServiceWorker])
useEffect(() => {
let disposed = false
const requestPersistentStorage = async () => {
if (!navigator.storage?.persisted || !navigator.storage.persist) {
if (!disposed) setStorageStatus('unsupported')
return
}
try {
const persistent = await navigator.storage.persisted() || await navigator.storage.persist()
if (!disposed) setStorageStatus(persistent ? 'persistent' : 'best-effort')
} catch {
if (!disposed) setStorageStatus('best-effort')
}
}
void requestPersistentStorage()
return () => { disposed = true }
}, [])
useEffect(() => {
const handleInstallPrompt = (event: Event) => {
event.preventDefault()
setInstallPrompt(event as BeforeInstallPromptEvent)
}
const handleInstalled = () => {
setInstallPrompt(null)
setIsInstalled(true)
}
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('beforeinstallprompt', handleInstallPrompt)
window.addEventListener('appinstalled', handleInstalled)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('beforeinstallprompt', handleInstallPrompt)
window.removeEventListener('appinstalled', handleInstalled)
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
useEffect(() => {
const dialog = installDialogRef.current
if (!dialog) return
if (showIosInstall && !dialog.open) dialog.showModal()
if (!showIosInstall && dialog.open) dialog.close()
}, [showIosInstall])
useEffect(() => {
if (!swRegistration) return
const checkForUpdate = () => {
if (navigator.onLine) void swRegistration.update().catch(() => undefined)
}
const interval = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL)
window.addEventListener('online', checkForUpdate)
return () => {
window.clearInterval(interval)
window.removeEventListener('online', checkForUpdate)
}
}, [swRegistration])
useEffect(() => {
if (!needRefresh || isUpdating) return
if (document.visibilityState === 'hidden') {
const backgroundTimer = window.setTimeout(() => void applyUpdate(), 0)
return () => window.clearTimeout(backgroundTimer)
}
const updateWhenHidden = () => {
if (document.visibilityState === 'hidden') void applyUpdate()
}
document.addEventListener('visibilitychange', updateWhenHidden)
return () => {
document.removeEventListener('visibilitychange', updateWhenHidden)
}
}, [applyUpdate, isUpdating, needRefresh])
const installApp = async () => {
if (!installPrompt) {
setShowIosInstall(true)
return
}
await installPrompt.prompt()
const { outcome } = await installPrompt.userChoice
setInstallPrompt(null)
if (outcome === 'accepted') setIsInstalled(true)
}
const installAction = !isInstalled && (installPrompt || isIos) ? (
<button className="install-button" type="button" ref={installButtonRef} onClick={() => void installApp()}>
<Smartphone size={15} /> Install app
</button>
) : null
return (
<>
<div className="app-shell">
<aside className="sidebar">
<button className="brand" type="button" onClick={() => setView('scanner')} aria-label="Open scanner">
<span className="brand-mark"><img src="/fieldlens-mark.svg" width="28" height="28" alt="" aria-hidden="true" /></span>
<span><strong>FIELDLENS</strong><small>CAPTURE SYSTEM</small></span>
</button>
<nav aria-label="Primary navigation">
{navigation.map(({ id, label, icon: Icon }) => (
<button className={view === id ? 'active' : ''} type="button" key={id} onClick={() => setView(id)}>
<Icon size={19} /><span>{label}</span>
{id === 'history' && scanCount > 0 && <b>{scanCount}</b>}
</button>
))}
</nav>
<div className="sidebar-status">
{isOnline ? <Wifi size={16} /> : <WifiOff size={16} />}
<span><strong>{isOnline ? 'Online' : 'Offline ready'}</strong><small>{storageStatus === 'persistent' ? 'Durable storage' : storageStatus === 'checking' ? 'Checking storage' : 'Best-effort storage'}</small></span>
</div>
</aside>
<main>
<Suspense fallback={<div className="loading-view">Loading workspace</div>}>
{view === 'scanner' && <ScannerView installAction={installAction} />}
{view === 'ean-pallet' && <EanPalletView />}
{view === 'label-check' && <LabelCheckView onOpenSettings={() => setView('settings')} />}
{view === 'history' && <HistoryView />}
{view === 'lab' && <CodeLab />}
{view === 'settings' && (
<SettingsView
storageStatus={storageStatus}
updateAvailable={needRefresh}
isUpdating={isUpdating}
updateError={updateError}
onApplyUpdate={applyUpdate}
/>
)}
</Suspense>
</main>
<nav className="mobile-nav" aria-label="Mobile navigation">
{navigation.map(({ id, label, mobileLabel, icon: Icon }) => (
<button className={view === id ? 'active' : ''} type="button" key={id} onClick={() => setView(id)}>
<Icon size={20} /><span>{mobileLabel ?? label}</span>
</button>
))}
</nav>
</div>
<dialog className="install-dialog-shell" ref={installDialogRef} aria-labelledby="install-title" onCancel={(event) => { event.preventDefault(); event.currentTarget.close() }} onClose={() => { setShowIosInstall(false); installButtonRef.current?.focus() }} onClick={(event) => { if (event.target === event.currentTarget) event.currentTarget.close() }}>
<div className="install-dialog">
<button className="dialog-close" type="button" autoFocus aria-label="Close" onClick={() => installDialogRef.current?.close()}><X /></button>
<Smartphone className="install-dialog-icon" />
<h2 id="install-title">Install FieldLens</h2>
<p><Share size={18} /> Tap <strong>Share</strong> in Safari.</p>
<p><SquarePlus size={18} /> Choose <strong>Add to Home Screen</strong>.</p>
</div>
</dialog>
{needRefresh && (
<aside className={`update-banner ${updateError ? 'error' : ''}`} aria-label="Application update" aria-live="polite">
<span className="update-icon"><CloudDownload /></span>
<span className="update-copy">
<strong>{updateError ? 'Update paused' : isUpdating ? 'Updating FieldLens' : 'Update available'}</strong>
<small>{updateError || (isUpdating ? 'Installing the latest version…' : 'Update now or FieldLens will refresh after you leave the app.')}</small>
</span>
<button type="button" disabled={isUpdating} onClick={() => void applyUpdate()}>
{isUpdating ? <LoaderCircle className="spinning" /> : <CloudDownload />} {updateError ? 'Retry' : isUpdating ? 'Updating' : 'Update now'}
</button>
</aside>
)}
</>
)
}
export default App

View File

@ -0,0 +1,64 @@
/* Manual detection-only correction for phones with OS/browser rotation lock.
* The camera element, camera cover rectangle and app layout stay untouched.
* deviceOrientation.ts remaps each rendered EAN detection box into corrected
* normalized coordinates and publishes them through these custom properties. */
html[data-detection-rotation='90'] .carton-video-coordinate-space .carton-batch-box,
html[data-detection-rotation='270'] .carton-video-coordinate-space .carton-batch-box {
left: var(--fieldlens-box-left) !important;
top: var(--fieldlens-box-top) !important;
width: var(--fieldlens-box-width) !important;
height: var(--fieldlens-box-height) !important;
}
.fieldlens-detection-rotate {
position: absolute;
z-index: 12;
right: max(10px, env(safe-area-inset-right));
bottom: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 58px;
height: 36px;
padding: 0 9px;
border: 1px solid rgba(255,255,255,.28);
border-radius: 5px;
color: white;
background: rgba(17,29,25,.82);
box-shadow: 0 5px 16px rgba(0,0,0,.18);
backdrop-filter: blur(8px);
font: inherit;
}
.fieldlens-detection-rotate > span {
font-size: 18px;
line-height: 1;
}
.fieldlens-detection-rotate > small {
color: #d5dfdb;
font-size: 7px;
font-weight: 900;
letter-spacing: .035em;
}
.fieldlens-detection-rotate[data-rotation]:not([data-rotation='0']) {
color: var(--ink);
border-color: var(--yellow);
background: var(--yellow);
}
.fieldlens-detection-rotate[data-rotation]:not([data-rotation='0']) > small {
color: #493e12;
}
@media (pointer: coarse) and (orientation: landscape) and (max-height: 500px) {
.fieldlens-detection-rotate {
right: max(8px, env(safe-area-inset-right));
bottom: 8px;
height: 32px;
min-width: 54px;
padding: 0 8px;
}
}

View File

@ -0,0 +1,147 @@
.code-lab-tabs {
display: inline-flex;
gap: 4px;
margin: -12px 0 20px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 7px;
background: white;
}
.code-lab-tabs button {
min-height: 36px;
padding: 0 13px;
border: 0;
border-radius: 5px;
color: #6d7773;
background: transparent;
font-size: 10px;
font-weight: 900;
}
.code-lab-tabs button.active {
color: var(--ink);
background: var(--yellow);
}
.ean-lab-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.ean-lab-carton {
display: grid;
place-items: center;
min-height: 270px;
padding: 28px;
border-radius: 8px;
background: #4b4a43;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
}
.ean-lab-label {
width: min(100%, 520px);
aspect-ratio: 2.25 / 1;
padding: 14px 16px;
border: 1px solid #d9d9d2;
background: white;
box-shadow: 0 5px 18px rgba(0,0,0,.2);
color: #111;
font-family: Arial, Helvetica, sans-serif;
}
.ean-lab-label-head {
display: flex;
justify-content: space-between;
gap: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #111;
}
.ean-lab-label-head strong {
font-size: 15px;
letter-spacing: .02em;
}
.ean-lab-label-head span {
font-size: 10px;
font-weight: 800;
}
.ean-lab-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px 12px;
padding-top: 10px;
}
.ean-lab-fields span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-weight: 700;
}
.ean-lab-fields b {
display: block;
margin-bottom: 1px;
color: #555;
font-size: 7px;
font-weight: 800;
letter-spacing: .035em;
text-transform: uppercase;
}
.ean-lab-temp {
grid-column: span 2;
}
@media (max-width: 760px) {
.code-lab-tabs {
width: 100%;
margin: -4px 0 14px;
}
.code-lab-tabs button {
flex: 1;
}
.ean-lab-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.ean-lab-carton {
min-height: 0;
padding: 14px;
}
.ean-lab-label {
width: 100%;
padding: 11px 12px;
}
.ean-lab-label-head strong {
font-size: 12px;
}
.ean-lab-label-head span {
font-size: 8px;
}
.ean-lab-fields {
gap: 5px 8px;
padding-top: 7px;
}
.ean-lab-fields span {
font-size: 8.5px;
}
.ean-lab-fields b {
font-size: 5.8px;
}
}

View File

@ -0,0 +1,162 @@
import bwipjs from 'bwip-js'
import { RefreshCw } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { appendGtinCheckDigit, randomDigits } from '../../lib/barcodes'
import './CodeLab.css'
type LabTab = 'barcodes' | 'ean'
type CodeFixture = {
id: string
name: string
bcid: string
value: string
}
type EanLabelFixture = {
id: string
carton: number
ppin: string
grower: string
variety: string
grade: string
packer: string
pickDate: string
packDate: string
size: string
count: string
region: string
tempPc: string
}
function makeFixtures(): CodeFixture[] {
const gtin14 = appendGtinCheckDigit(randomDigits(13))
const lot = `LOT-${randomDigits(6)}`
return [
{ id: crypto.randomUUID(), name: 'EAN-13', bcid: 'ean13', value: appendGtinCheckDigit(randomDigits(12)) },
{ id: crypto.randomUUID(), name: 'EAN-8', bcid: 'ean8', value: appendGtinCheckDigit(randomDigits(7)) },
{ id: crypto.randomUUID(), name: 'UPC-A', bcid: 'upca', value: appendGtinCheckDigit(randomDigits(11)) },
{ id: crypto.randomUUID(), name: 'ITF-14', bcid: 'itf14', value: gtin14 },
{ id: crypto.randomUUID(), name: 'Code 128', bcid: 'code128', value: `FL-${randomDigits(10)}` },
{ id: crypto.randomUUID(), name: 'GS1 Digital Link QR', bcid: 'qrcode', value: `https://id.gs1.org/01/${gtin14}/10/${lot}` },
]
}
function makeEanFixtures(): EanLabelFixture[] {
const pallet = {
ppin: randomDigits(8),
grower: randomDigits(5),
variety: 'HASS AVOCADO',
grade: 'CLASS 1',
packer: `PK${randomDigits(4)}`,
pickDate: '09/09/2026',
packDate: '11/09/2026',
size: '24',
count: '24',
region: 'BAY OF PLENTY',
tempPc: randomDigits(8),
}
return Array.from({ length: 6 }, (_, index) => ({
id: crypto.randomUUID(),
carton: index + 1,
...pallet,
}))
}
function CodeCard({ fixture }: { fixture: CodeFixture }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!canvasRef.current) return
try {
bwipjs.toCanvas(canvasRef.current, {
bcid: fixture.bcid,
text: fixture.value,
scale: 3,
height: fixture.bcid === 'qrcode' ? 24 : 13,
includetext: fixture.bcid !== 'qrcode',
textxalign: 'center',
backgroundcolor: 'FFFFFF',
paddingwidth: 8,
paddingheight: 8,
})
} catch {
// A failed fixture remains labeled so generator errors are visible during testing.
}
}, [fixture])
return (
<article className="code-card">
<div className="code-card-heading"><strong>{fixture.name}</strong><span>VALID SAMPLE</span></div>
<div className="barcode-canvas"><canvas ref={canvasRef} /></div>
<code>{fixture.value}</code>
</article>
)
}
function EanLabelCard({ fixture }: { fixture: EanLabelFixture }) {
return (
<article className="ean-lab-carton">
<div className="ean-lab-label" aria-label={`EAN carton test label ${fixture.carton}`}>
<div className="ean-lab-label-head">
<strong>EXPORT AVOCADOS</strong>
<span>CARTON {String(fixture.carton).padStart(2, '0')}</span>
</div>
<div className="ean-lab-fields">
<span><b>PPIN</b>{fixture.ppin}</span>
<span><b>Grower</b>{fixture.grower}</span>
<span><b>Variety</b>{fixture.variety}</span>
<span><b>Grade</b>{fixture.grade}</span>
<span><b>Packer</b>{fixture.packer}</span>
<span><b>Region</b>{fixture.region}</span>
<span><b>Pick Date</b>{fixture.pickDate}</span>
<span><b>Pack Date</b>{fixture.packDate}</span>
<span><b>Size</b>{fixture.size}</span>
<span><b>Count</b>{fixture.count}</span>
<span className="ean-lab-temp"><b>TempPC</b>{fixture.tempPc}</span>
</div>
</div>
</article>
)
}
export function CodeLab() {
const [tab, setTab] = useState<LabTab>('barcodes')
const [fixtures, setFixtures] = useState(makeFixtures)
const [eanFixtures, setEanFixtures] = useState(makeEanFixtures)
const regenerate = () => {
if (tab === 'ean') setEanFixtures(makeEanFixtures())
else setFixtures(makeFixtures())
}
return (
<section className="workspace-view code-lab" aria-labelledby="lab-title">
<header className="view-heading">
<div>
<span className="eyebrow">VALIDATION BENCH</span>
<h1 id="lab-title">Code lab</h1>
<p>{tab === 'ean'
? 'Synthetic carton labels for testing pallet detection, OCR and conformity from another screen.'
: 'Open this view on another screen and scan each symbol from the PWA.'}</p>
</div>
<button className="primary-button" type="button" onClick={regenerate}>
<RefreshCw size={17} /> Generate new set
</button>
</header>
<div className="code-lab-tabs" role="tablist" aria-label="Code lab fixtures">
<button type="button" role="tab" aria-selected={tab === 'barcodes'} className={tab === 'barcodes' ? 'active' : ''} onClick={() => setTab('barcodes')}>Barcodes</button>
<button type="button" role="tab" aria-selected={tab === 'ean'} className={tab === 'ean' ? 'active' : ''} onClick={() => setTab('ean')}>EAN labels</button>
</div>
{tab === 'barcodes' ? (
<div className="code-grid">{fixtures.map((fixture) => <CodeCard fixture={fixture} key={fixture.id} />)}</div>
) : (
<div className="ean-lab-grid">{eanFixtures.map((fixture) => <EanLabelCard fixture={fixture} key={fixture.id} />)}</div>
)}
</section>
)
}

View File

@ -0,0 +1 @@
.ean-pallet-view{padding:clamp(18px,3vw,38px);max-width:1200px;margin:0 auto}.ean-pallet-view>header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:24px}.ean-pallet-view h1{margin:.25rem 0;font-size:clamp(1.7rem,4vw,2.6rem)}.ean-pallet-view p{color:#7f8a96;max-width:720px}.ean-local-badge{padding:8px 12px;border:1px solid #2b7667;border-radius:999px;color:#59c7ad;white-space:nowrap}.ean-setup-card,.ean-progress,.ean-result{background:#111820;border:1px solid #25303a;border-radius:18px;padding:22px}.ean-setup-card{display:flex;align-items:end;gap:16px;max-width:520px}.ean-setup-card label{display:grid;gap:8px;flex:1}.ean-setup-card input{background:#0b1015;border:1px solid #36434e;border-radius:10px;color:#fff;padding:12px;font:inherit}.ean-primary{display:inline-flex;gap:8px;align-items:center;justify-content:center;border:0;border-radius:10px;padding:12px 16px;background:#5be0bf;color:#07110e;font-weight:800;cursor:pointer}.ean-scan-layout{display:grid;grid-template-columns:minmax(0,2fr) minmax(260px,1fr);gap:18px}.ean-camera{position:relative;overflow:hidden;min-height:55vh;border-radius:18px;background:#030506;border:1px solid #25303a}.ean-camera video{width:100%;height:100%;min-height:55vh;object-fit:cover}.ean-reticle{position:absolute;inset:25% 12%;border:2px solid #5be0bf;border-radius:16px;box-shadow:0 0 0 9999px #0005}.ean-progress{display:flex;flex-direction:column;gap:14px}.ean-progress>strong{font-size:2.2rem}.ean-progress-track{height:8px;background:#27313a;border-radius:999px;overflow:hidden}.ean-progress-track i{display:block;height:100%;background:#5be0bf;transition:width .2s}.ean-progress small{color:#7f8a96}.ean-error{color:#ff8d86!important}.ean-result{display:flex;gap:18px;max-width:760px}.ean-result>svg{width:48px;height:48px;flex:none}.ean-result.pass>svg{color:#5be0bf}.ean-result.fail>svg{color:#ff8d86}.ean-result ul{list-style:none;padding:0;margin:18px 0;display:grid;gap:8px}.ean-result li{display:flex;justify-content:space-between;background:#0b1015;border-radius:8px;padding:10px 12px}@media(max-width:760px){.ean-pallet-view>header{display:block}.ean-local-badge{display:inline-block;margin-top:8px}.ean-scan-layout{grid-template-columns:1fr}.ean-camera,.ean-camera video{min-height:48vh}.ean-setup-card{align-items:stretch;flex-direction:column}}

View File

@ -0,0 +1,186 @@
import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser'
import { CheckCircle2, RefreshCw, ScanLine, XCircle } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
finishEanPalletSession,
saveEanPalletScans,
startEanPalletSession,
} from '../../lib/database'
import { extractEanFromScan, summarizeEanScans } from '../../lib/eanPallet'
import './EanPalletView.css'
type Step = 'setup' | 'scan' | 'result'
export function EanPalletView() {
const videoRef = useRef<HTMLVideoElement>(null)
const controlsRef = useRef<IScannerControls | null>(null)
const armedRef = useRef(false)
const sessionIdRef = useRef('')
const scansRef = useRef<string[]>([])
const [step, setStep] = useState<Step>('setup')
const [targetCount, setTargetCount] = useState(88)
const [scans, setScans] = useState<string[]>([])
const [armed, setArmed] = useState(false)
const [cameraError, setCameraError] = useState('')
const [message, setMessage] = useState('Ready')
const summary = useMemo(() => summarizeEanScans(scans), [scans])
useEffect(() => {
if (step !== 'scan') return
const video = videoRef.current
if (!video) return
let disposed = false
setCameraError('')
const reader = new BrowserMultiFormatReader()
void reader.decodeFromConstraints(
{ audio: false, video: { facingMode: { ideal: 'environment' } } },
video,
(result) => {
if (!result || !armedRef.current) return
const ean = extractEanFromScan(result.getText())
if (!ean) {
setMessage('Barcode read, but no valid EAN/UPC/GTIN was found.')
return
}
armedRef.current = false
setArmed(false)
const nextScans = [...scansRef.current, ean]
scansRef.current = nextScans
setScans(nextScans)
setMessage(`Captured ${ean}`)
navigator.vibrate?.(45)
if (sessionIdRef.current) {
void saveEanPalletScans(sessionIdRef.current, nextScans).catch(() => {
setMessage('Local storage failed. Do not leave this screen until the pallet is recorded.')
})
}
if (nextScans.length >= targetCount) {
const nextSummary = summarizeEanScans(nextScans)
if (sessionIdRef.current) {
void finishEanPalletSession(sessionIdRef.current, {
conforms: nextSummary.conforms,
consensusEan: nextSummary.consensusEan,
uniqueEanCount: nextSummary.counts.length,
}).catch(() => undefined)
}
setStep('result')
}
},
).then((controls) => {
if (disposed) controls.stop()
else controlsRef.current = controls
}).catch((error) => {
if (!disposed) setCameraError(error instanceof Error ? error.message : 'Camera could not be started.')
})
return () => {
disposed = true
controlsRef.current?.stop()
controlsRef.current = null
}
}, [step, targetCount])
const start = async () => {
const safeCount = Math.max(1, Math.min(500, Math.round(targetCount || 1)))
setTargetCount(safeCount)
const session = await startEanPalletSession(safeCount)
sessionIdRef.current = session.id
scansRef.current = []
setScans([])
setMessage('Aim at the first carton barcode.')
armedRef.current = true
setArmed(true)
setStep('scan')
}
const armNext = () => {
setMessage('Aim at the next carton barcode.')
armedRef.current = true
setArmed(true)
}
const reset = () => {
sessionIdRef.current = ''
scansRef.current = []
setScans([])
setMessage('Ready')
armedRef.current = false
setArmed(false)
setStep('setup')
}
return (
<section className="ean-pallet-view">
<header>
<div>
<span className="eyebrow">LOCAL-FIRST PALLET CHECK</span>
<h1>EAN pallet verification</h1>
<p>Capture one carton at a time. Each accepted read is persisted locally before it is eligible for sync.</p>
</div>
<span className="ean-local-badge">Works offline</span>
</header>
{step === 'setup' && (
<div className="ean-setup-card">
<label>
Cartons on pallet
<input
type="number"
min="1"
max="500"
inputMode="numeric"
value={targetCount}
onChange={(event) => setTargetCount(Number(event.target.value))}
/>
</label>
<button type="button" className="ean-primary" onClick={() => void start()}>
<ScanLine size={18} /> Start pallet
</button>
</div>
)}
{step === 'scan' && (
<div className="ean-scan-layout">
<div className="ean-camera">
<video ref={videoRef} autoPlay muted playsInline />
<div className="ean-reticle" />
</div>
<aside className="ean-progress">
<strong>{scans.length} / {targetCount}</strong>
<span>{message}</span>
<div className="ean-progress-track"><i style={{ width: `${Math.min(100, scans.length / targetCount * 100)}%` }} /></div>
{cameraError && <p className="ean-error">{cameraError}</p>}
{!armed && scans.length < targetCount && (
<button type="button" className="ean-primary" onClick={armNext}>
<ScanLine size={17} /> Scan next carton
</button>
)}
<small>Re-arming is manual so identical EANs on adjacent cartons are not counted repeatedly from the same camera frame.</small>
</aside>
</div>
)}
{step === 'result' && (
<div className={`ean-result ${summary.conforms ? 'pass' : 'fail'}`}>
{summary.conforms ? <CheckCircle2 /> : <XCircle />}
<div>
<span className="eyebrow">{summary.conforms ? 'PALLET EAN PASS' : 'EAN MISMATCH FOUND'}</span>
<h2>{summary.total} cartons captured</h2>
<p>{summary.conforms
? `All cartons resolved to ${summary.consensusEan}.`
: `${summary.counts.length} different trade item codes were captured. Review before release.`}</p>
<ul>
{summary.counts.map((entry) => <li key={entry.ean}><strong>{entry.ean}</strong><span>{entry.count}</span></li>)}
</ul>
<button type="button" className="ean-primary" onClick={reset}><RefreshCw size={17} /> New pallet</button>
</div>
</div>
)}
</section>
)
}

View File

@ -0,0 +1,381 @@
.history-view {
--history-surface: #fff;
--history-muted: #6d7773;
--history-soft: #eef1eb;
--history-green-soft: #e7f1ed;
}
.history-view .view-heading {
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.history-view .view-heading p {
max-width: 520px;
}
.history-view .view-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.history-view .history-clear-button:not(:disabled) {
color: var(--red);
}
.history-view .history-clear-button:not(:disabled):hover {
border-color: #e0b0aa;
background: var(--red-soft);
}
.history-view .metric-strip {
gap: 10px;
margin-bottom: 18px;
border: 0;
background: transparent;
}
.history-view .metric-strip > div {
min-width: 0;
padding: 17px 18px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--history-surface);
}
.history-view .metric-strip > div:last-child {
border-right: 1px solid var(--line);
}
.history-view .metric-strip strong {
color: var(--ink);
font-size: 28px;
line-height: 1;
}
.history-view .metric-strip span {
margin-top: 7px;
color: var(--history-muted);
font-size: 9px;
letter-spacing: .035em;
}
.history-view .history-list {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--history-surface);
box-shadow: 0 1px 0 rgba(23, 33, 31, .03);
}
.history-view .history-swipe-row {
border-bottom: 1px solid var(--line);
}
.history-view .history-swipe-row:last-child {
border-bottom: 0;
}
.history-view .history-row {
grid-template-columns: 42px minmax(0, 1fr) auto 38px;
min-height: 78px;
padding: 12px 13px;
background: var(--history-surface);
}
.history-view .history-row:hover {
background: #fbfcf9;
}
.history-view .history-row.duplicate {
background: #fff9f7;
box-shadow: inset 3px 0 var(--red);
}
.history-view .history-row.duplicate:hover {
background: #fdf1ed;
}
.history-view .history-status {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: 1px solid #c9ddd5;
border-radius: 6px;
color: var(--green);
background: var(--history-green-soft);
}
.history-view .history-status svg {
width: 18px;
height: 18px;
stroke-width: 2.25;
}
.history-view .duplicate .history-status {
border-color: #e8beb8;
color: var(--red);
background: var(--red-soft);
}
.history-view .history-value {
min-width: 0;
}
.history-view .history-value strong,
.history-view .history-value span {
display: block;
}
.history-view .history-value strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ink);
font: 700 13px/1.35 "IBM Plex Mono", "Courier New", monospace;
}
.history-view .history-value span {
width: max-content;
max-width: 100%;
margin-top: 5px;
padding: 3px 6px;
overflow: hidden;
border-radius: 4px;
color: #66716d;
background: var(--history-soft);
font-size: 8px;
font-weight: 900;
letter-spacing: .04em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.history-view .history-meta {
min-width: 126px;
align-content: center;
gap: 6px;
}
.history-view .status-label {
justify-self: end;
width: max-content;
padding: 4px 7px;
border-radius: 999px;
color: var(--green);
background: var(--history-green-soft);
font-size: 8px;
font-weight: 900;
letter-spacing: .05em;
}
.history-view .duplicate .status-label {
color: var(--red);
background: var(--red-soft);
}
.history-view .history-meta time {
color: #7a8581;
font-size: 9px;
white-space: nowrap;
}
.history-view .swipe-menu {
display: grid;
width: 36px;
height: 36px;
place-items: center;
padding: 0;
border: 0;
border-radius: 5px;
color: #77817d;
background: transparent;
}
.history-view .swipe-menu:hover,
.history-view .swipe-menu[aria-expanded='true'] {
color: var(--ink);
background: var(--history-soft);
}
.history-view .swipe-menu svg {
width: 18px;
height: 18px;
}
.history-view .history-action {
transition: filter .15s ease;
}
.history-view .history-action:hover {
filter: brightness(.92);
}
.history-view .empty-state {
min-height: 260px;
padding: 42px 20px;
border: 0;
background: white;
}
.history-view .empty-state > svg {
width: 34px;
height: 34px;
color: var(--green);
}
.history-view .empty-state strong {
margin-top: 12px;
font-family: Georgia, serif;
font-size: 21px;
font-weight: 500;
}
.history-view .empty-state span {
margin-top: 5px;
color: var(--history-muted);
font-size: 10px;
}
.history-view .undo-toast svg {
width: 14px;
height: 14px;
}
.history-view .undo-toast button {
display: inline-flex;
align-items: center;
gap: 5px;
}
@media (max-width: 760px) {
.history-view {
padding-top: calc(20px + env(safe-area-inset-top));
}
.history-view .view-heading {
padding-bottom: 16px;
}
.history-view .view-actions {
display: grid;
grid-template-columns: 1fr 1fr;
width: 100%;
gap: 8px;
}
.history-view .view-actions .quiet-button {
min-height: 40px;
padding: 0 10px;
font-size: 10px;
}
.history-view .metric-strip {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
margin-bottom: 13px;
}
.history-view .metric-strip > div {
display: block;
min-height: 68px;
padding: 11px 10px;
border: 1px solid var(--line);
}
.history-view .metric-strip > div:last-child {
border-bottom: 1px solid var(--line);
}
.history-view .metric-strip strong {
font-size: 23px;
}
.history-view .metric-strip span {
margin-top: 5px;
font-size: 7px;
line-height: 1.2;
}
.history-view .history-row {
grid-template-columns: 34px minmax(0, 1fr) 32px;
gap: 10px;
min-height: 76px;
padding: 11px 10px;
}
.history-view .history-status {
width: 32px;
height: 32px;
}
.history-view .history-status svg {
width: 16px;
height: 16px;
}
.history-view .history-value strong {
font-size: 11px;
}
.history-view .history-value span {
margin-top: 4px;
font-size: 7px;
}
.history-view .history-meta {
grid-column: 2 / 3;
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: -3px;
}
.history-view .status-label {
justify-self: auto;
flex: 0 0 auto;
padding: 3px 6px;
font-size: 7px;
}
.history-view .history-meta time {
min-width: 0;
overflow: hidden;
font-size: 8px;
text-overflow: ellipsis;
}
.history-view .swipe-menu {
grid-column: 3;
grid-row: 1 / span 2;
align-self: center;
width: 32px;
height: 36px;
}
.history-view .empty-state {
min-height: 220px;
}
}
@media (max-width: 390px) {
.history-view .metric-strip > div {
padding: 10px 7px;
}
.history-view .metric-strip strong {
font-size: 21px;
}
.history-view .metric-strip span {
font-size: 6.5px;
}
.history-view .history-meta time {
max-width: 145px;
}
}

View File

@ -0,0 +1,166 @@
import { useLiveQuery } from 'dexie-react-hooks'
import { AlertTriangle, Barcode, Check, CircleCheck, Copy, Download, MoreHorizontal, RotateCcw, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { clearScanHistory, countDistinctScanValues, database, deleteScan, restoreScan, serializeScanHistory, type ScanRecord } from '../../lib/database'
import './HistoryView.css'
const ACTION_WIDTH = 148
function HistoryRow({ scan, isOpen, setOpen, onDelete }: { scan: ScanRecord; isOpen: boolean; setOpen: (open: boolean) => void; onDelete: () => void }) {
const pointerStartRef = useRef<{ x: number; y: number; offset: number } | null>(null)
const horizontalDragRef = useRef<boolean | null>(null)
const copyTimerRef = useRef<number | null>(null)
const [dragOffset, setDragOffset] = useState<number | null>(null)
const [copied, setCopied] = useState(false)
const offset = dragOffset ?? (isOpen ? -ACTION_WIDTH : 0)
useEffect(() => () => {
if (copyTimerRef.current) window.clearTimeout(copyTimerRef.current)
}, [])
const startDrag = (event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0 || (event.target as HTMLElement).closest('button')) return
event.currentTarget.setPointerCapture(event.pointerId)
pointerStartRef.current = { x: event.clientX, y: event.clientY, offset: isOpen ? -ACTION_WIDTH : 0 }
horizontalDragRef.current = null
}
const moveDrag = (event: ReactPointerEvent<HTMLElement>) => {
const start = pointerStartRef.current
if (!start) return
const deltaX = event.clientX - start.x
const deltaY = event.clientY - start.y
if (horizontalDragRef.current === null && Math.max(Math.abs(deltaX), Math.abs(deltaY)) > 6) {
horizontalDragRef.current = Math.abs(deltaX) > Math.abs(deltaY)
}
if (!horizontalDragRef.current) return
event.preventDefault()
setDragOffset(Math.min(0, Math.max(-ACTION_WIDTH, start.offset + deltaX)))
}
const finishDrag = (event: ReactPointerEvent<HTMLElement>) => {
if (horizontalDragRef.current) setOpen(offset < -48)
setDragOffset(null)
pointerStartRef.current = null
horizontalDragRef.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
}
const copyValue = async () => {
await navigator.clipboard.writeText(scan.rawValue)
setCopied(true)
setOpen(false)
if (copyTimerRef.current) window.clearTimeout(copyTimerRef.current)
copyTimerRef.current = window.setTimeout(() => setCopied(false), 1400)
}
return (
<div className="history-swipe-row">
<div className="history-actions" aria-hidden={!isOpen}>
<button className="history-action copy-action" type="button" tabIndex={isOpen ? 0 : -1} onClick={() => void copyValue()}>
{copied ? <Check /> : <Copy />}<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
<button className="history-action delete-action" type="button" tabIndex={isOpen ? 0 : -1} onClick={onDelete}>
<Trash2 /><span>Delete</span>
</button>
</div>
<article
className={`history-row ${scan.isDuplicate ? 'duplicate' : ''} ${dragOffset !== null ? 'dragging' : ''}`}
style={{ transform: `translateX(${offset}px)` }}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
>
<div className="history-status">{scan.isDuplicate ? <AlertTriangle /> : <CircleCheck />}</div>
<div className="history-value">
<strong>{scan.rawValue}</strong>
<span>{scan.format}</span>
</div>
<div className="history-meta">
<span className="status-label">{scan.isDuplicate ? 'DUPLICATE' : 'UNIQUE'}</span>
<time dateTime={scan.scannedAt}>{new Date(scan.scannedAt).toLocaleString()}</time>
</div>
<button className="swipe-menu" type="button" aria-label={`Actions for ${scan.rawValue}`} aria-expanded={isOpen} onClick={() => setOpen(!isOpen)}>
<MoreHorizontal />
</button>
</article>
</div>
)
}
export function HistoryView() {
const scans = useLiveQuery(() => database.scans.orderBy('scannedAt').reverse().toArray(), []) ?? []
const duplicates = scans.filter((scan) => scan.isDuplicate).length
const uniqueValues = countDistinctScanValues(scans)
const [openScanId, setOpenScanId] = useState<string | null>(null)
const [deletedScan, setDeletedScan] = useState<ScanRecord | null>(null)
useEffect(() => {
if (!deletedScan) return
const timer = window.setTimeout(() => setDeletedScan(null), 5000)
return () => window.clearTimeout(timer)
}, [deletedScan])
const removeScan = (scan: ScanRecord) => {
setOpenScanId(null)
setDeletedScan(scan)
void deleteScan(scan.id)
}
const undoDelete = () => {
if (!deletedScan) return
void restoreScan(deletedScan)
setDeletedScan(null)
}
const exportHistory = () => {
const url = URL.createObjectURL(new Blob([serializeScanHistory(scans)], { type: 'application/json' }))
const link = document.createElement('a')
link.href = url
link.download = `fieldlens-scans-${new Date().toISOString().slice(0, 10)}.json`
link.click()
URL.revokeObjectURL(url)
}
return (
<section className="workspace-view history-view" aria-labelledby="history-title">
<header className="view-heading">
<div>
<span className="eyebrow">DEVICE REGISTER</span>
<h1 id="history-title">Capture history</h1>
<p>Records stay available offline in this browser.</p>
</div>
<div className="view-actions">
<button className="quiet-button" type="button" disabled={!scans.length} onClick={exportHistory}>
<Download size={16} /> Export
</button>
<button className="quiet-button history-clear-button" type="button" disabled={!scans.length} onClick={() => { setDeletedScan(null); void clearScanHistory() }}>
<Trash2 size={16} /> Clear history
</button>
</div>
</header>
<div className="metric-strip">
<div><strong>{scans.length}</strong><span>Total captures</span></div>
<div><strong>{duplicates}</strong><span>Duplicates flagged</span></div>
<div><strong>{uniqueValues}</strong><span>Unique values</span></div>
</div>
<div className="history-list">
{!scans.length && <div className="empty-state"><Barcode /><strong>No scans recorded</strong><span>Open Scanner to capture the first code.</span></div>}
{scans.map((scan) => (
<HistoryRow scan={scan} isOpen={openScanId === scan.id} setOpen={(open) => setOpenScanId(open ? scan.id : null)} onDelete={() => removeScan(scan)} key={scan.id} />
))}
</div>
{deletedScan && (
<div className="undo-toast" role="status">
<span>Scan deleted</span>
<button type="button" onClick={undoDelete}><RotateCcw /> Undo</button>
</div>
)}
</section>
)
}

View File

@ -0,0 +1,51 @@
.ar-overlay { position: absolute; z-index: 5; inset: 0; pointer-events: none; overflow: hidden; }
.ar-overlay-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.ar-overlay-hud { position: absolute; top: 12px; left: 12px; display: grid; gap: 2px; min-width: 126px; max-width: 195px; padding: 8px 10px; border: 1px solid rgba(255,255,255,.24); border-radius: 6px; color: white; background: rgba(23,33,31,.7); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0,0,0,.16); }
.ar-mode-badge { color: var(--yellow); font-size: 7px; font-weight: 900; letter-spacing: .08em; }
.ar-overlay-hud strong { font: 800 17px/1.1 "IBM Plex Mono", "Courier New", monospace; }
.ar-overlay-hud small { color: #d5ddda; font-size: 7px; line-height: 1.35; }
.ar-detection-readout { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: grid; justify-items: center; gap: 2px; max-width: min(42%, 250px); padding: 8px 10px; border: 1px solid rgba(255,255,255,.22); border-radius: 6px; color: white; background: rgba(23,33,31,.78); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0,0,0,.16); }
.ar-detection-readout strong { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 800 11px/1.1 "IBM Plex Mono", "Courier New", monospace; }
.ar-detection-readout span { font-size: 7px; font-weight: 900; letter-spacing: .04em; text-transform: uppercase; }
.ar-detection-readout.candidate span { color: var(--yellow); }
.ar-detection-readout.accepted span { color: #66cf9d; }
.ar-detection-readout.invalid span { color: #ed736a; }
.ar-memory-map { position: absolute; top: 12px; right: 12px; display: grid; gap: 4px; width: 82px; padding: 6px; border: 1px solid rgba(255,255,255,.22); border-radius: 6px; color: white; background: rgba(23,33,31,.68); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0,0,0,.16); }
.ar-memory-map > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 6px; font-weight: 900; letter-spacing: .05em; text-align: center; }
.ar-memory-map > small { color: #d5ddda; font-size: 7px; text-align: center; }
.ar-memory-field { position: relative; height: 90px; overflow: hidden; border: 1px solid rgba(255,255,255,.16); border-radius: 3px; background-color: rgba(255,255,255,.035); background-image: linear-gradient(rgba(255,255,255,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.08) 1px, transparent 1px); background-size: 12.5% 10%; }
.ar-memory-field i { position: absolute; width: 5px; height: 5px; transform: translate(-50%, -50%); border: 1px solid rgba(255,255,255,.7); border-radius: 50%; background: #66cf9d; box-shadow: 0 0 0 2px rgba(102,207,157,.16); }
.ar-unavailable { position: absolute; top: 12px; right: 12px; max-width: 210px; padding: 8px 10px; border-radius: 5px; color: white; background: rgba(23,33,31,.78); font-size: 8px; line-height: 1.35; }
.ean-setup-card .ean-custom-row { grid-template-columns: auto minmax(150px, 190px) auto; justify-content: center; }
.ean-setup-card .ean-custom-row select { width: 100%; min-height: 40px; padding: 0 9px; border: 1px solid var(--line); border-radius: 5px; color: var(--ink); background: white; font-size: 11px; font-weight: 700; }
.ean-setup-card .ean-custom-row .primary-button { min-height: 40px; white-space: nowrap; }
.ean-ar-toggle { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 14px; width: min(100%, 452px); margin-top: 2px; padding: 11px 13px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); text-align: left; cursor: pointer; transition: border-color .16s ease, background .16s ease; }
.ean-ar-toggle:hover { border-color: #b7beb8; background: white; }
.ean-ar-toggle input { position: relative; order: 2; width: 40px; height: 22px; margin: 0; appearance: none; border: 1px solid #aeb6b1; border-radius: 999px; background: #dce0db; cursor: pointer; transition: background .16s ease, border-color .16s ease; }
.ean-ar-toggle input::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: white; box-shadow: 0 1px 3px rgba(0,0,0,.22); transition: transform .16s ease; }
.ean-ar-toggle input:checked { border-color: var(--green); background: var(--green); }
.ean-ar-toggle input:checked::after { transform: translateX(18px); }
.ean-ar-toggle input:focus-visible { outline: 3px solid var(--green); outline-offset: 2px; }
.ean-ar-toggle span { min-width: 0; }
.ean-ar-toggle strong, .ean-ar-toggle small { display: block; }
.ean-ar-toggle strong { font-size: 10px; }
.ean-ar-toggle small { margin-top: 3px; color: #74807b; font-size: 8px; line-height: 1.35; }
@media (max-width: 760px) {
.ar-overlay-hud { top: 9px; left: 9px; min-width: 116px; max-width: 155px; padding: 7px 8px; }
.ar-overlay-hud strong { font-size: 15px; }
.ar-memory-map { top: 9px; right: 9px; width: 68px; padding: 5px; }
.ar-memory-field { height: 70px; }
.ar-memory-field i { width: 4px; height: 4px; }
.ar-detection-readout { top: 72px; left: 50%; width: calc(100% - 188px); min-width: 138px; max-width: 220px; }
.ean-setup-card .ean-custom-row { grid-template-columns: minmax(0, 1fr) auto; width: 100%; }
.ean-setup-card .ean-custom-row label { grid-column: 1 / -1; }
.ean-setup-card .ean-custom-row select { min-width: 0; }
.ean-ar-toggle { width: 100%; }
}

View File

@ -0,0 +1,308 @@
import { useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import './ArPalletOverlay.css'
export type ArDetectionState = 'candidate' | 'accepted' | 'invalid'
export type ArDetection = {
ean: string
state: ArDetectionState
sourceWidth: number
sourceHeight: number
box: {
x: number
y: number
width: number
height: number
}
}
type ArRememberedPosition = {
scanNumber: number
ean: string
sourceWidth: number
sourceHeight: number
box: ArDetection['box']
}
type PointLike = {
getX?: () => number
getY?: () => number
x?: number
y?: number
}
function pointCoordinate(point: PointLike, axis: 'x' | 'y') {
const getter = axis === 'x' ? point.getX : point.getY
const fallback = axis === 'x' ? point.x : point.y
return typeof getter === 'function' ? getter.call(point) : typeof fallback === 'number' ? fallback : 0
}
export function createArDetection(
ean: string,
state: ArDetectionState,
points: PointLike[] | undefined,
sourceWidth: number,
sourceHeight: number,
): ArDetection | null {
if (!sourceWidth || !sourceHeight || !points?.length) return null
const xs = points.map((point) => pointCoordinate(point, 'x'))
const ys = points.map((point) => pointCoordinate(point, 'y'))
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
const rawWidth = Math.max(1, maxX - minX)
const rawHeight = Math.max(1, maxY - minY)
const width = Math.max(rawWidth + sourceWidth * 0.04, sourceWidth * 0.18)
const height = Math.max(rawHeight + sourceHeight * 0.035, sourceHeight * 0.09)
const centerX = (minX + maxX) / 2
const centerY = (minY + maxY) / 2
const x = Math.max(0, Math.min(sourceWidth - width, centerX - width / 2))
const y = Math.max(0, Math.min(sourceHeight - height, centerY - height / 2))
return {
ean,
state,
sourceWidth,
sourceHeight,
box: { x, y, width, height },
}
}
const STATUS_COLOR: Record<ArDetectionState, number> = {
candidate: 0xf4d35e,
accepted: 0x66cf9d,
invalid: 0xed736a,
}
function projectBox(
item: Pick<ArDetection, 'sourceWidth' | 'sourceHeight' | 'box'>,
hostWidth: number,
hostHeight: number,
) {
const scale = Math.max(hostWidth / item.sourceWidth, hostHeight / item.sourceHeight)
const renderedWidth = item.sourceWidth * scale
const renderedHeight = item.sourceHeight * scale
const offsetX = (hostWidth - renderedWidth) / 2
const offsetY = (hostHeight - renderedHeight) / 2
const left = offsetX + item.box.x * scale
const top = offsetY + item.box.y * scale
return {
left,
top,
right: left + item.box.width * scale,
bottom: top + item.box.height * scale,
}
}
function lineVertices(item: Pick<ArDetection, 'sourceWidth' | 'sourceHeight' | 'box'>, hostWidth: number, hostHeight: number) {
const { left, top, right, bottom } = projectBox(item, hostWidth, hostHeight)
return new Float32Array([
left, hostHeight - top, 0,
right, hostHeight - top, 0,
right, hostHeight - bottom, 0,
left, hostHeight - bottom, 0,
])
}
export function ArPalletOverlay({
enabled,
detection,
scanned,
target,
uniqueEans,
}: {
enabled: boolean
detection: ArDetection | null
scanned: number
target: number
uniqueEans: number
}) {
const hostRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const rendererRef = useRef<THREE.WebGLRenderer | null>(null)
const sceneRef = useRef<THREE.Scene | null>(null)
const cameraRef = useRef<THREE.OrthographicCamera | null>(null)
const lineRef = useRef<THREE.LineLoop | null>(null)
const materialRef = useRef<THREE.LineBasicMaterial | null>(null)
const memoryGroupRef = useRef<THREE.Group | null>(null)
const lastScannedRef = useRef(0)
const [available, setAvailable] = useState(true)
const [remembered, setRemembered] = useState<ArRememberedPosition[]>([])
useEffect(() => {
if (scanned === 0) {
lastScannedRef.current = 0
setRemembered([])
return
}
if (scanned < lastScannedRef.current) {
lastScannedRef.current = scanned
setRemembered((current) => current.filter((position) => position.scanNumber <= scanned))
return
}
if (scanned > lastScannedRef.current && detection?.state === 'accepted') {
const next: ArRememberedPosition = {
scanNumber: scanned,
ean: detection.ean,
sourceWidth: detection.sourceWidth,
sourceHeight: detection.sourceHeight,
box: { ...detection.box },
}
lastScannedRef.current = scanned
setRemembered((current) => [...current.filter((position) => position.scanNumber < scanned), next])
return
}
lastScannedRef.current = scanned
}, [detection, scanned])
useEffect(() => {
if (!enabled) return
const host = hostRef.current
const canvas = canvasRef.current
if (!host || !canvas) return
try {
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
renderer.setClearColor(0x000000, 0)
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
const scene = new THREE.Scene()
const camera = new THREE.OrthographicCamera(0, 1, 1, 0, -10, 10)
camera.position.z = 1
const geometry = new THREE.BufferGeometry()
const material = new THREE.LineBasicMaterial({ color: STATUS_COLOR.candidate, transparent: true, opacity: 0.98 })
const line = new THREE.LineLoop(geometry, material)
const memoryGroup = new THREE.Group()
line.visible = false
scene.add(memoryGroup)
scene.add(line)
rendererRef.current = renderer
sceneRef.current = scene
cameraRef.current = camera
lineRef.current = line
materialRef.current = material
memoryGroupRef.current = memoryGroup
setAvailable(true)
const render = () => {
const width = Math.max(1, host.clientWidth)
const height = Math.max(1, host.clientHeight)
renderer.setSize(width, height, false)
camera.left = 0
camera.right = width
camera.top = height
camera.bottom = 0
camera.updateProjectionMatrix()
renderer.render(scene, camera)
}
const resizeObserver = new ResizeObserver(render)
resizeObserver.observe(host)
render()
return () => {
resizeObserver.disconnect()
geometry.dispose()
material.dispose()
for (const child of [...memoryGroup.children]) {
memoryGroup.remove(child)
if (child instanceof THREE.LineLoop) {
child.geometry.dispose()
if (child.material instanceof THREE.Material) child.material.dispose()
}
}
renderer.dispose()
rendererRef.current = null
sceneRef.current = null
cameraRef.current = null
lineRef.current = null
materialRef.current = null
memoryGroupRef.current = null
}
} catch {
setAvailable(false)
}
}, [enabled])
useEffect(() => {
if (!enabled || !available) return
const host = hostRef.current
const renderer = rendererRef.current
const scene = sceneRef.current
const camera = cameraRef.current
const line = lineRef.current
const material = materialRef.current
const memoryGroup = memoryGroupRef.current
if (!host || !renderer || !scene || !camera || !line || !material || !memoryGroup) return
const hostWidth = Math.max(1, host.clientWidth)
const hostHeight = Math.max(1, host.clientHeight)
for (const child of [...memoryGroup.children]) {
memoryGroup.remove(child)
if (child instanceof THREE.LineLoop) {
child.geometry.dispose()
if (child.material instanceof THREE.Material) child.material.dispose()
}
}
for (const position of remembered) {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(lineVertices(position, hostWidth, hostHeight), 3))
const rememberedMaterial = new THREE.LineBasicMaterial({ color: STATUS_COLOR.accepted, transparent: true, opacity: 0.34 })
memoryGroup.add(new THREE.LineLoop(geometry, rememberedMaterial))
}
if (!detection) {
line.visible = false
renderer.render(scene, camera)
return
}
line.geometry.setAttribute('position', new THREE.BufferAttribute(lineVertices(detection, hostWidth, hostHeight), 3))
line.geometry.computeBoundingSphere()
material.color.setHex(STATUS_COLOR[detection.state])
line.visible = true
renderer.render(scene, camera)
}, [available, detection, enabled, remembered])
if (!enabled) return null
return (
<div className="ar-overlay" ref={hostRef} aria-hidden="true">
{available && <canvas ref={canvasRef} className="ar-overlay-canvas" />}
<div className="ar-overlay-hud">
<span className="ar-mode-badge">AR ASSIST</span>
<strong>{scanned} / {target}</strong>
<small>{uniqueEans || 0} EAN value{uniqueEans === 1 ? '' : 's'} seen · {remembered.length} positions remembered</small>
</div>
{remembered.length > 0 && (
<div className="ar-memory-map">
<span>POSITION MEMORY</span>
<div className="ar-memory-field">
{remembered.map((position) => {
const centerX = ((position.box.x + position.box.width / 2) / position.sourceWidth) * 100
const centerY = ((position.box.y + position.box.height / 2) / position.sourceHeight) * 100
return <i key={position.scanNumber} style={{ left: `${centerX}%`, top: `${centerY}%` }} />
})}
</div>
<small>{remembered.length} / {target}</small>
</div>
)}
{detection && (
<div className={`ar-detection-readout ${detection.state}`}>
<strong>{detection.ean}</strong>
<span>{detection.state === 'accepted' ? 'accepted · position saved' : detection.state === 'invalid' ? 'invalid EAN' : 'hold steady'}</span>
</div>
)}
{!available && <div className="ar-unavailable">AR overlay unavailable · scanner continues normally</div>}
</div>
)
}

View File

@ -0,0 +1,16 @@
.ean-config-picker { display: grid; width: min(100%, 452px); gap: 8px; text-align: left; }
.ean-config-picker > label { color: #66716d; font-size: 9px; font-weight: 900; text-transform: uppercase; }
.ean-config-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.ean-config-row .compact-input { width: 100%; min-width: 0; max-width: none; }
.ean-config-row .primary-button { white-space: nowrap; }
.ean-config-editor { display: grid; grid-template-columns: minmax(0, 1fr) 110px auto; gap: 8px; padding: 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); }
.ean-config-editor .compact-input { width: 100%; min-width: 0; max-width: none; }
.ean-config-editor .quiet-button { white-space: nowrap; }
.ean-config-note { color: #6f7b76; font-size: 9px; line-height: 1.35; }
@media (max-width: 520px) {
.ean-config-row { grid-template-columns: 1fr; }
.ean-config-row .primary-button { width: 100%; }
.ean-config-editor { grid-template-columns: minmax(0, 1fr) 92px; }
.ean-config-editor .quiet-button { grid-column: 1 / -1; width: 100%; }
}

View File

@ -0,0 +1,108 @@
import { useMemo, useState } from 'react'
import { loadCartonConfigs, upsertCartonConfig } from '../../lib/cartonConfigs'
import './CartonConfigPicker.css'
const PRESETS = [
{ value: 160, label: '160 · 8 × 20' },
{ value: 176, label: '176 · 8 × 22' },
] as const
function initialSelection(value: number, saved: ReturnType<typeof loadCartonConfigs>) {
const preset = PRESETS.find((option) => option.value === value)
if (preset) return `preset:${preset.value}`
const config = saved.find((option) => option.count === value)
return config ? `saved:${config.id}` : 'custom'
}
export function CartonConfigPicker({
value,
onChange,
onStart,
}: {
value: number
onChange: (count: number) => void
onStart: () => void
}) {
const [savedConfigs, setSavedConfigs] = useState(loadCartonConfigs)
const [selection, setSelection] = useState(() => initialSelection(value, loadCartonConfigs()))
const [name, setName] = useState('')
const [message, setMessage] = useState('')
const custom = selection === 'custom'
const selectedSavedConfig = useMemo(
() => selection.startsWith('saved:') ? savedConfigs.find((config) => `saved:${config.id}` === selection) : undefined,
[savedConfigs, selection],
)
const choose = (nextSelection: string) => {
setSelection(nextSelection)
setMessage('')
if (nextSelection === 'custom') {
onChange(0)
return
}
if (nextSelection.startsWith('preset:')) {
onChange(Number(nextSelection.slice('preset:'.length)))
return
}
const config = savedConfigs.find((candidate) => `saved:${candidate.id}` === nextSelection)
if (config) onChange(config.count)
}
const saveCustom = () => {
const cleanName = name.trim()
if (!cleanName || value < 1 || value > 500) {
setMessage('Add a name and carton count first.')
return
}
const saved = upsertCartonConfig(savedConfigs, cleanName, value)
setSavedConfigs(saved.configs)
setSelection(`saved:${saved.config.id}`)
setName('')
setMessage(`${saved.config.name} saved on this device.`)
}
return (
<div className="ean-config-picker">
<label htmlFor="ean-carton-config">Carton config</label>
<div className="ean-config-row">
<select id="ean-carton-config" className="compact-input" value={selection} onChange={(event) => choose(event.target.value)}>
{PRESETS.map((option) => <option value={`preset:${option.value}`} key={option.value}>{option.label}</option>)}
{savedConfigs.map((config) => <option value={`saved:${config.id}`} key={config.id}>{config.name} · {config.count}</option>)}
<option value="custom">Custom…</option>
</select>
<button className="primary-button" type="button" disabled={value < 1} onClick={onStart}>Start scan</button>
</div>
{custom && (
<div className="ean-config-editor">
<input
className="compact-input"
type="text"
maxLength={40}
placeholder="Config name"
aria-label="Carton config name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<input
className="compact-input"
type="number"
min="1"
max="500"
inputMode="numeric"
placeholder="Cartons"
aria-label="Custom carton count"
value={value || ''}
onChange={(event) => onChange(Number(event.target.value))}
/>
<button className="quiet-button" type="button" disabled={!name.trim() || value < 1} onClick={saveCustom}>Save config</button>
</div>
)}
{!custom && selectedSavedConfig && <small className="ean-config-note">Saved locally · {selectedSavedConfig.count} cartons</small>}
{message && <small className="ean-config-note">{message}</small>}
</div>
)
}

View File

@ -0,0 +1,276 @@
.label-stepper-three { grid-template-columns: repeat(3, 1fr); }
.carton-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: white;
}
.carton-section-head > div { min-width: 0; }
.carton-section-head span,
.carton-section-actions span {
display: block;
color: #73807b;
font-size: 8px;
font-weight: 900;
text-transform: uppercase;
}
.carton-section-head strong { display: block; margin-top: 2px; font-size: 14px; }
.carton-section-head small,
.carton-section-actions small { display: block; margin-top: 3px; color: #78837f; font-size: 8px; line-height: 1.35; }
.carton-section-head .quiet-button { flex: 0 0 auto; min-height: 38px; }
.carton-batch-stats { grid-template-columns: repeat(4, minmax(0,1fr)); }
.carton-batch-stats > div:nth-child(3) { display: block; }
.carton-label-camera { min-height: 540px; }
.carton-batch-camera { overflow: hidden; background: #17211f; }
.carton-batch-camera video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.carton-video-coordinate-space {
position: absolute;
z-index: 5;
pointer-events: none;
transform-origin: 0 0;
}
.carton-batch-box {
position: absolute;
z-index: 5;
min-width: 18px;
min-height: 9px;
border: 2px solid rgba(244,211,94,.95);
border-radius: 4px;
box-shadow: 0 0 0 1px rgba(0,0,0,.22), 0 4px 12px rgba(0,0,0,.16);
pointer-events: none;
transition: left .12s linear, top .12s linear, width .12s linear, height .12s linear, border-color .12s ease, background .12s ease;
}
.carton-batch-box > span {
position: absolute;
right: -2px;
top: -18px;
min-width: 18px;
height: 16px;
padding: 0 4px;
border-radius: 3px 3px 0 0;
color: #17211f;
background: var(--yellow);
font: 900 8px/16px "IBM Plex Mono", "Courier New", monospace;
text-align: center;
}
.carton-batch-box.queued { border-style: dashed; background: rgba(244,211,94,.07); }
.carton-batch-box.reading { border-width: 3px; background: rgba(244,211,94,.12); }
.carton-batch-box.accepted { border-color: #58d3a7; background: rgba(39,153,112,.1); }
.carton-batch-box.accepted > span { color: white; background: #208763; }
.carton-batch-box.accepted.remembered {
border-style: dashed;
border-color: rgba(88,211,167,.78);
background: rgba(39,153,112,.055);
box-shadow: 0 0 0 1px rgba(12,69,52,.16);
opacity: .72;
}
.carton-batch-box.accepted.remembered > span { opacity: .86; }
.carton-batch-box.retry { border-color: #f0bd56; background: rgba(240,189,86,.12); }
.carton-batch-box.retry > span { background: #f0bd56; }
.carton-batch-box.ignored { border-color: rgba(184,194,190,.7); border-style: dashed; background: rgba(135,148,143,.08); opacity: .66; }
.carton-batch-box.ignored > span { color: white; background: rgba(91,104,99,.9); }
.carton-batch-instruction {
position: absolute;
z-index: 6;
top: 13px;
left: 50%;
display: grid;
max-width: calc(100% - 28px);
padding: 8px 11px;
transform: translateX(-50%);
border: 1px solid rgba(255,255,255,.16);
border-radius: 6px;
color: white;
background: rgba(18,29,26,.76);
backdrop-filter: blur(8px);
text-align: center;
pointer-events: none;
}
.carton-batch-instruction > span { color: var(--yellow); font-size: 8px; font-weight: 900; text-transform: uppercase; }
.carton-batch-instruction strong { margin-top: 2px; font-size: 12px; }
.carton-batch-instruction small { margin-top: 2px; color: #d6dfdc; font-size: 8px; line-height: 1.35; }
.carton-batch-instruction em {
justify-self: center;
margin-top: 5px;
padding: 2px 6px;
border: 1px solid rgba(88,211,167,.28);
border-radius: 999px;
color: #9de8cf;
background: rgba(13,63,49,.5);
font-size: 6.5px;
font-style: normal;
font-weight: 900;
letter-spacing: .035em;
text-transform: uppercase;
}
.carton-batch-ocr-status {
position: absolute;
z-index: 8;
left: 12px;
bottom: 12px;
width: min(230px, calc(100% - 24px));
padding: 8px 9px;
border: 1px solid rgba(255,255,255,.18);
border-radius: 6px;
color: white;
background: rgba(17,29,25,.84);
backdrop-filter: blur(8px);
}
.carton-batch-ocr-status strong,
.carton-batch-ocr-status > span { display: block; }
.carton-batch-ocr-status strong { font-size: 9px; }
.carton-batch-ocr-status > span { margin: 3px 0 7px; color: #cbd6d2; font-size: 8px; }
.carton-batch-ocr-status .label-progress { width: 100%; }
.carton-live-fields {
position: absolute;
z-index: 7;
top: 70px;
right: 10px;
width: min(34%, 230px);
max-height: 118px;
overflow: hidden;
border: 1px solid rgba(255,255,255,.2);
border-radius: 6px;
background: rgba(17,29,25,.8);
box-shadow: 0 8px 22px rgba(0,0,0,.18);
backdrop-filter: blur(7px);
color: white;
}
.carton-live-fields > span { display: block; padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,.12); color: #c5d0cc; font-size: 6.5px; font-weight: 900; text-transform: uppercase; }
.carton-live-fields > div { display: grid; grid-template-columns: 52px minmax(0,1fr); gap: 6px; align-items: center; padding: 4px 8px; border-top: 1px solid rgba(255,255,255,.08); }
.carton-live-fields > div:first-of-type { border-top: 0; }
.carton-live-fields b { color: #aebbb6; font-size: 6.5px; text-transform: uppercase; }
.carton-live-fields strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 8.5px/1.2 "IBM Plex Mono", "Courier New", monospace; }
.carton-section-actions {
display: grid;
grid-template-columns: minmax(0,1fr) auto auto;
align-items: center;
gap: 9px;
padding: 11px 13px;
border-top: 1px solid var(--line);
background: white;
}
.carton-section-actions > div { min-width: 0; }
.carton-section-actions .primary-button,
.carton-section-actions .quiet-button { min-height: 42px; white-space: nowrap; }
.carton-label-camera.scanner-starting::before {
content: '';
position: absolute;
z-index: 10;
inset: 0;
background: rgba(18,27,25,.62);
}
.carton-label-camera.scanner-starting::after {
content: 'Preparing camera…';
position: absolute;
z-index: 11;
top: 50%;
left: 50%;
padding: 12px 15px;
transform: translate(-50%, -50%);
border: 1px solid rgba(255,255,255,.2);
border-radius: 6px;
color: white;
background: rgba(23,33,31,.92);
font-size: 10px;
font-weight: 900;
white-space: nowrap;
}
.carton-final-summary { grid-template-columns: repeat(4, 1fr); }
.carton-majority-fields { border-top: 1px solid var(--line); }
.carton-majority-head,
.carton-majority-fields > div:not(.carton-majority-head) { display: grid; grid-template-columns: 150px minmax(0,1fr) 100px; gap: 12px; align-items: center; padding: 10px 16px; }
.carton-majority-head { color: #6f7b76; background: var(--paper); font-size: 8px; font-weight: 900; text-transform: uppercase; }
.carton-majority-fields > div:not(.carton-majority-head) { min-height: 46px; border-top: 1px solid var(--line); font-size: 10px; }
.carton-majority-fields strong { font-size: 10px; }
.carton-majority-fields b { justify-self: end; padding: 4px 7px; border-radius: 999px; background: var(--paper); font-size: 8px; }
.carton-issues { border-top: 1px solid var(--line); }
.carton-issues > header { display: flex; justify-content: space-between; gap: 12px; padding: 11px 16px; background: #fff5c8; }
.carton-issues > header strong { font-size: 10px; }
.carton-issues > header span { color: #6d5a13; font-size: 8px; font-weight: 900; }
.carton-issues > div { display: grid; grid-template-columns: 82px 100px 90px 90px minmax(0,1fr) 44px minmax(0,1fr); gap: 8px; align-items: center; min-height: 48px; padding: 8px 16px; border-top: 1px solid var(--line); font-size: 9px; }
.carton-issues > div.mismatch { background: #fff8f7; }
.carton-issues > div.unread { background: #fffdf1; }
.carton-issues > div > span { font-weight: 900; }
.carton-issues > div > em { color: #697570; font-size: 8px; font-style: normal; }
.carton-issues > div > small { color: #77817d; font-size: 7px; font-weight: 900; text-transform: uppercase; }
.carton-issues > div > b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 9px/1.2 "IBM Plex Mono", "Courier New", monospace; }
.carton-issues > p { margin: 0; padding: 10px 16px; color: #697570; font-size: 9px; }
.carton-conformity-card.review { border-top: 5px solid var(--yellow); }
.carton-conformity-card.review .label-result-icon { color: var(--ink); background: var(--yellow); }
@media (max-width: 760px) {
.label-stepper-three,
.label-stepper.label-stepper-three { grid-template-columns: repeat(3, minmax(0,1fr)); }
/* Feature CSS is lazy-loaded: own the standalone iOS safe-area after LabelCheckView.css. */
@supports selector(:has(*)) {
.carton-label-view:has(.carton-label-scan-panel) {
width: 100%;
max-width: none;
min-height: 100dvh;
padding: env(safe-area-inset-top) 0 0 !important;
gap: 0;
background: var(--ink);
}
.carton-label-view:has(.carton-label-scan-panel) .label-check-heading,
.carton-label-view:has(.carton-label-scan-panel) .label-stepper { display: none; }
.carton-label-view:has(.carton-label-scan-panel) .carton-label-scan-panel { border: 0; border-radius: 0; }
}
.carton-section-head { padding: 9px 10px; }
.carton-section-head strong { font-size: 12px; }
.carton-section-head small { max-width: 250px; font-size: 7px; }
.carton-section-head .quiet-button { min-height: 34px; padding: 0 9px; font-size: 9px; }
.carton-batch-stats { grid-template-columns: repeat(4, minmax(0,1fr)); }
.carton-batch-stats > div { padding: 7px 6px; }
.carton-batch-stats span { font-size: 6px; }
.carton-batch-stats strong { font-size: 11px; }
.carton-label-camera {
min-height: max(410px, calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 286px)) !important;
}
.carton-batch-instruction { top: 9px; max-width: calc(100% - 18px); }
.carton-batch-instruction strong { font-size: 11px; }
.carton-batch-instruction small { font-size: 7px; }
.carton-batch-instruction em { margin-top: 4px; font-size: 5.8px; }
.carton-live-fields { top: 68px; right: 8px; width: 40%; max-height: 102px; }
.carton-live-fields > span { padding: 5px 6px; font-size: 6px; }
.carton-live-fields > div { grid-template-columns: 42px minmax(0,1fr); gap: 4px; padding: 3px 6px; }
.carton-live-fields b { font-size: 6px; }
.carton-live-fields strong { font-size: 7.5px; }
.carton-batch-ocr-status { left: 8px; bottom: 8px; width: min(210px, calc(100% - 16px)); padding: 7px 8px; }
.carton-section-actions { grid-template-columns: 1fr auto; gap: 7px; padding: 9px 10px calc(9px + env(safe-area-inset-bottom)); }
.carton-section-actions > div { grid-column: 1 / -1; }
.carton-section-actions .primary-button { min-height: 44px; }
.carton-section-actions .quiet-button { min-height: 44px; padding: 0 10px; }
.carton-final-summary { grid-template-columns: 1fr 1fr; }
.carton-majority-head { display: none; }
.carton-majority-fields > div:not(.carton-majority-head) { grid-template-columns: 92px minmax(0,1fr) auto; padding: 9px 12px; }
.carton-majority-fields b { font-size: 7px; }
.carton-issues > div { grid-template-columns: 72px minmax(0,1fr); gap: 4px 8px; padding: 9px 12px; }
.carton-issues > div > span,
.carton-issues > div > em,
.carton-issues > div > small { grid-column: 1; }
.carton-issues > div > strong,
.carton-issues > div > b { grid-column: 2; }
}

View File

@ -0,0 +1,172 @@
.label-check-view { display: grid; gap: 18px; }
.label-check-heading { margin-bottom: 0; }
.label-check-heading .view-actions { align-self: center; }
.label-stepper { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin: 0; padding: 0; overflow: hidden; list-style: none; border: 1px solid var(--line); border-radius: 6px; background: var(--line); }
.label-stepper-five { grid-template-columns: repeat(5, 1fr); }
.label-stepper li { display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 46px; padding: 0 12px; color: #7a8581; background: white; font-size: 9px; font-weight: 900; text-transform: uppercase; }
.label-stepper li span { display: grid; flex: 0 0 auto; width: 22px; height: 22px; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: var(--paper); font-size: 9px; }
.label-stepper li.active { color: var(--ink); box-shadow: inset 0 -3px var(--yellow); }
.label-stepper li.done span { color: white; background: var(--green); border-color: var(--green); }
.label-scan-panel, .label-capture-card, .label-processing-card, .label-result-card, .label-setup-card, .ean-setup-card, .ean-conformity-card { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: white; }
.ean-setup-card { display: grid; justify-items: center; gap: 13px; padding: 28px; text-align: center; }
.ean-setup-card > svg { color: var(--green); }
.ean-setup-card h2 { margin: 0; font-family: Georgia, serif; font-size: 28px; font-weight: 500; }
.ean-setup-card p { max-width: 610px; margin: 0; color: #68746f; font-size: 12px; line-height: 1.5; }
.ean-preset-grid { display: grid; width: min(100%, 452px); grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 5px; }
.ean-preset-grid button { display: grid; height: 74px; align-content: center; gap: 3px; }
.ean-preset-grid strong { font-size: 24px; line-height: 1; }
.ean-preset-grid span { font-size: 9px; }
.ean-custom-row { display: grid; width: min(100%, 452px); grid-template-columns: 1fr 92px auto; align-items: center; gap: 8px; }
.ean-custom-row label { color: #66716d; font-size: 9px; font-weight: 900; text-align: left; text-transform: uppercase; }
.ean-custom-row input { width: 100%; min-height: 40px; padding: 0 9px; border: 1px solid var(--line); border-radius: 5px; font: 700 13px/1 "IBM Plex Mono", "Courier New", monospace; }
.ean-custom-row .quiet-button { min-height: 40px; }
.ean-session-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) auto; gap: 1px; background: var(--line); }
.ean-session-strip > div { min-width: 0; padding: 11px 13px; background: white; }
.ean-session-strip span, .ean-distribution span, .ean-final-summary span { display: block; color: #74807b; font-size: 8px; font-weight: 900; text-transform: uppercase; }
.ean-session-strip strong { display: block; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 14px/1.2 "IBM Plex Mono", "Courier New", monospace; }
.ean-session-strip button { align-self: center; min-height: 38px; margin: 7px; }
.ean-session-strip button:disabled { opacity: .45; }
.ean-progress-track { height: 6px; overflow: hidden; background: #e6e9e4; }
.ean-progress-track span { display: block; height: 100%; background: var(--yellow); transition: width .18s ease; }
.label-camera-stage { position: relative; min-height: 500px; overflow: hidden; background: #202c29; isolation: isolate; }
.label-camera-stage video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.label-camera-shade { position: absolute; z-index: 2; inset: 0; background: linear-gradient(180deg, rgba(23,33,31,.52), transparent 24%, transparent 68%, rgba(23,33,31,.72)); pointer-events: none; }
.label-scan-frame { position: absolute; z-index: 3; top: 50%; left: 50%; width: min(76%, 620px); height: 170px; transform: translate(-50%, -50%); }
.label-scan-frame span { position: absolute; width: 48px; height: 48px; border-color: var(--yellow); border-style: solid; }
.label-scan-frame span:nth-child(1) { left: 0; top: 0; border-width: 4px 0 0 4px; }
.label-scan-frame span:nth-child(2) { right: 0; top: 0; border-width: 4px 4px 0 0; }
.label-scan-frame span:nth-child(3) { left: 0; bottom: 0; border-width: 0 0 4px 4px; }
.label-scan-frame span:nth-child(4) { right: 0; bottom: 0; border-width: 0 4px 4px 0; }
.label-camera-copy { position: absolute; z-index: 6; left: 24px; right: 24px; bottom: 20px; display: grid; justify-items: center; gap: 4px; color: white; text-align: center; pointer-events: none; }
.label-camera-copy svg { width: 18px; color: var(--yellow); }
.label-camera-copy strong { font-size: 14px; }
.label-camera-copy small { max-width: 520px; color: #d7dfdc; font-size: 10px; line-height: 1.4; }
.ean-live-warning { display: flex; gap: 10px; align-items: center; padding: 11px 14px; color: #6d5a13; background: #fff5c8; border-top: 1px solid #eadb91; font-size: 10px; }
.ean-live-warning svg, .label-error svg { flex: 0 0 auto; width: 16px; }
.label-error { display: flex; align-items: center; gap: 10px; padding: 11px 14px; color: #7f2924; background: var(--red-soft); border-top: 1px solid #ecc4bd; font-size: 10px; }
.label-error button { margin-left: auto; border: 0; color: #7f2924; background: transparent; font-weight: 900; }
.label-capture-card, .label-processing-card { display: grid; justify-items: center; gap: 13px; padding: 30px; text-align: center; }
.label-capture-card > svg, .label-processing-card > svg { color: var(--green); }
.label-capture-card h2, .label-processing-card h2, .label-result-card h2, .ean-conformity-card h2 { margin: 0; font-family: Georgia, serif; font-size: 28px; font-weight: 500; }
.label-capture-card p, .label-processing-card p, .label-result-card p, .ean-conformity-card p { max-width: 620px; margin: 0; color: #68746f; font-size: 12px; line-height: 1.5; }
.label-ean-chip { display: inline-flex; align-items: center; gap: 8px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 999px; background: var(--paper); }
.label-ean-chip span { color: #74807b; font-size: 8px; font-weight: 900; }
.label-ean-chip strong { font: 700 11px/1 "IBM Plex Mono", "Courier New", monospace; }
.label-photo-button { cursor: pointer; }
.label-photo-button input { display: none; }
.label-photo-preview { width: min(100%, 620px); max-height: 420px; object-fit: contain; border: 1px solid var(--line); border-radius: 6px; background: #eef0eb; }
.label-progress { width: min(100%, 520px); height: 7px; overflow: hidden; border-radius: 999px; background: #e6e9e4; }
.label-progress span { display: block; height: 100%; background: var(--yellow); transition: width .2s ease; }
.label-result-card.pass, .ean-conformity-card.pass { border-top: 5px solid var(--green); }
.label-result-card.fail, .ean-conformity-card.fail { border-top: 5px solid var(--red); }
.label-result-card.review, .label-result-card.not-found { border-top: 5px solid var(--yellow); }
.label-result-heading { display: grid; grid-template-columns: 48px 1fr; gap: 15px; padding: 20px; align-items: start; }
.label-result-icon { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 6px; background: var(--paper); }
.pass .label-result-icon { color: white; background: var(--green); }
.fail .label-result-icon { color: white; background: var(--red); }
.review .label-result-icon, .not-found .label-result-icon { color: var(--ink); background: var(--yellow); }
.ean-distribution { border-top: 1px solid var(--line); }
.ean-distribution > div { display: grid; grid-template-columns: 130px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 54px; padding: 9px 16px; border-top: 1px solid var(--line); }
.ean-distribution > div:first-child { border-top: 0; }
.ean-distribution strong { overflow: hidden; text-overflow: ellipsis; font: 700 13px/1.2 "IBM Plex Mono", "Courier New", monospace; }
.ean-distribution b { padding: 4px 7px; border-radius: 999px; background: var(--paper); font-size: 9px; white-space: nowrap; }
.ean-distribution .consensus { background: #f8f8f2; }
.ean-ocr-choice { display: grid; grid-template-columns: 1fr minmax(220px, 330px); align-items: center; gap: 16px; padding: 14px 16px; border-top: 1px solid var(--line); }
.ean-ocr-choice strong, .ean-ocr-choice small { display: block; }
.ean-ocr-choice strong { font-size: 11px; }
.ean-ocr-choice small { margin-top: 3px; color: #75807b; font-size: 9px; line-height: 1.35; }
.ean-ocr-choice select { min-height: 40px; width: 100%; padding: 0 9px; border: 1px solid var(--line); border-radius: 5px; background: white; }
.label-comparison-table { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.label-comparison-head, .label-comparison-row { display: grid; grid-template-columns: 1fr 1.2fr 1.2fr .8fr; gap: 12px; align-items: center; padding: 11px 16px; }
.label-comparison-head { color: #6f7b76; background: var(--paper); font-size: 8px; font-weight: 900; text-transform: uppercase; }
.label-comparison-row { min-height: 48px; border-top: 1px solid var(--line); font-size: 10px; }
.label-comparison-row:first-of-type { border-top: 0; }
.label-comparison-row b { justify-self: start; padding: 4px 7px; border-radius: 999px; font-size: 8px; text-transform: uppercase; }
.label-comparison-row.match b { color: #1e654f; background: #e4f3ec; }
.label-comparison-row.mismatch b { color: #8e2c27; background: var(--red-soft); }
.label-comparison-row.unread b { color: #6d5a13; background: #fff5c8; }
.ean-final-summary { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); background: var(--paper); }
.ean-final-summary span { padding: 12px 14px; border-right: 1px solid var(--line); }
.ean-final-summary span:last-child { border-right: 0; }
.ean-final-summary strong { display: block; margin-top: 3px; color: var(--ink); font-size: 11px; }
.label-result-meta { display: flex; flex-wrap: wrap; gap: 14px; padding: 12px 20px; color: #697570; font-size: 9px; }
.label-result-meta strong { color: var(--ink); }
.label-ocr-details { margin: 0 20px 16px; border: 1px solid var(--line); border-radius: 5px; }
.label-ocr-details summary { padding: 10px 11px; cursor: pointer; font-size: 9px; font-weight: 900; }
.label-ocr-details pre { margin: 0; padding: 12px; overflow: auto; border-top: 1px solid var(--line); background: var(--paper); white-space: pre-wrap; font-size: 9px; line-height: 1.45; }
.label-result-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 16px 20px 20px; }
.label-setup-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 16px; padding: 20px; }
.label-setup-card strong { font-size: 13px; }
.label-setup-card p { margin: 4px 0 0; color: #68746f; font-size: 10px; }
@media (max-width: 760px) {
.label-check-view { gap: 12px; }
.label-check-heading { gap: 10px; }
.label-check-heading h1 { font-size: 32px; }
.label-check-heading p { font-size: 11px; line-height: 1.4; }
.label-check-heading .view-actions { display: flex; flex-wrap: wrap; }
.label-check-heading .quiet-button { width: auto; min-height: 38px; padding: 0 11px; }
.label-stepper, .label-stepper-five { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.label-stepper li { display: grid; justify-items: center; justify-content: stretch; min-width: 0; min-height: 44px; gap: 3px; padding: 5px 2px; font-size: 7px; text-align: center; }
.label-stepper li span { width: 19px; height: 19px; font-size: 8px; }
.ean-setup-card { gap: 12px; padding: 22px 16px; }
.ean-setup-card h2 { font-size: 25px; }
.ean-setup-card p { font-size: 11px; }
.ean-preset-grid { width: 100%; }
.ean-preset-grid button { height: 68px; }
.ean-custom-row { width: 100%; grid-template-columns: 82px 1fr; }
.ean-custom-row label { grid-column: 1 / -1; }
.ean-custom-row .quiet-button { padding: 0 9px; }
.ean-session-strip { grid-template-columns: 1fr 1fr auto; }
.ean-session-strip > div { padding: 9px 10px; }
.ean-session-strip > div:nth-child(3) { display: none; }
.ean-session-strip strong { font-size: 13px; }
.ean-session-strip button { margin: 5px; padding: 0 9px; font-size: 9px; }
.label-camera-stage { min-height: 480px; }
.label-scan-frame { width: 86%; height: 145px; }
.label-camera-copy { left: 18px; right: 18px; bottom: 16px; }
.label-camera-copy strong { font-size: 13px; }
.label-camera-copy small { font-size: 9px; }
.label-capture-card, .label-processing-card { padding: 22px 16px; }
.label-capture-card h2, .label-processing-card h2, .label-result-card h2, .ean-conformity-card h2 { font-size: 25px; }
.ean-distribution > div { grid-template-columns: minmax(0, 1fr) auto; }
.ean-distribution span { grid-column: 1 / -1; }
.ean-ocr-choice { grid-template-columns: 1fr; }
.ean-final-summary { grid-template-columns: 1fr 1fr; }
.ean-final-summary span:nth-child(2) { border-right: 0; }
.ean-final-summary span:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
.label-comparison-head { display: none; }
.label-comparison-row { grid-template-columns: 1fr 1fr; gap: 6px 10px; }
.label-comparison-row strong { grid-column: 1 / -1; }
.label-comparison-row b { justify-self: end; }
.label-result-heading { grid-template-columns: 42px 1fr; padding: 16px; }
.label-result-icon { width: 40px; height: 40px; }
.label-result-meta { padding: 10px 16px; }
.label-ocr-details { margin: 0 16px 14px; }
.label-result-actions { display: grid; grid-template-columns: 1fr; padding: 14px 16px 18px; }
.label-result-actions .primary-button, .label-result-actions .quiet-button { width: 100%; }
.label-setup-card { grid-template-columns: 1fr; justify-items: start; }
@supports selector(:has(*)) {
.label-check-view:has(.label-scan-panel) { max-width: none; padding: 0; gap: 0; }
.label-check-view:has(.label-scan-panel) .label-check-heading,
.label-check-view:has(.label-scan-panel) .label-stepper { display: none; }
.label-check-view:has(.label-scan-panel) .label-scan-panel { border: 0; border-radius: 0; }
.label-check-view:has(.label-scan-panel) .label-camera-stage { min-height: calc(100dvh - 70px - env(safe-area-inset-bottom) - 92px); }
}
}

View File

@ -0,0 +1,920 @@
import { AlertTriangle, ArrowDown, CheckCircle2, Pause, Play, RefreshCw, RotateCcw, ScanLine, Settings, XCircle } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
boxIntersectsVideo,
estimateSectionHomography,
getCoverCoordinateSpace,
IDENTITY_HOMOGRAPHY,
invertHomography,
projectBox,
type CoverCoordinateSpace,
type Homography,
} from '../../lib/cartonArTracking'
import {
finishPalletLabelSession,
savePalletLabelScans,
startPalletLabelSession,
} from '../../lib/database'
import {
createCartonLabelScan,
MIN_RECOGNIZED_LABEL_FIELDS,
summarizeCartonLabels,
type CartonLabelScan,
type CartonLabelSection,
} from '../../lib/cartonLabelConformity'
import {
boxCenterDistance,
boxIou,
boxSizeDifference,
cropDetectedLabel,
detectWhiteLabelBoxes,
type LabelBox,
type LabelDetection,
} from '../../lib/cartonLabelDetection'
import { associateLabelDetections } from '../../lib/cartonTrackAssociation'
import {
hasCartonLabelIdentity,
LABEL_FIELD_KEYS,
LABEL_FIELD_LABELS,
recognizeLabel,
warmLabelOcr,
type LabelFields,
} from '../../lib/labelOcr'
import { CartonConfigPicker } from './CartonConfigPicker'
import './LabelCheckView.css'
import './CartonLabelScan.css'
type Step = 'setup' | 'scan' | 'result'
type TrackStatus = 'tracking' | 'queued' | 'reading' | 'accepted' | 'retry' | 'ignored'
type LabelTrack = {
id: string
box: LabelBox
anchorBox: LabelBox
stableFrames: number
missedFrames: number
status: TrackStatus
retryAfter: number
recognizedFields?: number
memoryId?: string
}
type RememberedAnchor = {
id: string
anchorBox: LabelBox
labelNumber: number
}
type OcrQueueItem = {
trackId: string
anchorBox: LabelBox
section: CartonLabelSection
sessionId: string
image: Blob
}
const CARTON_COUNT_STORAGE_KEY = 'fieldlens-ean-carton-count'
const DEFAULT_CARTON_COUNT = 160
const DETECTION_INTERVAL_MS = 360
const MIN_STABLE_FRAMES = 2
const MAX_MISSED_FRAMES = 4
const OCR_RETRY_DELAY_MS = 1600
const OCR_CONCURRENCY = 2
const MAX_OCR_QUEUE = 8
const STEP_INDEX: Record<Step, number> = {
setup: 0,
scan: 1,
result: 2,
}
function loadCartonCount() {
try {
const stored = Number(localStorage.getItem(CARTON_COUNT_STORAGE_KEY))
return Number.isFinite(stored) && stored >= 1 && stored <= 500 ? Math.round(stored) : DEFAULT_CARTON_COUNT
} catch {
return DEFAULT_CARTON_COUNT
}
}
function saveCartonCount(count: number) {
try {
localStorage.setItem(CARTON_COUNT_STORAGE_KEY, String(count))
} catch {
// The picker remains usable if browser storage is unavailable.
}
}
function visibleFields(fields?: LabelFields) {
if (!fields) return []
return LABEL_FIELD_KEYS
.filter((field) => fields[field].trim())
.map((field) => ({ field, label: LABEL_FIELD_LABELS[field], value: fields[field] }))
}
function sectionKey(section: CartonLabelSection) {
return `${section.face}-${section.half}`
}
function sectionLabel(section: CartonLabelSection) {
return `Face ${section.face} · ${section.half === 'top' ? 'Top half' : 'Bottom half'}`
}
function blendBox(previous: LabelBox, next: LabelBox): LabelBox {
const nextWeight = 0.46
const previousWeight = 1 - nextWeight
return {
x: previous.x * previousWeight + next.x * nextWeight,
y: previous.y * previousWeight + next.y * nextWeight,
width: previous.width * previousWeight + next.width * nextWeight,
height: previous.height * previousWeight + next.height * nextWeight,
}
}
function samePhysicalAnchor(left: LabelBox, right: LabelBox) {
const sizeDifference = boxSizeDifference(left, right)
if (sizeDifference > 0.46) return false
const iou = boxIou(left, right)
if (iou >= 0.34) return true
const distance = boxCenterDistance(left, right)
const scale = Math.max(0.018, Math.min(0.052, Math.min(left.width, right.width) * 0.32))
return distance <= scale
}
export function LabelCheckView({ onOpenSettings }: { onOpenSettings: () => void }) {
const videoRef = useRef<HTMLVideoElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const labelsRef = useRef<CartonLabelScan[]>([])
const sessionIdRef = useRef('')
const tracksRef = useRef<LabelTrack[]>([])
const trackCounterRef = useRef(0)
const queueRef = useRef<OcrQueueItem[]>([])
const activeOcrWorkersRef = useRef(0)
const activeTrackIdsRef = useRef(new Set<string>())
const acceptedAnchorsRef = useRef(new Map<string, RememberedAnchor>())
const homographyRef = useRef<Homography>([...IDENTITY_HOMOGRAPHY])
const sectionRef = useRef<CartonLabelSection>({ face: 1, half: 'top' })
const sectionStartCountRef = useRef(0)
const targetCountRef = useRef(loadCartonCount())
const finishedRef = useRef(false)
const [step, setStep] = useState<Step>('setup')
const [targetCount, setTargetCount] = useState(loadCartonCount)
const [labels, setLabels] = useState<CartonLabelScan[]>([])
const [tracks, setTracks] = useState<LabelTrack[]>([])
const [acceptedAnchors, setAcceptedAnchors] = useState<RememberedAnchor[]>([])
const [homography, setHomography] = useState<Homography>([...IDENTITY_HOMOGRAPHY])
const [anchorLandmarks, setAnchorLandmarks] = useState(0)
const [anchorMode, setAnchorMode] = useState<'idle' | 'translation' | 'homography'>('idle')
const [videoSpace, setVideoSpace] = useState<CoverCoordinateSpace>({ left: 0, top: 0, width: 0, height: 0 })
const [section, setSection] = useState<CartonLabelSection>({ face: 1, half: 'top' })
const [sectionStartCount, setSectionStartCount] = useState(0)
const [cameraReady, setCameraReady] = useState(false)
const [cameraError, setCameraError] = useState('')
const [retryKey, setRetryKey] = useState(0)
const [scanPaused, setScanPaused] = useState(false)
const [queueCount, setQueueCount] = useState(0)
const [activeOcrCount, setActiveOcrCount] = useState(0)
const [, setProcessingTrackId] = useState('')
const [progress, setProgress] = useState(0)
const [progressLabel, setProgressLabel] = useState('Preparing OCR')
const [error, setError] = useState('')
const [lastRead, setLastRead] = useState<CartonLabelScan | null>(null)
const [arEnabled, setArEnabled] = useState(true)
const summary = useMemo(() => summarizeCartonLabels(labels), [labels])
const currentStepIndex = STEP_INDEX[step]
const lastFields = visibleFields(lastRead?.fields)
const sectionRead = labels.length - sectionStartCount
const sectionGuide = Math.max(1, Math.round(targetCount / 4))
const visibleTracks = tracks.filter((track) => track.missedFrames === 0)
const retryTracks = visibleTracks.filter((track) => track.status === 'retry').length
const ignoredTracks = visibleTracks.filter((track) => track.status === 'ignored').length
const visibleMemoryIds = new Set(visibleTracks.map((track) => track.memoryId).filter((value): value is string => Boolean(value)))
const rememberedBoxes = acceptedAnchors
.filter((anchor) => !visibleMemoryIds.has(anchor.id))
.map((anchor) => ({ ...anchor, box: projectBox(homography, anchor.anchorBox) }))
.filter((anchor) => boxIntersectsVideo(anchor.box))
const syncTracks = (next: LabelTrack[]) => {
tracksRef.current = next
setTracks(next)
}
const syncAcceptedAnchors = () => {
setAcceptedAnchors([...acceptedAnchorsRef.current.values()])
}
const updateTrack = (trackId: string, update: (track: LabelTrack) => LabelTrack) => {
const next = tracksRef.current.map((track) => track.id === trackId ? update(track) : track)
syncTracks(next)
}
const updateOcrActivity = () => {
setActiveOcrCount(activeOcrWorkersRef.current)
setProcessingTrackId(activeTrackIdsRef.current.values().next().value ?? '')
}
const findRememberedAnchor = (anchorBox: LabelBox) => {
return [...acceptedAnchorsRef.current.values()]
.filter((anchor) => samePhysicalAnchor(anchor.anchorBox, anchorBox))
.sort((left, right) => {
const leftDistance = boxCenterDistance(left.anchorBox, anchorBox)
const rightDistance = boxCenterDistance(right.anchorBox, anchorBox)
if (Math.abs(leftDistance - rightDistance) > 0.002) return leftDistance - rightDistance
return boxSizeDifference(left.anchorBox, anchorBox) - boxSizeDifference(right.anchorBox, anchorBox)
})[0]
}
const finishPallet = (nextLabels: CartonLabelScan[]) => {
if (finishedRef.current) return
finishedRef.current = true
queueRef.current = []
setQueueCount(0)
const nextSummary = summarizeCartonLabels(nextLabels)
if (sessionIdRef.current) {
void finishPalletLabelSession(sessionIdRef.current, {
conforms: nextSummary.conforms,
resultStatus: nextSummary.status,
mismatchCartons: nextSummary.mismatchCartons,
majorityFields: nextSummary.majorityFields,
}).catch(() => undefined)
}
setStep('result')
}
const processOcrItem = async (item: OcrQueueItem) => {
activeTrackIdsRef.current.add(item.trackId)
updateOcrActivity()
try {
const track = tracksRef.current.find((candidate) => candidate.id === item.trackId)
if (!track || track.status === 'accepted' || track.status === 'ignored') return
updateTrack(item.trackId, (candidate) => ({ ...candidate, status: 'reading' }))
setProcessingTrackId(item.trackId)
setProgress(0)
setProgressLabel('Reading detected label')
const ocr = await recognizeLabel(item.image, (nextProgress, status) => {
setProcessingTrackId(item.trackId)
setProgress(nextProgress)
setProgressLabel(status)
})
if (
finishedRef.current
|| item.sessionId !== sessionIdRef.current
|| sectionKey(item.section) !== sectionKey(sectionRef.current)
) return
const scan = createCartonLabelScan(ocr, item.section, item.trackId)
if (!hasCartonLabelIdentity(ocr)) {
updateTrack(item.trackId, (candidate) => ({
...candidate,
status: 'ignored',
stableFrames: 0,
retryAfter: Number.POSITIVE_INFINITY,
recognizedFields: 0,
}))
setProgress(100)
setProgressLabel('Ignored · no valid PPIN or grower number')
return
}
setLastRead(scan)
if (scan.recognizedFields < MIN_RECOGNIZED_LABEL_FIELDS) {
updateTrack(item.trackId, (candidate) => ({
...candidate,
status: 'retry',
stableFrames: 0,
retryAfter: Date.now() + OCR_RETRY_DELAY_MS,
recognizedFields: scan.recognizedFields,
}))
return
}
if (labelsRef.current.length >= targetCountRef.current) return
const currentTrack = tracksRef.current.find((candidate) => candidate.id === item.trackId)
const anchorBox = currentTrack?.anchorBox ?? item.anchorBox
const rememberedDuplicate = findRememberedAnchor(anchorBox)
if (rememberedDuplicate && rememberedDuplicate.id !== item.trackId) {
updateTrack(item.trackId, (candidate) => ({
...candidate,
status: 'accepted',
memoryId: rememberedDuplicate.id,
recognizedFields: scan.recognizedFields,
}))
setProgress(100)
setProgressLabel('Already counted · AR position memory')
return
}
updateTrack(item.trackId, (candidate) => ({
...candidate,
status: 'accepted',
memoryId: item.trackId,
recognizedFields: scan.recognizedFields,
}))
const nextLabels = [...labelsRef.current, scan]
acceptedAnchorsRef.current.set(item.trackId, {
id: item.trackId,
anchorBox,
labelNumber: nextLabels.length,
})
syncAcceptedAnchors()
labelsRef.current = nextLabels
setLabels(nextLabels)
setProgress(100)
setProgressLabel(`${scan.recognizedFields} validated fields accepted`)
navigator.vibrate?.(45)
if (sessionIdRef.current) {
void savePalletLabelScans(sessionIdRef.current, nextLabels).catch(() => {
setError('Local persistence failed. This pallet remains available only in the current screen.')
})
}
if (nextLabels.length >= targetCountRef.current) finishPallet(nextLabels)
} catch (readError) {
if (item.sessionId === sessionIdRef.current && sectionKey(item.section) === sectionKey(sectionRef.current)) {
updateTrack(item.trackId, (candidate) => ({
...candidate,
status: 'retry',
stableFrames: 0,
retryAfter: Date.now() + OCR_RETRY_DELAY_MS,
}))
setError(readError instanceof Error ? readError.message : 'A detected carton label could not be read.')
}
} finally {
activeTrackIdsRef.current.delete(item.trackId)
activeOcrWorkersRef.current = Math.max(0, activeOcrWorkersRef.current - 1)
updateOcrActivity()
setQueueCount(queueRef.current.length)
pumpOcrQueue()
}
}
const pumpOcrQueue = () => {
if (finishedRef.current) return
while (activeOcrWorkersRef.current < OCR_CONCURRENCY && queueRef.current.length > 0) {
const item = queueRef.current.shift()
if (!item) break
activeOcrWorkersRef.current += 1
setQueueCount(queueRef.current.length)
void processOcrItem(item)
}
updateOcrActivity()
}
const captureAndQueue = async (track: LabelTrack) => {
const video = videoRef.current
if (!video || track.status !== 'tracking' && track.status !== 'retry') return
if (queueRef.current.length + activeOcrWorkersRef.current >= MAX_OCR_QUEUE) return
const currentSection = sectionRef.current
const expectedSectionKey = sectionKey(currentSection)
const expectedSessionId = sessionIdRef.current
updateTrack(track.id, (candidate) => ({ ...candidate, status: 'queued' }))
try {
const image = await cropDetectedLabel(video, track.box)
if (
sectionKey(sectionRef.current) !== expectedSectionKey
|| sessionIdRef.current !== expectedSessionId
|| finishedRef.current
) return
queueRef.current.push({
trackId: track.id,
anchorBox: track.anchorBox,
section: currentSection,
sessionId: expectedSessionId,
image,
})
setQueueCount(queueRef.current.length)
pumpOcrQueue()
} catch {
updateTrack(track.id, (candidate) => ({
...candidate,
status: 'retry',
stableFrames: 0,
retryAfter: Date.now() + OCR_RETRY_DELAY_MS,
}))
}
}
const reconcileDetections = (detections: LabelDetection[]) => {
const previous = tracksRef.current
const association = associateLabelDetections(previous, detections, homographyRef.current)
const matchedTracks: LabelTrack[] = []
const correspondences: { anchor: LabelBox; current: LabelBox }[] = []
association.matches.forEach((match) => {
const previousTrack = previous[match.trackIndex]
const detection = detections[match.detectionIndex]
const motion = boxCenterDistance(match.expectedBox, detection.box)
const stableMotion = Math.max(
0.018,
Math.min(0.04, Math.hypot(match.expectedBox.width, match.expectedBox.height) * 0.35),
)
matchedTracks.push({
...previousTrack,
box: blendBox(previousTrack.box, detection.box),
stableFrames: motion <= stableMotion ? previousTrack.stableFrames + 1 : 1,
missedFrames: 0,
})
const reliableForPlane =
previousTrack.status !== 'ignored'
&& previousTrack.stableFrames >= MIN_STABLE_FRAMES
&& (match.iou >= 0.2 || match.normalizedDistance <= 0.55)
if (reliableForPlane) {
correspondences.push({ anchor: previousTrack.anchorBox, current: detection.box })
}
})
const estimate = estimateSectionHomography(correspondences, homographyRef.current)
homographyRef.current = estimate.matrix
setHomography(estimate.matrix)
setAnchorLandmarks(estimate.landmarks)
setAnchorMode(estimate.mode)
const inverse = invertHomography(estimate.matrix)
const nextTracks = [...matchedTracks]
association.unmatchedDetectionIndices.forEach((detectionIndex) => {
const detection = detections[detectionIndex]
trackCounterRef.current += 1
const anchorBox = projectBox(inverse, detection.box)
const remembered = findRememberedAnchor(anchorBox)
nextTracks.push({
id: `label-${trackCounterRef.current}`,
box: detection.box,
anchorBox,
stableFrames: 1,
missedFrames: 0,
status: remembered ? 'accepted' : 'tracking',
retryAfter: 0,
memoryId: remembered?.id,
})
})
association.unmatchedTrackIndices.forEach((index) => {
const track = previous[index]
const missedFrames = track.missedFrames + 1
if (missedFrames <= MAX_MISSED_FRAMES) nextTracks.push({ ...track, missedFrames })
})
syncTracks(nextTracks)
const availableQueueSlots = Math.max(0, MAX_OCR_QUEUE - queueRef.current.length - activeOcrWorkersRef.current)
nextTracks
.filter((track) =>
track.missedFrames === 0
&& track.stableFrames >= MIN_STABLE_FRAMES
&& (track.status === 'tracking' || track.status === 'retry')
&& Date.now() >= track.retryAfter,
)
.sort((left, right) => right.box.width * right.box.height - left.box.width * left.box.height)
.slice(0, Math.min(4, availableQueueSlots))
.forEach((track) => void captureAndQueue(track))
}
useEffect(() => {
if (step !== 'scan') return
void warmLabelOcr().catch(() => undefined)
}, [step])
useEffect(() => {
if (step !== 'scan') return
let disposed = false
const stopCamera = () => {
streamRef.current?.getTracks().forEach((track) => track.stop())
streamRef.current = null
if (videoRef.current) videoRef.current.srcObject = null
}
const startCamera = async () => {
stopCamera()
setCameraReady(false)
setCameraError('')
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
})
if (disposed) {
stream.getTracks().forEach((track) => track.stop())
return
}
streamRef.current = stream
const video = videoRef.current
if (!video) return
video.srcObject = stream
await video.play()
if (!disposed) setCameraReady(true)
}
void startCamera().catch((cameraStartError: unknown) => {
if (!disposed) {
setCameraError(cameraStartError instanceof Error ? cameraStartError.message : 'Camera access failed.')
setCameraReady(false)
}
})
return () => {
disposed = true
stopCamera()
}
}, [retryKey, step])
useEffect(() => {
if (step !== 'scan' || !cameraReady) return
const video = videoRef.current
const stage = stageRef.current
if (!video || !stage) return
const updateSpace = () => {
setVideoSpace(getCoverCoordinateSpace(
video.videoWidth,
video.videoHeight,
stage.clientWidth,
stage.clientHeight,
))
}
updateSpace()
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateSpace)
observer?.observe(stage)
video.addEventListener('loadedmetadata', updateSpace)
window.addEventListener('resize', updateSpace)
window.addEventListener('orientationchange', updateSpace)
return () => {
observer?.disconnect()
video.removeEventListener('loadedmetadata', updateSpace)
window.removeEventListener('resize', updateSpace)
window.removeEventListener('orientationchange', updateSpace)
}
}, [cameraReady, step])
useEffect(() => {
if (step !== 'scan' || !cameraReady || scanPaused || finishedRef.current) return
const timer = window.setInterval(() => {
const video = videoRef.current
if (!video) return
reconcileDetections(detectWhiteLabelBoxes(video))
}, DETECTION_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [cameraReady, scanPaused, step])
const changeCartonCount = (count: number) => {
const nextCount = Math.max(0, Math.min(500, Math.round(count)))
setTargetCount(nextCount)
targetCountRef.current = nextCount
if (nextCount > 0) saveCartonCount(nextCount)
}
const clearSectionTracking = () => {
queueRef.current = []
setQueueCount(0)
syncTracks([])
trackCounterRef.current = 0
acceptedAnchorsRef.current.clear()
syncAcceptedAnchors()
const identity = [...IDENTITY_HOMOGRAPHY] as Homography
homographyRef.current = identity
setHomography(identity)
setAnchorLandmarks(0)
setAnchorMode('idle')
setProgress(0)
setProgressLabel('Preparing OCR')
setError('')
}
const startSession = async () => {
if (!Number.isFinite(targetCount) || targetCount < 1 || targetCount > 500) {
setError('Enter a carton count between 1 and 500.')
return
}
const safeCount = Math.round(targetCount)
setError('')
setLastRead(null)
setTargetCount(safeCount)
targetCountRef.current = safeCount
saveCartonCount(safeCount)
finishedRef.current = false
labelsRef.current = []
setLabels([])
sectionRef.current = { face: 1, half: 'top' }
setSection(sectionRef.current)
sectionStartCountRef.current = 0
setSectionStartCount(0)
setScanPaused(false)
clearSectionTracking()
void warmLabelOcr().catch(() => undefined)
try {
const session = await startPalletLabelSession(safeCount)
sessionIdRef.current = session.id
} catch {
setError('Could not create the local pallet record. Check browser storage and try again.')
return
}
setStep('scan')
setRetryKey((value) => value + 1)
}
const reset = () => {
finishedRef.current = false
labelsRef.current = []
sessionIdRef.current = ''
queueRef.current = []
setQueueCount(0)
setLabels([])
setLastRead(null)
setCameraReady(false)
setCameraError('')
setScanPaused(false)
setProcessingTrackId('')
sectionRef.current = { face: 1, half: 'top' }
setSection(sectionRef.current)
sectionStartCountRef.current = 0
setSectionStartCount(0)
clearSectionTracking()
setStep('setup')
}
const undoLastScan = () => {
if (labelsRef.current.length === 0 || activeOcrCount > 0 || queueCount > 0) return
const removed = labelsRef.current.at(-1)
const next = labelsRef.current.slice(0, -1)
labelsRef.current = next
setLabels(next)
setLastRead(next.at(-1) ?? null)
if (removed?.trackId) {
acceptedAnchorsRef.current.delete(removed.trackId)
syncAcceptedAnchors()
updateTrack(removed.trackId, (track) => ({
...track,
status: 'tracking',
memoryId: undefined,
recognizedFields: undefined,
stableFrames: 0,
retryAfter: Date.now() + 500,
}))
}
if (sessionIdRef.current) void savePalletLabelScans(sessionIdRef.current, next).catch(() => undefined)
setError('Last accepted carton removed.')
}
const nextSection = () => {
if (activeOcrCount > 0 || queueCount > 0) return
const nextSectionValue: CartonLabelSection = section.half === 'top'
? { face: section.face, half: 'bottom' }
: { face: section.face + 1, half: 'top' }
sectionRef.current = nextSectionValue
setSection(nextSectionValue)
sectionStartCountRef.current = labelsRef.current.length
setSectionStartCount(labelsRef.current.length)
clearSectionTracking()
setScanPaused(false)
}
const mismatchIssues = summary.issues.filter((issue) => issue.status === 'mismatch')
const unreadIssues = summary.issues.filter((issue) => issue.status === 'unread')
const anchorStatus = anchorMode === 'homography'
? `AR plane locked · ${anchorLandmarks} anchors`
: anchorMode === 'translation'
? `AR following · ${anchorLandmarks} anchor${anchorLandmarks === 1 ? '' : 's'}`
: acceptedAnchors.length > 0
? 'AR position memory coasting'
: 'AR plane acquiring'
return (
<section className="workspace-view label-check-view carton-label-view" aria-labelledby="label-check-title">
<header className="view-heading label-check-heading">
<div>
<span className="eyebrow">PALLET LABEL VERIFICATION</span>
<h1 id="label-check-title">Pallet label check</h1>
<p>Batch OCR · sweep each pallet section and compare printed key/value fields</p>
</div>
<div className="view-actions">
<button className="quiet-button" type="button" onClick={onOpenSettings}><Settings size={16} /> Integrations</button>
{step !== 'setup' && <button className="quiet-button" type="button" onClick={reset}><RefreshCw size={16} /> New pallet</button>}
</div>
</header>
<ol className="label-stepper label-stepper-three" aria-label="Pallet label check progress">
{['Setup', 'Sweep sections', 'Conformity'].map((label, index) => (
<li className={currentStepIndex === index ? 'active' : currentStepIndex > index ? 'done' : ''} key={label}><span>{index + 1}</span> {label}</li>
))}
</ol>
{step === 'setup' && (
<div className="ean-setup-card">
<ScanLine size={38} />
<h2>Start pallet check</h2>
<p>Scan the top half, then the bottom half, move around the pallet and repeat. White labels are detected and OCR'd in batches while you sweep.</p>
<CartonConfigPicker value={targetCount} onChange={changeCartonCount} onStart={() => void startSession()} />
<label className="ean-ar-toggle">
<input type="checkbox" checked={arEnabled} onChange={(event) => setArEnabled(event.target.checked)} />
<span><strong>AR assist</strong><small>Tracks accepted carton positions across each pallet-half sweep. OCR and storage stay local-first.</small></span>
</label>
{error && <div className="label-error"><AlertTriangle /> <span>{error}</span></div>}
</div>
)}
{step === 'scan' && (
<div className="label-scan-panel carton-label-scan-panel">
<div className="carton-section-head">
<div>
<span>Current sweep</span>
<strong>{sectionLabel(section)}</strong>
<small>Sweep slowly with some overlap between views so AR can keep the pallet face anchored.</small>
</div>
<button className="quiet-button" type="button" onClick={() => setScanPaused((value) => !value)}>
{scanPaused ? <Play size={15} /> : <Pause size={15} />} {scanPaused ? 'Resume' : 'Pause'}
</button>
</div>
<div className="ean-session-strip carton-batch-stats">
<div><span>Pallet</span><strong>{labels.length} / {targetCount}</strong></div>
<div><span>This section</span><strong>{sectionRead} / ~{sectionGuide}</strong></div>
<div><span>Visible labels</span><strong>{visibleTracks.length}</strong></div>
<div><span>OCR queue</span><strong>{queueCount + activeOcrCount}</strong></div>
</div>
<div className="ean-progress-track"><span style={{ width: `${Math.min(100, (labels.length / targetCount) * 100)}%` }} /></div>
<div ref={stageRef} className={`label-camera-stage carton-label-camera carton-batch-camera ${!cameraReady ? 'scanner-starting' : ''}`}>
<video ref={videoRef} autoPlay muted playsInline aria-label="Pallet carton label batch OCR camera" />
<div className="label-camera-shade" />
{arEnabled && videoSpace.width > 0 && (
<div
className="carton-video-coordinate-space"
style={{
left: `${videoSpace.left}px`,
top: `${videoSpace.top}px`,
width: `${videoSpace.width}px`,
height: `${videoSpace.height}px`,
}}
>
{rememberedBoxes.map((anchor) => (
<div
className="carton-batch-box accepted remembered"
key={`remembered-${anchor.id}`}
style={{
left: `${anchor.box.x * 100}%`,
top: `${anchor.box.y * 100}%`,
width: `${anchor.box.width * 100}%`,
height: `${anchor.box.height * 100}%`,
}}
>
<span>✓</span>
</div>
))}
{visibleTracks.map((track) => (
<div
className={`carton-batch-box ${track.status}`}
key={track.id}
style={{
left: `${track.box.x * 100}%`,
top: `${track.box.y * 100}%`,
width: `${track.box.width * 100}%`,
height: `${track.box.height * 100}%`,
}}
>
<span>{track.status === 'accepted' ? '✓' : track.status === 'ignored' ? '–' : track.status === 'retry' ? '?' : track.status === 'reading' ? 'OCR' : track.status === 'queued' ? '…' : ''}</span>
</div>
))}
</div>
)}
<div className="carton-batch-instruction">
<span>{sectionLabel(section)}</span>
<strong>{scanPaused ? 'Sweep paused' : 'Sweep across the white labels'}</strong>
<small>Green = accepted, amber = another pass, grey = not a PPIN/grower label.</small>
{arEnabled && <em>{anchorStatus}</em>}
</div>
{activeOcrCount > 0 && (
<div className="carton-batch-ocr-status" aria-live="polite">
<strong>{activeOcrCount} OCR worker{activeOcrCount === 1 ? '' : 's'} · {progress}%</strong>
<span>{progressLabel}</span>
<div className="label-progress"><span style={{ width: `${progress}%` }} /></div>
</div>
)}
{arEnabled && lastFields.length > 0 && (
<div className="carton-live-fields carton-batch-last-fields" aria-live="polite">
<span>Last accepted · {lastRead?.recognizedFields} fields</span>
{lastFields.slice(0, 4).map(({ field, label, value }) => <div key={field}><b>{label}</b><strong>{value}</strong></div>)}
</div>
)}
</div>
<div className="carton-section-actions">
<div>
<span>{retryTracks > 0 ? `${retryTracks} visible label${retryTracks === 1 ? '' : 's'} need another pass` : sectionRead >= sectionGuide ? 'Section guide reached' : `Aim for about ${sectionGuide} cartons in this section`}</span>
<small>Section counts are guidance only; the pallet stops automatically at {targetCount} accepted cartons.{ignoredTracks > 0 ? ` ${ignoredTracks} visible non-target shape${ignoredTracks === 1 ? '' : 's'} ignored.` : ''}</small>
</div>
<button className="primary-button" type="button" disabled={activeOcrCount > 0 || queueCount > 0 || labels.length >= targetCount} onClick={nextSection}>
{section.half === 'top' ? <ArrowDown size={17} /> : <RefreshCw size={17} />}
{section.half === 'top' ? 'Finish top · scan bottom' : `Finish face ${section.face} · move round`}
</button>
<button className="quiet-button" type="button" disabled={labels.length === 0 || activeOcrCount > 0 || queueCount > 0} onClick={undoLastScan}><RotateCcw size={15} /> Undo last</button>
</div>
{error && <div className="label-error"><AlertTriangle /> <span>{error}</span></div>}
{cameraError && <div className="label-error"><AlertTriangle /> <span>{cameraError}</span><button type="button" onClick={() => setRetryKey((value) => value + 1)}>Retry camera</button></div>}
</div>
)}
{step === 'result' && (
<div className={`ean-conformity-card carton-conformity-card ${summary.status}`}>
<div className="label-result-heading">
<span className="label-result-icon">{summary.status === 'pass' ? <CheckCircle2 /> : summary.status === 'fail' ? <XCircle /> : <AlertTriangle />}</span>
<div>
<span className="eyebrow">{summary.status === 'pass' ? 'LABEL CONFORMITY PASS' : summary.status === 'fail' ? 'LABEL MISMATCH FOUND' : 'LABEL REVIEW REQUIRED'}</span>
<h2>{summary.total} / {targetCount} cartons read</h2>
<p>{summary.status === 'pass'
? 'The recognised key/value fields agree across the pallet.'
: summary.status === 'fail'
? `${summary.mismatchCartons} carton${summary.mismatchCartons === 1 ? '' : 's'} differ from the completed-pallet majority.`
: `Only ${summary.majorityFields.length} fields had enough OCR evidence to establish a reliable pallet pattern.`}</p>
</div>
</div>
<div className="ean-final-summary carton-final-summary">
<span>Cartons <strong>{summary.total}</strong></span>
<span>Compared fields <strong>{summary.majorityFields.length}</strong></span>
<span>Mismatch cartons <strong>{summary.mismatchCartons}</strong></span>
<span>Avg OCR <strong>{Math.round(summary.averageConfidence)}%</strong></span>
</div>
<div className="carton-majority-fields">
<div className="carton-majority-head"><span>Field</span><span>Pallet majority</span><span>Read support</span></div>
{summary.majorityFields.map((field) => (
<div key={field.field}>
<strong>{field.label}</strong>
<span>{field.value}</span>
<b>{field.support} / {summary.total}</b>
</div>
))}
</div>
{summary.issues.length > 0 && (
<div className="carton-issues">
<header><strong>Exceptions</strong><span>{mismatchIssues.length} mismatch · {unreadIssues.length} unread</span></header>
{summary.issues.slice(0, 24).map((issue) => (
<div className={issue.status} key={`${issue.scanId}-${issue.field}`}>
<span>Carton {issue.cartonNumber}</span>
<strong>{issue.label}</strong>
{issue.section && <em>Face {issue.section.face} · {issue.section.half}</em>}
<small>Expected majority</small><b>{issue.expected}</b>
<small>Read</small><b>{issue.actual || 'Unread'}</b>
</div>
))}
{summary.issues.length > 24 && <p>+ {summary.issues.length - 24} more exceptions</p>}
</div>
)}
{labels.at(-1)?.rawText && (
<details className="label-ocr-details">
<summary>Last carton raw OCR</summary>
<pre>{labels.at(-1)?.rawText}</pre>
</details>
)}
<div className="label-result-actions">
<button className="primary-button" type="button" onClick={reset}><ScanLine size={16} /> New pallet</button>
</div>
</div>
)}
</section>
)
}

View File

@ -0,0 +1,377 @@
import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser'
import { BarcodeFormat, DecodeHintType } from '@zxing/library'
import { Camera, CheckCircle2, RefreshCw, Settings2, ShieldAlert, SwitchCamera, Volume2, VolumeX, Zap, ZapOff } from 'lucide-react'
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { recordScan, type ScanRecord } from '../../lib/database'
import { detectionSpanPercent, formatName, formatsForMode, scanFormatOptions, validateScanPayload, type PayloadRules, type ScanFormatMode } from '../../lib/barcodes'
import { loadScannerPreferences, saveScannerPreferences } from '../../lib/preferences'
import { disableScanAudio, enableScanAudio, getScanAudio } from '../../lib/scanAudio'
type ScannerStatus = 'starting' | 'detecting' | 'captured' | 'duplicate' | 'permission-denied' | 'camera-error' | 'storage-error'
type DetectionPoint = { left: number; top: number }
type ConfirmationCandidate = { value: string; format: BarcodeFormat; count: number; lastSeen: number }
type PendingCapture = { rawValue: string; format: string }
export function ScannerView({ installAction }: { installAction?: ReactNode }) {
const [initialPreferences] = useState(loadScannerPreferences)
const videoRef = useRef<HTMLVideoElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const controlsRef = useRef<IScannerControls | null>(null)
const armedRef = useRef(true)
const autoAdvanceRef = useRef(initialPreferences.autoAdvance)
const soundEnabledRef = useRef(initialPreferences.soundEnabled)
const confirmationFramesRef = useRef(initialPreferences.confirmationFrames)
const minimumSizeRef = useRef(initialPreferences.minimumSize)
const payloadRulesRef = useRef<PayloadRules>(initialPreferences.payloadRules)
const qualitySettingsOpenRef = useRef(false)
const candidateRef = useRef<ConfirmationCandidate | null>(null)
const rearmTimerRef = useRef<number | null>(null)
const lastReadRef = useRef({ value: '', time: 0 })
const [armed, setArmed] = useState(true)
const [status, setStatus] = useState<ScannerStatus>('starting')
const [cameraError, setCameraError] = useState('')
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([])
const [selectedCamera, setSelectedCamera] = useState('')
const [requestedCamera, setRequestedCamera] = useState('')
const [torchAvailable, setTorchAvailable] = useState(false)
const [torchOn, setTorchOn] = useState(false)
const [autoAdvance, setAutoAdvance] = useState(initialPreferences.autoAdvance)
const [soundEnabled, setSoundEnabled] = useState(initialPreferences.soundEnabled)
const [formatMode, setFormatMode] = useState<ScanFormatMode>(initialPreferences.formatMode)
const [confirmationFrames, setConfirmationFrames] = useState(initialPreferences.confirmationFrames)
const [minimumSize, setMinimumSize] = useState(initialPreferences.minimumSize)
const [payloadRules, setPayloadRules] = useState<PayloadRules>(initialPreferences.payloadRules)
const [showQualitySettings, setShowQualitySettings] = useState(false)
const [qualityMessage, setQualityMessage] = useState('')
const [confirmationProgress, setConfirmationProgress] = useState(0)
const [retryKey, setRetryKey] = useState(0)
const [detectionPoints, setDetectionPoints] = useState<DetectionPoint[]>([])
const [latest, setLatest] = useState<ScanRecord | null>(null)
const [pendingCapture, setPendingCapture] = useState<PendingCapture | null>(null)
const setScannerArmed = useCallback((value: boolean) => {
if (value && rearmTimerRef.current) {
window.clearTimeout(rearmTimerRef.current)
rearmTimerRef.current = null
}
armedRef.current = value
setArmed(value)
if (value) {
setStatus('detecting')
setDetectionPoints([])
setConfirmationProgress(0)
setQualityMessage('')
candidateRef.current = null
}
}, [])
const saveCapture = useCallback(async (capture: PendingCapture) => {
try {
const record = await recordScan(capture.rawValue, capture.format)
setPendingCapture(null)
setLatest(record)
setStatus(record.isDuplicate ? 'duplicate' : 'captured')
navigator.vibrate?.(record.isDuplicate ? [80, 70, 80] : 80)
const audioContext = getScanAudio()
if (soundEnabledRef.current && audioContext) {
const oscillator = audioContext.createOscillator()
const gain = audioContext.createGain()
oscillator.frequency.value = record.isDuplicate ? 220 : 660
gain.gain.setValueAtTime(0.08, audioContext.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.16)
oscillator.connect(gain).connect(audioContext.destination)
oscillator.start()
oscillator.stop(audioContext.currentTime + 0.16)
}
if (autoAdvanceRef.current) {
rearmTimerRef.current = window.setTimeout(() => setScannerArmed(true), 1600)
}
} catch {
setStatus('storage-error')
}
}, [setScannerArmed])
const discardPendingCapture = () => {
setPendingCapture(null)
setScannerArmed(true)
}
useEffect(() => {
const video = videoRef.current
if (!video) return
const hints = new Map()
hints.set(DecodeHintType.POSSIBLE_FORMATS, formatsForMode(formatMode))
const reader = new BrowserMultiFormatReader(hints, {
delayBetweenScanAttempts: 120,
delayBetweenScanSuccess: 180,
})
let disposed = false
setStatus('starting')
setCameraError('')
setTorchAvailable(false)
void reader.decodeFromConstraints(
{
audio: false,
video: requestedCamera
? { deviceId: { exact: requestedCamera } }
: { facingMode: { ideal: 'environment' } },
},
video,
(result) => {
if (!armedRef.current || qualitySettingsOpenRef.current) return
if (!result) {
if (candidateRef.current) {
candidateRef.current = null
setConfirmationProgress(0)
setQualityMessage('')
}
return
}
const rawValue = result.getText()
const barcodeFormat = result.getBarcodeFormat()
const now = Date.now()
if (lastReadRef.current.value === rawValue && now - lastReadRef.current.time < 1200) return
const stage = stageRef.current
const points = result.getResultPoints()
const detectedSize = detectionSpanPercent(points, video.videoWidth, video.videoHeight)
if (detectedSize !== null && detectedSize < minimumSizeRef.current) {
candidateRef.current = null
setConfirmationProgress(0)
setQualityMessage(`Move closer · code is ${Math.round(detectedSize)}% of frame`)
return
}
const validationError = validateScanPayload(rawValue, barcodeFormat, payloadRulesRef.current)
if (validationError) {
candidateRef.current = null
setConfirmationProgress(0)
setQualityMessage(validationError)
return
}
const candidate = candidateRef.current
const isSameCandidate = candidate?.value === rawValue && candidate.format === barcodeFormat && now - candidate.lastSeen < 1000
const nextCount = isSameCandidate ? candidate.count + 1 : 1
candidateRef.current = { value: rawValue, format: barcodeFormat, count: nextCount, lastSeen: now }
setConfirmationProgress(nextCount)
setQualityMessage(nextCount < confirmationFramesRef.current ? `Hold steady · confirming ${nextCount}/${confirmationFramesRef.current}` : '')
if (nextCount < confirmationFramesRef.current) return
candidateRef.current = null
lastReadRef.current = { value: rawValue, time: now }
setScannerArmed(false)
if (stage && video.videoWidth && video.videoHeight && points.length) {
const scale = Math.max(stage.clientWidth / video.videoWidth, stage.clientHeight / video.videoHeight)
const offsetX = (stage.clientWidth - video.videoWidth * scale) / 2
const offsetY = (stage.clientHeight - video.videoHeight * scale) / 2
setDetectionPoints(points.map((point) => ({
left: Math.min(100, Math.max(0, ((offsetX + point.getX() * scale) / stage.clientWidth) * 100)),
top: Math.min(100, Math.max(0, ((offsetY + point.getY() * scale) / stage.clientHeight) * 100)),
})))
}
const format = formatName(BarcodeFormat[barcodeFormat] ?? 'Barcode')
const capture = { rawValue, format }
setPendingCapture(capture)
void saveCapture(capture)
},
).then((controls) => {
if (disposed) controls.stop()
else {
controlsRef.current = controls
setTorchAvailable(Boolean(controls.switchTorch))
setStatus('detecting')
void BrowserMultiFormatReader.listVideoInputDevices().then((devices) => {
if (disposed) return
setCameras(devices)
const activeDevice = controls.streamVideoSettingsGet?.((track) => [track]).deviceId
if (activeDevice) setSelectedCamera(activeDevice)
})
}
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : 'Camera access failed.'
setCameraError(message)
setStatus(error instanceof DOMException && error.name === 'NotAllowedError' ? 'permission-denied' : 'camera-error')
})
return () => {
disposed = true
controlsRef.current?.stop()
controlsRef.current = null
setTorchOn(false)
}
}, [formatMode, requestedCamera, retryKey, saveCapture, setScannerArmed])
useEffect(() => () => {
if (rearmTimerRef.current) window.clearTimeout(rearmTimerRef.current)
}, [])
const toggleTorch = async () => {
const nextValue = !torchOn
try {
await controlsRef.current?.switchTorch?.(nextValue)
setTorchOn(nextValue)
} catch {
setTorchAvailable(false)
setTorchOn(false)
}
}
const toggleAutoAdvance = () => {
const nextValue = !autoAdvance
autoAdvanceRef.current = nextValue
setAutoAdvance(nextValue)
saveScannerPreferences({ ...loadScannerPreferences(), autoAdvance: nextValue })
if (!nextValue && rearmTimerRef.current) {
window.clearTimeout(rearmTimerRef.current)
rearmTimerRef.current = null
}
}
const toggleSound = () => {
const nextValue = !soundEnabled
if (nextValue) enableScanAudio()
else disableScanAudio()
soundEnabledRef.current = nextValue
setSoundEnabled(nextValue)
saveScannerPreferences({ ...loadScannerPreferences(), soundEnabled: nextValue })
}
const updateConfirmationFrames = (value: 2 | 3) => {
confirmationFramesRef.current = value
setConfirmationFrames(value)
saveScannerPreferences({ ...loadScannerPreferences(), confirmationFrames: value })
candidateRef.current = null
setConfirmationProgress(0)
}
const updateMinimumSize = (value: number) => {
minimumSizeRef.current = value
setMinimumSize(value)
saveScannerPreferences({ ...loadScannerPreferences(), minimumSize: value })
}
const updatePayloadRules = (rules: PayloadRules) => {
payloadRulesRef.current = rules
setPayloadRules(rules)
saveScannerPreferences({ ...loadScannerPreferences(), payloadRules: rules })
candidateRef.current = null
setConfirmationProgress(0)
}
const toggleQualitySettings = () => {
const nextValue = !showQualitySettings
qualitySettingsOpenRef.current = nextValue
setShowQualitySettings(nextValue)
candidateRef.current = null
setConfirmationProgress(0)
setQualityMessage('')
}
const retryCamera = () => {
setScannerArmed(true)
setRetryKey((value) => value + 1)
}
const statusLabel = {
starting: 'STARTING CAMERA',
detecting: 'AUTO DETECTING',
captured: 'CAPTURED',
duplicate: 'DUPLICATE',
'permission-denied': 'CAMERA BLOCKED',
'camera-error': 'CAMERA ERROR',
'storage-error': 'SAVE FAILED',
}[status]
return (
<section className="scanner-view" aria-labelledby="scanner-title">
<div className={`camera-stage scanner-${status} ${showQualitySettings ? 'quality-settings-open' : ''}`} ref={stageRef}>
<video ref={videoRef} autoPlay muted playsInline aria-label="Rear camera preview" />
<div className="camera-shade" />
<div className="camera-heading">
<div>
<span className="eyebrow">FIELDLENS / LIVE</span>
<h1 id="scanner-title">Scan station</h1>
</div>
<div className="camera-actions">
<span className="local-badge"><span /> {statusLabel}</span>
{installAction}
</div>
</div>
<div className="camera-tools" aria-label="Scanner controls">
{cameras.length > 1 && (
<label className="camera-select">
<SwitchCamera size={15} />
<span className="sr-only">Camera</span>
<select value={selectedCamera} onChange={(event) => { setSelectedCamera(event.target.value); setRequestedCamera(event.target.value) }}>
{cameras.map((camera, index) => <option value={camera.deviceId} key={camera.deviceId}>{camera.label || `Camera ${index + 1}`}</option>)}
</select>
</label>
)}
{torchAvailable && (
<button className="camera-tool-button" type="button" aria-label={torchOn ? 'Turn torch off' : 'Turn torch on'} title={torchOn ? 'Turn torch off' : 'Turn torch on'} aria-pressed={torchOn} onClick={() => void toggleTorch()}>
{torchOn ? <ZapOff /> : <Zap />}
</button>
)}
<button className="camera-tool-button" type="button" aria-label={soundEnabled ? 'Turn scan sounds off' : 'Turn scan sounds on'} title={soundEnabled ? 'Turn scan sounds off' : 'Turn scan sounds on'} aria-pressed={soundEnabled} onClick={toggleSound}>
{soundEnabled ? <Volume2 /> : <VolumeX />}
</button>
<button className="auto-toggle" type="button" aria-pressed={autoAdvance} onClick={toggleAutoAdvance}>
<span /> Auto next
</button>
<button className="camera-tool-button" type="button" aria-label="Scan quality settings" title="Scan quality settings" aria-expanded={showQualitySettings} onClick={toggleQualitySettings}>
<Settings2 />
</button>
</div>
{showQualitySettings && (
<div className="quality-panel">
<label><span>Formats</span><select value={formatMode} onChange={(event) => { const value = event.target.value as ScanFormatMode; setFormatMode(value); saveScannerPreferences({ ...loadScannerPreferences(), formatMode: value }) }}>{scanFormatOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>
<label><span>Confirm reads</span><select value={confirmationFrames} onChange={(event) => updateConfirmationFrames(Number(event.target.value) as 2 | 3)}><option value="2">2 frames</option><option value="3">3 frames</option></select></label>
<label className="quality-range"><span>Minimum size <b>{minimumSize}%</b></span><input type="range" min="0" max="20" step="1" value={minimumSize} onChange={(event) => updateMinimumSize(Number(event.target.value))} /></label>
<label className="quality-check"><input type="checkbox" checked={payloadRules.enabled} onChange={(event) => updatePayloadRules({ ...payloadRules, enabled: event.target.checked })} /><span>Validate known barcode standards</span></label>
<label><span>Expected length</span><input type="number" min="0" max="256" inputMode="numeric" placeholder="Any" value={payloadRules.expectedLength || ''} onChange={(event) => updatePayloadRules({ ...payloadRules, expectedLength: Math.max(0, Number(event.target.value)) })} /></label>
<label><span>Required prefix</span><input type="text" placeholder="Any" value={payloadRules.requiredPrefix} onChange={(event) => updatePayloadRules({ ...payloadRules, requiredPrefix: event.target.value })} /></label>
</div>
)}
<div className={`scan-frame ${latest && /QR|Data Matrix|Aztec|MaxiCode/i.test(latest.format) ? 'matrix' : ''}`} aria-hidden="true"><span /><span /><span /><span /></div>
{detectionPoints.map((point, index) => <i className="detection-point" style={{ left: `${point.left}%`, top: `${point.top}%` }} key={index} />)}
<p className="camera-instruction" aria-live="polite">
{cameraError || (showQualitySettings ? 'Scanning paused while settings are open' : qualityMessage || (status === 'starting' ? 'Preparing the rear camera' : armed ? `Barcode type is detected automatically${confirmationProgress ? ` · ${confirmationProgress}/${confirmationFrames}` : ''}` : autoAdvance ? 'Captured · scanner will rearm automatically' : 'Capture held for review'))}
</p>
</div>
<div className={`scan-result ${latest?.isDuplicate ? 'duplicate' : ''}`} aria-live="polite">
<div className="result-icon">
{latest?.isDuplicate ? <ShieldAlert /> : latest ? <CheckCircle2 /> : <Camera />}
</div>
<div className="result-copy">
<span className="eyebrow">
{status === 'storage-error' ? 'CAPTURE NOT SAVED' : cameraError ? 'CAMERA UNAVAILABLE' : latest?.isDuplicate ? 'DUPLICATE DETECTED' : latest ? 'CAPTURE SAVED' : 'SCANNER READY'}
</span>
<strong>{status === 'storage-error' ? pendingCapture?.rawValue : cameraError ? 'Camera access required' : latest?.rawValue ?? 'Waiting for a code'}</strong>
<small>{status === 'storage-error' ? 'Local storage failed. Retry saving or discard this capture.' : cameraError ? (status === 'permission-denied' ? 'Allow camera access in browser settings, then try again.' : 'Check that a camera is available, then try again.') : latest ? `${latest.format} · stored on this device` : 'Nothing leaves the device until sync is configured.'}</small>
</div>
{status === 'storage-error' && pendingCapture ? (
<div className="result-actions">
<button className="quiet-button" type="button" onClick={discardPendingCapture}>Discard</button>
<button className="primary-button" type="button" onClick={() => void saveCapture(pendingCapture)}><RefreshCw size={17} /> Retry save</button>
</div>
) : cameraError ? (
<button className="primary-button" type="button" onClick={retryCamera}><RefreshCw size={17} /> Try camera again</button>
) : (
<button className="primary-button" type="button" disabled={armed} onClick={() => setScannerArmed(true)}>
<RefreshCw size={17} /> Scan next
</button>
)}
</div>
</section>
)
}

View File

@ -0,0 +1,92 @@
import { CloudDownload, Database, RefreshCcw, ScanLine, ShieldCheck } from 'lucide-react'
import { useEffect, useState } from 'react'
import { scanFormatOptions } from '../../lib/barcodes'
import { DEVICE_HOLD_EVENT, getDeviceHoldOrientation, type DeviceHoldOrientation } from '../../lib/deviceOrientation'
import { loadScannerPreferences, resetScannerPreferences, saveScannerPreferences, type ScannerPreferences } from '../../lib/preferences'
import { disableScanAudio, enableScanAudio } from '../../lib/scanAudio'
type StorageStatus = 'checking' | 'persistent' | 'best-effort' | 'unsupported'
type SettingsViewProps = {
storageStatus: StorageStatus
updateAvailable: boolean
isUpdating: boolean
updateError: string
onApplyUpdate: () => Promise<void>
}
export function SettingsView({ storageStatus, updateAvailable, isUpdating, updateError, onApplyUpdate }: SettingsViewProps) {
const [preferences, setPreferences] = useState(loadScannerPreferences)
const [deviceHold, setDeviceHold] = useState<DeviceHoldOrientation>(getDeviceHoldOrientation)
useEffect(() => {
const update = () => setDeviceHold(getDeviceHoldOrientation())
window.addEventListener(DEVICE_HOLD_EVENT, update)
window.addEventListener('orientationchange', update)
window.visualViewport?.addEventListener('resize', update)
return () => {
window.removeEventListener(DEVICE_HOLD_EVENT, update)
window.removeEventListener('orientationchange', update)
window.visualViewport?.removeEventListener('resize', update)
}
}, [])
const updatePreferences = (updates: Partial<ScannerPreferences>) => {
const next = { ...preferences, ...updates }
setPreferences(next)
saveScannerPreferences(next)
}
const updatePayloadRules = (updates: Partial<ScannerPreferences['payloadRules']>) => {
updatePreferences({ payloadRules: { ...preferences.payloadRules, ...updates } })
}
const toggleSound = () => {
const soundEnabled = !preferences.soundEnabled
if (soundEnabled) enableScanAudio()
else disableScanAudio()
updatePreferences({ soundEnabled })
}
const storageLabel = storageStatus === 'persistent' ? 'Durable' : storageStatus === 'checking' ? 'Checking' : storageStatus === 'unsupported' ? 'Browser managed' : 'Best effort'
return (
<section className="workspace-view settings-view" aria-labelledby="settings-title">
<header className="view-heading">
<div><span className="eyebrow">DEVICE PROFILE</span><h1 id="settings-title">Settings</h1><p>Core scanner defaults are stored locally on this device.</p></div>
<button className="quiet-button" type="button" onClick={() => setPreferences(resetScannerPreferences())}><RefreshCcw size={16} /> Reset defaults</button>
</header>
<div className="settings-layout">
<section className="settings-section">
<header><span className="settings-icon"><ScanLine /></span><div><h2>Capture</h2><p>Detection and workflow defaults</p></div></header>
<div className="setting-row"><label htmlFor="setting-formats"><strong>Barcode formats</strong><small>Choose the decoder profile</small></label><select id="setting-formats" value={preferences.formatMode} onChange={(event) => updatePreferences({ formatMode: event.target.value as ScannerPreferences['formatMode'] })}>{scanFormatOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></div>
<div className="setting-row"><div><strong>Phone orientation</strong><small>Detected from how the device is held.</small></div><b>{deviceHold === 'portrait' ? 'Portrait' : 'Landscape'}</b></div>
<div className="setting-row"><div><strong>Automatic next scan</strong><small>Rearm after a successful capture</small></div><button className="switch-control" type="button" role="switch" aria-checked={preferences.autoAdvance} onClick={() => updatePreferences({ autoAdvance: !preferences.autoAdvance })}><span /></button></div>
<div className="setting-row"><div><strong>Capture sound</strong><small>Play distinct success and duplicate tones</small></div><button className="switch-control" type="button" role="switch" aria-checked={preferences.soundEnabled} onClick={toggleSound}><span /></button></div>
<div className="setting-row"><label htmlFor="setting-confirmation"><strong>Confirmation reads</strong><small>Consecutive matches before capture</small></label><select id="setting-confirmation" value={preferences.confirmationFrames} onChange={(event) => updatePreferences({ confirmationFrames: Number(event.target.value) as 2 | 3 })}><option value="2">2 frames</option><option value="3">3 frames</option></select></div>
<div className="setting-row setting-range"><label htmlFor="setting-size"><strong>Minimum code size</strong><small>Reject distant codes below {preferences.minimumSize}% of frame</small></label><input id="setting-size" type="range" min="0" max="20" value={preferences.minimumSize} onChange={(event) => updatePreferences({ minimumSize: Number(event.target.value) })} /></div>
</section>
<section className="settings-section">
<header><span className="settings-icon"><ShieldCheck /></span><div><h2>Validation</h2><p>Payload acceptance rules</p></div></header>
<div className="setting-row"><div><strong>Known standards</strong><small>Validate checksums and barcode structure</small></div><button className="switch-control" type="button" role="switch" aria-checked={preferences.payloadRules.enabled} onClick={() => updatePayloadRules({ enabled: !preferences.payloadRules.enabled })}><span /></button></div>
<div className="setting-row"><label htmlFor="setting-length"><strong>Expected length</strong><small>Zero accepts any payload length</small></label><input id="setting-length" className="compact-input" type="number" min="0" max="256" inputMode="numeric" value={preferences.payloadRules.expectedLength || ''} placeholder="Any" onChange={(event) => updatePayloadRules({ expectedLength: Math.max(0, Number(event.target.value)) })} /></div>
<div className="setting-row"><label htmlFor="setting-prefix"><strong>Required prefix</strong><small>Optional exact leading characters</small></label><input id="setting-prefix" className="compact-input" type="text" value={preferences.payloadRules.requiredPrefix} placeholder="None" onChange={(event) => updatePayloadRules({ requiredPrefix: event.target.value })} /></div>
</section>
<section className="settings-section">
<header><span className="settings-icon"><Database /></span><div><h2>Local-first storage</h2><p>Core persistence and integration boundary</p></div></header>
<div className="setting-row"><div><strong>Browser storage</strong><small>Capture data stays usable without a backend</small></div><b>{storageLabel}</b></div>
<div className="setting-row"><div><strong>Remote integration</strong><small>Register a provider or add sync in the host application</small></div><b>Host supplied</b></div>
</section>
<section className="settings-section">
<header><span className="settings-icon"><CloudDownload /></span><div><h2>Application</h2><p>PWA lifecycle</p></div></header>
<div className="setting-row"><div><strong>{updateAvailable ? 'Update available' : 'Service worker active'}</strong><small>{updateError || (isUpdating ? 'Installing the latest build…' : updateAvailable ? 'A newer build is ready.' : 'Updates are managed by the PWA service worker.')}</small></div><button className="quiet-button" type="button" disabled={!updateAvailable || isUpdating} onClick={() => void onApplyUpdate()}><CloudDownload size={15} /> {isUpdating ? 'Updating' : 'Update'}</button></div>
</section>
</div>
</section>
)
}

View File

@ -0,0 +1,17 @@
:root {
font-family: "Trebuchet MS", "Aptos", sans-serif;
color: #17211f;
background: #e9ebe5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* { box-sizing: border-box; }
html { background: #e9ebe5; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: .48; }
code { font-family: "IBM Plex Mono", "Courier New", monospace; }
#root { min-height: 100vh; }

View File

@ -0,0 +1,18 @@
/* iOS standalone PWA safe-area handling. Keep operational UI clear of the status bar/notch. */
@media (max-width: 760px) {
.workspace-view {
padding-top: calc(24px + env(safe-area-inset-top));
}
@supports selector(:has(*)) {
.label-check-view:has(.label-scan-panel) {
padding: env(safe-area-inset-top) 0 0;
background: var(--ink);
}
.label-check-view:has(.label-scan-panel) .label-camera-stage {
min-height: calc(100dvh - 70px - env(safe-area-inset-bottom) - env(safe-area-inset-top) - 92px);
}
}
}

View File

@ -0,0 +1,31 @@
import { BarcodeFormat } from '@zxing/library'
import { describe, expect, it } from 'vitest'
import { appendGtinCheckDigit, detectionSpanPercent, formatsForMode, validateScanPayload } from './barcodes'
const validationRules = { enabled: true, expectedLength: 0, requiredPrefix: '' }
describe('barcode quality rules', () => {
it('generates and validates GTIN check digits', () => {
expect(appendGtinCheckDigit('400638133393')).toBe('4006381333931')
expect(validateScanPayload('4006381333931', BarcodeFormat.EAN_13, validationRules)).toBeNull()
expect(validateScanPayload('4006381333932', BarcodeFormat.EAN_13, validationRules)).toBe('Invalid EAN-13 checksum')
})
it('excludes noisy matrix formats from narrower presets', () => {
expect(formatsForMode('standard')).not.toContain(BarcodeFormat.MICRO_QR_CODE)
expect(formatsForMode('products')).not.toContain(BarcodeFormat.QR_CODE)
expect(formatsForMode('all')).toContain(BarcodeFormat.MICRO_QR_CODE)
})
it('enforces custom payload rules', () => {
expect(validateScanPayload('ABC123', BarcodeFormat.CODE_128, { enabled: true, expectedLength: 6, requiredPrefix: 'ABC' })).toBeNull()
expect(validateScanPayload('ABC123', BarcodeFormat.CODE_128, { enabled: true, expectedLength: 8, requiredPrefix: '' })).toBe('Expected 8 characters')
})
it('measures detection span relative to the source frame', () => {
const points = [{ getX: () => 10, getY: () => 20 }, { getX: () => 30, getY: () => 25 }]
expect(detectionSpanPercent(points, 200, 100)).toBe(10)
expect(detectionSpanPercent([], 200, 100)).toBeNull()
})
})

View File

@ -0,0 +1,96 @@
import { BarcodeFormat } from '@zxing/library'
export type ScanFormatMode = 'standard' | 'products' | 'matrix' | 'all'
export type PayloadRules = {
enabled: boolean
expectedLength: number
requiredPrefix: string
}
const allFormats = Object.values(BarcodeFormat).filter((value): value is BarcodeFormat => typeof value === 'number')
const productFormats = [
BarcodeFormat.CODABAR,
BarcodeFormat.CODE_39,
BarcodeFormat.CODE_93,
BarcodeFormat.CODE_128,
BarcodeFormat.EAN_8,
BarcodeFormat.EAN_13,
BarcodeFormat.ITF,
BarcodeFormat.RSS_14,
BarcodeFormat.RSS_EXPANDED,
BarcodeFormat.UPC_A,
BarcodeFormat.UPC_E,
]
const matrixFormats = [
BarcodeFormat.AZTEC,
BarcodeFormat.DATA_MATRIX,
BarcodeFormat.MAXICODE,
BarcodeFormat.PDF_417,
BarcodeFormat.QR_CODE,
]
export const scanFormatOptions: { value: ScanFormatMode; label: string }[] = [
{ value: 'standard', label: 'Standard codes' },
{ value: 'products', label: 'Product barcodes' },
{ value: 'matrix', label: '2D codes' },
{ value: 'all', label: 'All including Micro QR' },
]
export function formatsForMode(mode: ScanFormatMode) {
if (mode === 'products') return productFormats
if (mode === 'matrix') return matrixFormats
if (mode === 'all') return allFormats
return allFormats.filter((format) => format !== BarcodeFormat.MICRO_QR_CODE)
}
export function detectionSpanPercent(points: { getX: () => number; getY: () => number }[], width: number, height: number) {
if (points.length < 2 || width <= 0 || height <= 0) return null
const xValues = points.map((point) => point.getX())
const yValues = points.map((point) => point.getY())
const widthPercent = ((Math.max(...xValues) - Math.min(...xValues)) / width) * 100
const heightPercent = ((Math.max(...yValues) - Math.min(...yValues)) / height) * 100
return Math.max(widthPercent, heightPercent)
}
function hasValidGtinCheckDigit(value: string) {
if (!/^\d+$/.test(value) || value.length < 2) return false
const body = value.slice(0, -1)
const expected = appendGtinCheckDigit(body).at(-1)
return value.at(-1) === expected
}
export function validateScanPayload(rawValue: string, format: BarcodeFormat, rules: PayloadRules) {
if (!rawValue) return 'Empty payload rejected'
if (!rules.enabled) return null
if (rules.expectedLength > 0 && rawValue.length !== rules.expectedLength) return `Expected ${rules.expectedLength} characters`
if (rules.requiredPrefix && !rawValue.startsWith(rules.requiredPrefix)) return `Expected prefix ${rules.requiredPrefix}`
if (format === BarcodeFormat.EAN_8 && (!/^\d{8}$/.test(rawValue) || !hasValidGtinCheckDigit(rawValue))) return 'Invalid EAN-8 checksum'
if (format === BarcodeFormat.EAN_13 && (!/^\d{13}$/.test(rawValue) || !hasValidGtinCheckDigit(rawValue))) return 'Invalid EAN-13 checksum'
if (format === BarcodeFormat.UPC_A && (!/^\d{12}$/.test(rawValue) || !hasValidGtinCheckDigit(rawValue))) return 'Invalid UPC-A checksum'
if (format === BarcodeFormat.UPC_E && !/^\d{8}$/.test(rawValue)) return 'Invalid UPC-E payload'
if (format === BarcodeFormat.ITF && (!/^\d{6,14}$/.test(rawValue) || rawValue.length % 2 !== 0)) return 'Invalid ITF payload'
if (format === BarcodeFormat.ITF && rawValue.length === 14 && !hasValidGtinCheckDigit(rawValue)) return 'Invalid GTIN-14 checksum'
if (format === BarcodeFormat.CODE_39 && !/^[0-9A-Z .\-$/+%]+$/.test(rawValue)) return 'Invalid Code 39 characters'
if (format === BarcodeFormat.CODABAR && !/^[A-D][0-9\-$:/.+]+[A-D]$/.test(rawValue)) return 'Invalid Codabar characters'
return null
}
export function formatName(value: string) {
return value.replaceAll('_', ' ').replace(/\b\w/g, (character) => character.toUpperCase())
}
export function appendGtinCheckDigit(base: string) {
const sum = [...base]
.reverse()
.reduce((total, digit, index) => total + Number(digit) * (index % 2 === 0 ? 3 : 1), 0)
return `${base}${(10 - (sum % 10)) % 10}`
}
export function randomDigits(length: number) {
const values = crypto.getRandomValues(new Uint8Array(length))
return [...values].map((value) => value % 10).join('')
}

View File

@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import {
estimateSectionHomography,
getCoverCoordinateSpace,
IDENTITY_HOMOGRAPHY,
invertHomography,
projectBox,
projectPoint,
} from './cartonArTracking'
describe('getCoverCoordinateSpace', () => {
it('matches object-fit cover when a landscape camera fills a portrait stage', () => {
const space = getCoverCoordinateSpace(1920, 1080, 390, 600)
expect(space.height).toBeCloseTo(600)
expect(space.width).toBeGreaterThan(390)
expect(space.left).toBeLessThan(0)
expect(space.top).toBeCloseTo(0)
})
})
describe('section homography', () => {
it('follows camera translation from one persistent label', () => {
const anchor = { x: 0.2, y: 0.3, width: 0.2, height: 0.1 }
const current = { x: 0.27, y: 0.25, width: 0.2, height: 0.1 }
const estimate = estimateSectionHomography([{ anchor, current }], IDENTITY_HOMOGRAPHY)
const projected = projectPoint(estimate.matrix, 0.3, 0.35)
expect(estimate.mode).toBe('translation')
expect(projected.x).toBeGreaterThan(0.32)
expect(projected.y).toBeLessThan(0.35)
})
it('projects remembered label boxes and can map them back into anchor space', () => {
const correspondences = [
{
anchor: { x: 0.12, y: 0.2, width: 0.18, height: 0.09 },
current: { x: 0.18, y: 0.22, width: 0.2, height: 0.1 },
},
{
anchor: { x: 0.55, y: 0.5, width: 0.17, height: 0.08 },
current: { x: 0.61, y: 0.52, width: 0.19, height: 0.09 },
},
{
anchor: { x: 0.34, y: 0.68, width: 0.16, height: 0.08 },
current: { x: 0.4, y: 0.7, width: 0.18, height: 0.09 },
},
]
const estimate = estimateSectionHomography(correspondences, IDENTITY_HOMOGRAPHY)
const remembered = { x: 0.4, y: 0.35, width: 0.18, height: 0.09 }
const projected = projectBox(estimate.matrix, remembered)
const recovered = projectBox(invertHomography(estimate.matrix), projected)
expect(estimate.landmarks).toBe(3)
expect(projected.x).toBeGreaterThan(remembered.x)
expect(recovered.x).toBeCloseTo(remembered.x, 2)
expect(recovered.y).toBeCloseTo(remembered.y, 2)
})
it('does not let one bad label match drag the remembered pallet positions', () => {
const correspondences = [
{
anchor: { x: 0.15, y: 0.15, width: 0.12, height: 0.06 },
current: { x: 0.2, y: 0.17, width: 0.12, height: 0.06 },
},
{
anchor: { x: 0.55, y: 0.15, width: 0.12, height: 0.06 },
current: { x: 0.6, y: 0.17, width: 0.12, height: 0.06 },
},
{
anchor: { x: 0.15, y: 0.55, width: 0.12, height: 0.06 },
current: { x: 0.2, y: 0.57, width: 0.12, height: 0.06 },
},
{
anchor: { x: 0.55, y: 0.55, width: 0.12, height: 0.06 },
current: { x: 0.02, y: 0.82, width: 0.12, height: 0.06 },
},
]
const estimate = estimateSectionHomography(correspondences, IDENTITY_HOMOGRAPHY)
const projected = projectPoint(estimate.matrix, 0.35, 0.35)
expect(estimate.mode).toBe('translation')
expect(projected.x).toBeGreaterThan(0.36)
expect(projected.x).toBeLessThan(0.4)
expect(projected.y).toBeGreaterThan(0.35)
expect(projected.y).toBeLessThan(0.38)
})
})

View File

@ -0,0 +1,354 @@
import type { LabelBox } from './cartonLabelDetection'
export type Homography = [
number, number, number,
number, number, number,
number, number, number,
]
export type BoxCorrespondence = {
anchor: LabelBox
current: LabelBox
}
export type CoverCoordinateSpace = {
left: number
top: number
width: number
height: number
}
export type CameraDisplayOrientation = 'auto' | 'portrait' | 'landscape'
export type HomographyEstimate = {
matrix: Homography
landmarks: number
residual: number
mode: 'idle' | 'translation' | 'homography'
}
export const IDENTITY_HOMOGRAPHY: Homography = [
1, 0, 0,
0, 1, 0,
0, 0, 1,
]
function center(box: LabelBox) {
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
function median(values: number[]) {
if (values.length === 0) return 0
const sorted = [...values].sort((left, right) => left - right)
const midpoint = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[midpoint - 1] + sorted[midpoint]) / 2 : sorted[midpoint]
}
function solveLinearSystem(matrix: number[][], vector: number[]) {
const size = vector.length
const augmented = matrix.map((row, index) => [...row, vector[index]])
for (let column = 0; column < size; column += 1) {
let pivot = column
for (let row = column + 1; row < size; row += 1) {
if (Math.abs(augmented[row][column]) > Math.abs(augmented[pivot][column])) pivot = row
}
if (Math.abs(augmented[pivot][column]) < 1e-10) return null
const swap = augmented[column]
augmented[column] = augmented[pivot]
augmented[pivot] = swap
const divisor = augmented[column][column]
for (let cursor = column; cursor <= size; cursor += 1) augmented[column][cursor] /= divisor
for (let row = 0; row < size; row += 1) {
if (row === column) continue
const factor = augmented[row][column]
if (Math.abs(factor) < 1e-12) continue
for (let cursor = column; cursor <= size; cursor += 1) {
augmented[row][cursor] -= factor * augmented[column][cursor]
}
}
}
return augmented.map((row) => row[size])
}
function leastSquaresSimilarity(correspondences: BoxCorrespondence[]) {
if (correspondences.length < 2) return null
// Fit x' = a*x - b*y + tx, y' = b*x + a*y + ty from label centres.
// Detection boxes are axis-aligned, so their corners are not reliable planar
// correspondences under perspective. Centres remain substantially more stable.
const normal = Array.from({ length: 4 }, () => Array<number>(4).fill(0))
const rhs = Array<number>(4).fill(0)
const accumulate = (row: number[], value: number) => {
for (let left = 0; left < 4; left += 1) {
rhs[left] += row[left] * value
for (let right = 0; right < 4; right += 1) normal[left][right] += row[left] * row[right]
}
}
correspondences.forEach(({ anchor, current }) => {
const from = center(anchor)
const to = center(current)
accumulate([from.x, -from.y, 1, 0], to.x)
accumulate([from.y, from.x, 0, 1], to.y)
})
for (let index = 0; index < 4; index += 1) normal[index][index] += 1e-8
const solved = solveLinearSystem(normal, rhs)
if (!solved) return null
const [a, b, tx, ty] = solved
if (![a, b, tx, ty].every(Number.isFinite)) return null
return [
a, -b, tx,
b, a, ty,
0, 0, 1,
] as Homography
}
function normalizeHomography(matrix: Homography): Homography {
const divisor = Math.abs(matrix[8]) > 1e-10 ? matrix[8] : 1
return matrix.map((value) => value / divisor) as Homography
}
function translateExisting(matrix: Homography, dx: number, dy: number): Homography {
return normalizeHomography([
matrix[0] + dx * matrix[6], matrix[1] + dx * matrix[7], matrix[2] + dx * matrix[8],
matrix[3] + dy * matrix[6], matrix[4] + dy * matrix[7], matrix[5] + dy * matrix[8],
matrix[6], matrix[7], matrix[8],
])
}
function blendHomography(previous: Homography, next: Homography, weight: number): Homography {
const blended = previous.map((value, index) => value * (1 - weight) + next[index] * weight) as Homography
return normalizeHomography(blended)
}
function similarityScale(matrix: Homography) {
return Math.hypot(matrix[0], matrix[3])
}
export function projectPoint(matrix: Homography, x: number, y: number) {
const denominator = matrix[6] * x + matrix[7] * y + matrix[8]
if (Math.abs(denominator) < 1e-8) return { x, y }
return {
x: (matrix[0] * x + matrix[1] * y + matrix[2]) / denominator,
y: (matrix[3] * x + matrix[4] * y + matrix[5]) / denominator,
}
}
export function projectBox(matrix: Homography, box: LabelBox): LabelBox {
const points = [
projectPoint(matrix, box.x, box.y),
projectPoint(matrix, box.x + box.width, box.y),
projectPoint(matrix, box.x + box.width, box.y + box.height),
projectPoint(matrix, box.x, box.y + box.height),
]
const xs = points.map((point) => point.x)
const ys = points.map((point) => point.y)
const left = Math.min(...xs)
const top = Math.min(...ys)
return {
x: left,
y: top,
width: Math.max(...xs) - left,
height: Math.max(...ys) - top,
}
}
export function invertHomography(matrix: Homography): Homography {
const [a, b, c, d, e, f, g, h, i] = matrix
const aa = e * i - f * h
const ab = c * h - b * i
const ac = b * f - c * e
const ad = f * g - d * i
const ae = a * i - c * g
const af = c * d - a * f
const ag = d * h - e * g
const ah = b * g - a * h
const ai = a * e - b * d
const determinant = a * aa + b * ad + c * ag
if (Math.abs(determinant) < 1e-9) return [...IDENTITY_HOMOGRAPHY] as Homography
return normalizeHomography([
aa / determinant, ab / determinant, ac / determinant,
ad / determinant, ae / determinant, af / determinant,
ag / determinant, ah / determinant, ai / determinant,
])
}
function residualForPair(matrix: Homography, pair: BoxCorrespondence) {
const reference = center(pair.anchor)
const expected = projectPoint(matrix, reference.x, reference.y)
const actual = center(pair.current)
return Math.hypot(expected.x - actual.x, expected.y - actual.y)
}
function estimateResidual(matrix: Homography, correspondences: BoxCorrespondence[]) {
if (correspondences.length === 0) return 0
return correspondences.reduce((sum, pair) => sum + residualForPair(matrix, pair), 0) / correspondences.length
}
function robustSimilarity(correspondences: BoxCorrespondence[]) {
const initial = leastSquaresSimilarity(correspondences)
if (!initial) return null
const residuals = correspondences.map((pair) => residualForPair(initial, pair))
const medianResidual = median(residuals)
const threshold = Math.max(0.014, Math.min(0.05, medianResidual * 2.5 + 0.006))
const inliers = correspondences.filter((_, index) => residuals[index] <= threshold)
if (inliers.length < 2) return null
const refined = inliers.length === correspondences.length ? initial : leastSquaresSimilarity(inliers)
if (!refined) return null
return {
matrix: refined,
inliers,
residual: estimateResidual(refined, inliers),
}
}
export function estimateSectionHomography(
correspondences: BoxCorrespondence[],
previous: Homography,
): HomographyEstimate {
if (correspondences.length === 0) {
return { matrix: previous, landmarks: 0, residual: 0, mode: 'idle' }
}
if (correspondences.length === 1) {
const pair = correspondences[0]
const referenceCenter = center(pair.anchor)
const predicted = projectPoint(previous, referenceCenter.x, referenceCenter.y)
const actual = center(pair.current)
const dx = actual.x - predicted.x
const dy = actual.y - predicted.y
const distance = Math.hypot(dx, dy)
// A single label is useful for translation, but not enough evidence to let
// one bad association pull the whole pallet overlay across the screen.
if (distance > 0.14) {
return { matrix: previous, landmarks: 1, residual: distance, mode: 'idle' }
}
const translated = translateExisting(previous, dx, dy)
return {
matrix: blendHomography(previous, translated, 0.42),
landmarks: 1,
residual: distance,
mode: 'translation',
}
}
const fitted = robustSimilarity(correspondences)
if (fitted) {
const previousScale = similarityScale(previous)
const nextScale = similarityScale(fitted.matrix)
const scaleRatio = previousScale > 1e-6 ? nextScale / previousScale : 1
// Reject abrupt zoom/association jumps. Normal handheld movement between
// detector frames should not resize the pallet by more than ~30% at once.
if (
Number.isFinite(fitted.residual)
&& fitted.residual <= 0.035
&& scaleRatio >= 0.72
&& scaleRatio <= 1.38
) {
const weight = fitted.inliers.length >= 4 ? 0.4 : fitted.inliers.length === 3 ? 0.34 : 0.28
return {
matrix: blendHomography(previous, fitted.matrix, weight),
landmarks: fitted.inliers.length,
residual: fitted.residual,
mode: 'homography',
}
}
}
// If a fit is ambiguous, follow only the consensus screen translation. This
// preserves tracking through a sweep without allowing a bad label swap to
// rotate or resize every remembered position.
const dx: number[] = []
const dy: number[] = []
correspondences.forEach((pair) => {
const referenceCenter = center(pair.anchor)
const predicted = projectPoint(previous, referenceCenter.x, referenceCenter.y)
const actual = center(pair.current)
dx.push(actual.x - predicted.x)
dy.push(actual.y - predicted.y)
})
const medianDx = median(dx)
const medianDy = median(dy)
const distance = Math.hypot(medianDx, medianDy)
if (distance > 0.14) {
return { matrix: previous, landmarks: correspondences.length, residual: distance, mode: 'idle' }
}
const translated = translateExisting(previous, medianDx, medianDy)
return {
matrix: blendHomography(previous, translated, 0.36),
landmarks: correspondences.length,
residual: distance,
mode: 'translation',
}
}
function currentCameraOrientation(): CameraDisplayOrientation {
if (typeof document === 'undefined') return 'auto'
const mode = document.documentElement.dataset.cameraOrientation
return mode === 'portrait' || mode === 'landscape' ? mode : 'auto'
}
function publishCameraDisplaySpace(space: CoverCoordinateSpace, quarterTurn: boolean) {
if (typeof document === 'undefined') return
const style = document.documentElement.style
style.setProperty('--fieldlens-camera-left', `${space.left}px`)
style.setProperty('--fieldlens-camera-top', `${space.top}px`)
style.setProperty('--fieldlens-camera-width', `${space.width}px`)
style.setProperty('--fieldlens-camera-height', `${space.height}px`)
style.setProperty('--fieldlens-camera-rotation', quarterTurn ? '90deg' : '0deg')
}
export function getCoverCoordinateSpace(
videoWidth: number,
videoHeight: number,
stageWidth: number,
stageHeight: number,
orientation: CameraDisplayOrientation = currentCameraOrientation(),
): CoverCoordinateSpace {
if (!videoWidth || !videoHeight || !stageWidth || !stageHeight) {
const empty = { left: 0, top: 0, width: stageWidth, height: stageHeight }
publishCameraDisplaySpace(empty, false)
return empty
}
const stageOrientation: Exclude<CameraDisplayOrientation, 'auto'> = stageWidth >= stageHeight ? 'landscape' : 'portrait'
const quarterTurn = orientation !== 'auto' && orientation !== stageOrientation
const visualWidth = quarterTurn ? videoHeight : videoWidth
const visualHeight = quarterTurn ? videoWidth : videoHeight
const scale = Math.max(stageWidth / visualWidth, stageHeight / visualHeight)
// Keep the CSS box in raw camera coordinates, then rotate the complete plane.
// The overlay uses this same rectangle, so its normalized detections remain
// pixel-aligned with the camera after a forced portrait/landscape quarter-turn.
const width = videoWidth * scale
const height = videoHeight * scale
const space = {
left: (stageWidth - width) / 2,
top: (stageHeight - height) / 2,
width,
height,
}
publishCameraDisplaySpace(space, quarterTurn)
return space
}
export function boxIntersectsVideo(box: LabelBox) {
return box.x < 1.05 && box.y < 1.05 && box.x + box.width > -0.05 && box.y + box.height > -0.05
}

View File

@ -0,0 +1,56 @@
export type CartonConfig = {
id: string
name: string
count: number
}
const STORAGE_KEY = 'fieldlens-ean-carton-configs'
function isValidConfig(value: unknown): value is CartonConfig {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<CartonConfig>
return typeof candidate.id === 'string'
&& candidate.id.length > 0
&& typeof candidate.name === 'string'
&& candidate.name.trim().length > 0
&& typeof candidate.count === 'number'
&& Number.isFinite(candidate.count)
&& candidate.count >= 1
&& candidate.count <= 500
}
export function loadCartonConfigs(): CartonConfig[] {
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') as unknown
if (!Array.isArray(parsed)) return []
return parsed
.filter(isValidConfig)
.map((config) => ({ ...config, name: config.name.trim(), count: Math.round(config.count) }))
.sort((left, right) => left.name.localeCompare(right.name))
} catch {
return []
}
}
function persistCartonConfigs(configs: CartonConfig[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(configs))
} catch {
// Configs still work for the current screen if browser storage is unavailable.
}
}
export function upsertCartonConfig(configs: CartonConfig[], name: string, count: number) {
const cleanName = name.trim()
const safeCount = Math.max(1, Math.min(500, Math.round(count)))
const existing = configs.find((config) => config.name.toLowerCase() === cleanName.toLowerCase())
const config: CartonConfig = existing
? { ...existing, name: cleanName, count: safeCount }
: { id: crypto.randomUUID(), name: cleanName, count: safeCount }
const next = existing
? configs.map((candidate) => candidate.id === existing.id ? config : candidate)
: [...configs, config]
next.sort((left, right) => left.name.localeCompare(right.name))
persistCartonConfigs(next)
return { configs: next, config }
}

View File

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { createCartonLabelScan, summarizeCartonLabels } from './cartonLabelConformity'
import type { LabelOcrResult } from './labelOcr'
function ocr(overrides: Partial<LabelOcrResult['fields']> = {}, rawText = ''): LabelOcrResult {
return {
confidence: 92,
rawText,
fields: {
ppin: '',
variety: 'HASS AVOCADO',
grower: '34000',
grade: 'CLASS 1',
packer: 'PK0405',
pickDate: '03/09/2026',
packDate: '07/09/2026',
size: '24',
count: '48',
region: '',
tempPc: '',
...overrides,
},
}
}
describe('summarizeCartonLabels', () => {
it('passes when the extracted carton label values agree', () => {
const summary = summarizeCartonLabels([
createCartonLabelScan(ocr()),
createCartonLabelScan(ocr()),
createCartonLabelScan(ocr()),
])
expect(summary.status).toBe('pass')
expect(summary.mismatchCartons).toBe(0)
expect(summary.majorityFields.length).toBeGreaterThanOrEqual(4)
})
it('uses the completed set majority rather than assuming the first label is correct', () => {
const summary = summarizeCartonLabels([
createCartonLabelScan(ocr({ packer: 'PK0999' })),
createCartonLabelScan(ocr()),
createCartonLabelScan(ocr()),
])
expect(summary.status).toBe('fail')
expect(summary.mismatchCartons).toBe(1)
expect(summary.issues).toContainEqual(expect.objectContaining({
cartonNumber: 1,
field: 'packer',
expected: 'PK0405',
actual: 'PK0999',
status: 'mismatch',
}))
})
it('accepts strong carton-label structure when PPIN or grower identity is missed', () => {
const structuralIdentity = createCartonLabelScan(ocr({ grower: '' }, 'Variety HASS AVOCADO Count 48 Packer PK0405 Size 24'))
const ppinIdentity = createCartonLabelScan(ocr({ grower: '', ppin: '123456' }, 'PPIN: 123456 Variety HASS AVOCADO Count 48 Packer PK0405 Size 24'))
expect(structuralIdentity.recognizedFields).toBeGreaterThanOrEqual(4)
expect(ppinIdentity.recognizedFields).toBeGreaterThanOrEqual(4)
})
it('drops partial OCR values before they can influence the pallet majority', () => {
const scan = createCartonLabelScan(ocr({ grower: '34', packer: 'PKO40', ppin: '04809' }))
expect(scan.fields.grower).toBe('')
expect(scan.fields.packer).toBe('')
expect(scan.fields.ppin).toBe('04809')
})
})

View File

@ -0,0 +1,191 @@
import {
LABEL_FIELD_KEYS,
LABEL_FIELD_LABELS,
hasCartonLabelIdentity,
normalizeLabelFieldValue,
sanitizeLabelFields,
type LabelFieldKey,
type LabelFields,
type LabelOcrResult,
} from './labelOcr'
export const MIN_RECOGNIZED_LABEL_FIELDS = 4
export type CartonLabelSection = {
face: number
half: 'top' | 'bottom'
}
export type CartonLabelScan = {
id: string
capturedAt: string
fields: LabelFields
rawText: string
confidence: number
recognizedFields: number
section?: CartonLabelSection
trackId?: string
}
export type CartonLabelMajorityField = {
field: LabelFieldKey
label: string
value: string
support: number
observed: number
coverage: number
}
export type CartonLabelIssue = {
scanId: string
cartonNumber: number
field: LabelFieldKey
label: string
expected: string
actual: string
status: 'mismatch' | 'unread'
section?: CartonLabelSection
}
export type CartonLabelSummary = {
total: number
status: 'pass' | 'fail' | 'review'
conforms: boolean
majorityFields: CartonLabelMajorityField[]
issues: CartonLabelIssue[]
mismatchCartons: number
averageConfidence: number
}
export function countRecognizedLabelFields(fields: LabelFields) {
return LABEL_FIELD_KEYS.filter((field) => fields[field].trim()).length
}
export function createCartonLabelScan(
ocr: LabelOcrResult,
section?: CartonLabelSection,
trackId?: string,
): CartonLabelScan {
const fields = sanitizeLabelFields(ocr.fields)
return {
id: crypto.randomUUID(),
capturedAt: new Date().toISOString(),
fields,
rawText: ocr.rawText,
confidence: ocr.confidence,
recognizedFields: hasCartonLabelIdentity({ ...ocr, fields }) ? countRecognizedLabelFields(fields) : 0,
section,
trackId,
}
}
function dominantValue(scans: CartonLabelScan[], field: LabelFieldKey) {
const totals = new Map<string, { count: number; display: string }>()
let observed = 0
scans.forEach((scan) => {
const display = scan.fields[field].trim()
const normalized = normalizeLabelFieldValue(field, display)
if (!normalized) return
observed += 1
const current = totals.get(normalized)
totals.set(normalized, { count: (current?.count ?? 0) + 1, display: current?.display ?? display })
})
const dominant = [...totals.entries()].sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]))[0]
if (!dominant) return null
return {
normalized: dominant[0],
display: dominant[1].display,
count: dominant[1].count,
observed,
}
}
export function summarizeCartonLabels(scans: CartonLabelScan[]): CartonLabelSummary {
if (scans.length === 0) {
return {
total: 0,
status: 'review',
conforms: false,
majorityFields: [],
issues: [],
mismatchCartons: 0,
averageConfidence: 0,
}
}
const majorityFields = LABEL_FIELD_KEYS.flatMap((field) => {
const dominant = dominantValue(scans, field)
if (!dominant) return []
const coverage = dominant.observed / scans.length
const dominance = dominant.count / dominant.observed
const enoughEvidence = dominant.count >= Math.min(2, scans.length) && coverage >= 0.45 && dominance >= 0.6
if (!enoughEvidence) return []
return [{
field,
label: LABEL_FIELD_LABELS[field],
value: dominant.display,
support: dominant.count,
observed: dominant.observed,
coverage,
}]
})
const issues: CartonLabelIssue[] = []
scans.forEach((scan, index) => {
majorityFields.forEach((majority) => {
const actual = scan.fields[majority.field].trim()
const normalizedActual = normalizeLabelFieldValue(majority.field, actual)
const normalizedExpected = normalizeLabelFieldValue(majority.field, majority.value)
if (!normalizedActual) {
if (majority.coverage >= 0.75) {
issues.push({
scanId: scan.id,
cartonNumber: index + 1,
field: majority.field,
label: majority.label,
expected: majority.value,
actual: '',
status: 'unread',
section: scan.section,
})
}
return
}
if (normalizedActual !== normalizedExpected) {
issues.push({
scanId: scan.id,
cartonNumber: index + 1,
field: majority.field,
label: majority.label,
expected: majority.value,
actual,
status: 'mismatch',
section: scan.section,
})
}
})
})
const mismatchCartons = new Set(issues.filter((issue) => issue.status === 'mismatch').map((issue) => issue.scanId)).size
const hasMismatch = mismatchCartons > 0
const enoughFields = majorityFields.length >= MIN_RECOGNIZED_LABEL_FIELDS
const status: CartonLabelSummary['status'] = hasMismatch ? 'fail' : enoughFields ? 'pass' : 'review'
return {
total: scans.length,
status,
conforms: status === 'pass',
majorityFields,
issues,
mismatchCartons,
averageConfidence: scans.reduce((total, scan) => total + scan.confidence, 0) / scans.length,
}
}

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { boxCenterDistance, boxIou, boxSizeDifference, type LabelBox } from './cartonLabelDetection'
describe('carton label tracking geometry', () => {
const label: LabelBox = { x: 0.2, y: 0.3, width: 0.18, height: 0.08 }
it('keeps a slightly moved white label associated with the same track', () => {
const moved: LabelBox = { x: 0.215, y: 0.305, width: 0.176, height: 0.082 }
expect(boxIou(label, moved)).toBeGreaterThan(0.7)
expect(boxCenterDistance(label, moved)).toBeLessThan(0.03)
expect(boxSizeDifference(label, moved)).toBeLessThan(0.1)
})
it('separates labels in different carton positions', () => {
const other: LabelBox = { x: 0.62, y: 0.3, width: 0.18, height: 0.08 }
expect(boxIou(label, other)).toBe(0)
expect(boxCenterDistance(label, other)).toBeGreaterThan(0.4)
})
})

View File

@ -0,0 +1,189 @@
export type LabelBox = {
x: number
y: number
width: number
height: number
}
export type LabelDetection = {
box: LabelBox
score: number
}
const WHITE_LUMA = 148
const MAX_CHROMA = 86
const MAX_DETECTIONS = 24
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function overlap(left: LabelBox, right: LabelBox) {
const x1 = Math.max(left.x, right.x)
const y1 = Math.max(left.y, right.y)
const x2 = Math.min(left.x + left.width, right.x + right.width)
const y2 = Math.min(left.y + left.height, right.y + right.height)
const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1)
const union = left.width * left.height + right.width * right.height - intersection
return union > 0 ? intersection / union : 0
}
export function boxIou(left: LabelBox, right: LabelBox) {
return overlap(left, right)
}
export function boxCenterDistance(left: LabelBox, right: LabelBox) {
const leftX = left.x + left.width / 2
const leftY = left.y + left.height / 2
const rightX = right.x + right.width / 2
const rightY = right.y + right.height / 2
return Math.hypot(leftX - rightX, leftY - rightY)
}
export function boxSizeDifference(left: LabelBox, right: LabelBox) {
const leftArea = left.width * left.height
const rightArea = right.width * right.height
if (!leftArea || !rightArea) return 1
return Math.abs(leftArea - rightArea) / Math.max(leftArea, rightArea)
}
export function detectWhiteLabelBoxes(video: HTMLVideoElement): LabelDetection[] {
const sourceWidth = video.videoWidth
const sourceHeight = video.videoHeight
if (!sourceWidth || !sourceHeight) return []
const scale = Math.min(1, 320 / sourceWidth, 480 / sourceHeight)
const width = Math.max(1, Math.round(sourceWidth * scale))
const height = Math.max(1, Math.round(sourceHeight * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d', { willReadFrequently: true })
if (!context) return []
context.drawImage(video, 0, 0, width, height)
const pixels = context.getImageData(0, 0, width, height).data
const mask = new Uint8Array(width * height)
for (let index = 0; index < mask.length; index += 1) {
const offset = index * 4
const red = pixels[offset]
const green = pixels[offset + 1]
const blue = pixels[offset + 2]
const max = Math.max(red, green, blue)
const min = Math.min(red, green, blue)
const luma = red * 0.299 + green * 0.587 + blue * 0.114
if (luma >= WHITE_LUMA && max - min <= MAX_CHROMA) mask[index] = 1
}
const visited = new Uint8Array(mask.length)
const queue = new Int32Array(mask.length)
const detections: LabelDetection[] = []
const frameArea = width * height
const enqueue = (next: number, tail: number) => {
if (next < 0 || next >= mask.length || visited[next] || !mask[next]) return tail
visited[next] = 1
queue[tail] = next
return tail + 1
}
for (let start = 0; start < mask.length; start += 1) {
if (!mask[start] || visited[start]) continue
let head = 0
let tail = 0
queue[tail++] = start
visited[start] = 1
let count = 0
let minX = width
let minY = height
let maxX = 0
let maxY = 0
while (head < tail) {
const index = queue[head++]
const x = index % width
const y = Math.floor(index / width)
count += 1
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
if (x > 0) tail = enqueue(index - 1, tail)
if (x < width - 1) tail = enqueue(index + 1, tail)
if (y > 0) tail = enqueue(index - width, tail)
if (y < height - 1) tail = enqueue(index + width, tail)
}
const boxWidth = maxX - minX + 1
const boxHeight = maxY - minY + 1
const area = boxWidth * boxHeight
const normalizedArea = area / frameArea
const aspect = boxWidth / Math.max(1, boxHeight)
const fill = count / Math.max(1, area)
// Keep the detector permissive enough for pallet-distance labels, but drop
// tiny highlights and near-square bright patches before they become tracks.
// These geometry limits are deliberately independent of OCR validity.
if (normalizedArea < 0.00045 || normalizedArea > 0.28) continue
if (aspect < 1.35 || aspect > 4.8) continue
if (fill < 0.34) continue
if (boxWidth < 9 || boxHeight < 5) continue
const box: LabelBox = {
x: minX / width,
y: minY / height,
width: boxWidth / width,
height: boxHeight / height,
}
const centeredness = 1 - Math.min(1, boxCenterDistance(box, { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }))
detections.push({ box, score: fill * 0.75 + centeredness * 0.25 })
}
return detections
.sort((left, right) => right.score - left.score)
.filter((candidate, index, all) => all.slice(0, index).every((kept) => overlap(candidate.box, kept.box) < 0.42))
.slice(0, MAX_DETECTIONS)
}
export async function cropDetectedLabel(video: HTMLVideoElement, box: LabelBox): Promise<Blob> {
const sourceWidth = video.videoWidth
const sourceHeight = video.videoHeight
if (!sourceWidth || !sourceHeight) throw new Error('Camera is not ready yet.')
const marginX = box.width * 0.08
const marginY = box.height * 0.12
const x = clamp(box.x - marginX, 0, 1)
const y = clamp(box.y - marginY, 0, 1)
const right = clamp(box.x + box.width + marginX, 0, 1)
const bottom = clamp(box.y + box.height + marginY, 0, 1)
const cropWidth = Math.max(1, Math.round((right - x) * sourceWidth))
const cropHeight = Math.max(1, Math.round((bottom - y) * sourceHeight))
const sourceX = Math.round(x * sourceWidth)
const sourceY = Math.round(y * sourceHeight)
const outputScale = Math.min(2.2, 1200 / cropWidth)
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(cropWidth * outputScale))
canvas.height = Math.max(1, Math.round(cropHeight * outputScale))
const context = canvas.getContext('2d')
if (!context) throw new Error('Could not crop the carton label.')
context.drawImage(
video,
sourceX,
sourceY,
cropWidth,
cropHeight,
0,
0,
canvas.width,
canvas.height,
)
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.94))
if (!blob) throw new Error('Could not crop the carton label.')
return blob
}

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { IDENTITY_HOMOGRAPHY } from './cartonArTracking'
import { associateLabelDetections, type TrackAssociationInput } from './cartonTrackAssociation'
function track(x: number, status = 'tracking'): TrackAssociationInput {
const box = { x, y: 0.3, width: 0.08, height: 0.04 }
return { box, anchorBox: box, missedFrames: 0, stableFrames: 4, status }
}
describe('associateLabelDetections', () => {
it('does not let an ambiguous early detection steal a much better neighboring match', () => {
const tracks = [track(0.2, 'accepted'), track(0.31)]
const detections = [
{ box: { x: 0.27, y: 0.301, width: 0.08, height: 0.04 }, score: 0.8 },
{ box: { x: 0.202, y: 0.299, width: 0.08, height: 0.04 }, score: 0.82 },
]
const result = associateLabelDetections(tracks, detections, IDENTITY_HOMOGRAPHY)
const byTrack = new Map(result.matches.map((match) => [match.trackIndex, match.detectionIndex]))
expect(byTrack.get(0)).toBe(1)
expect(byTrack.get(1)).toBe(0)
})
it('leaves implausibly distant detections unmatched instead of swapping tracks', () => {
const tracks = [track(0.2)]
const detections = [
{ box: { x: 0.42, y: 0.3, width: 0.08, height: 0.04 }, score: 0.9 },
]
const result = associateLabelDetections(tracks, detections, IDENTITY_HOMOGRAPHY)
expect(result.matches).toHaveLength(0)
expect(result.unmatchedTrackIndices).toEqual([0])
expect(result.unmatchedDetectionIndices).toEqual([0])
})
})

View File

@ -0,0 +1,129 @@
import {
boxCenterDistance,
boxIou,
boxSizeDifference,
type LabelBox,
type LabelDetection,
} from './cartonLabelDetection'
import { projectBox, type Homography } from './cartonArTracking'
export type TrackAssociationInput = {
box: LabelBox
anchorBox: LabelBox
missedFrames: number
stableFrames: number
status: string
}
export type TrackAssociationMatch = {
trackIndex: number
detectionIndex: number
expectedBox: LabelBox
score: number
iou: number
normalizedDistance: number
sizeDifference: number
}
type CandidateMatch = TrackAssociationMatch
function aspectDifference(left: LabelBox, right: LabelBox) {
const leftAspect = left.width / Math.max(0.0001, left.height)
const rightAspect = right.width / Math.max(0.0001, right.height)
return Math.abs(Math.log(Math.max(0.0001, leftAspect) / Math.max(0.0001, rightAspect)))
}
function matchRadius(box: LabelBox, missedFrames: number) {
const diagonal = Math.hypot(box.width, box.height)
const base = Math.max(0.024, Math.min(0.068, diagonal * 0.58))
return base * (1 + Math.min(2, missedFrames) * 0.18)
}
function persistenceBonus(track: TrackAssociationInput) {
const stable = Math.min(track.stableFrames, 6) * 0.035
if (track.status === 'accepted') return stable + 0.18
if (track.status === 'queued' || track.status === 'reading') return stable + 0.08
return stable
}
export function associateLabelDetections(
tracks: TrackAssociationInput[],
detections: LabelDetection[],
transform: Homography,
) {
const candidates: CandidateMatch[] = []
tracks.forEach((track, trackIndex) => {
const expectedBox = track.missedFrames > 0 ? projectBox(transform, track.anchorBox) : track.box
const radius = matchRadius(expectedBox, track.missedFrames)
const maxSizeDifference = track.missedFrames > 0 ? 0.5 : 0.42
detections.forEach((detection, detectionIndex) => {
const iou = boxIou(expectedBox, detection.box)
const distance = boxCenterDistance(expectedBox, detection.box)
const sizeDifference = boxSizeDifference(expectedBox, detection.box)
const shapeDifference = aspectDifference(expectedBox, detection.box)
if (sizeDifference > maxSizeDifference || shapeDifference > 0.42) return
if (iou < 0.12 && distance > radius) return
const normalizedDistance = distance / Math.max(0.001, radius)
const score =
iou * 4
- normalizedDistance * 1.65
- sizeDifference * 0.9
- shapeDifference * 0.45
+ detection.score * 0.18
+ persistenceBonus(track)
candidates.push({
trackIndex,
detectionIndex,
expectedBox,
score,
iou,
normalizedDistance,
sizeDifference,
})
})
})
candidates.sort((left, right) => right.score - left.score)
const bestByTrack = new Map<number, CandidateMatch>()
const bestByDetection = new Map<number, CandidateMatch>()
candidates.forEach((candidate) => {
if (!bestByTrack.has(candidate.trackIndex)) bestByTrack.set(candidate.trackIndex, candidate)
if (!bestByDetection.has(candidate.detectionIndex)) bestByDetection.set(candidate.detectionIndex, candidate)
})
const usedTracks = new Set<number>()
const usedDetections = new Set<number>()
const matches: TrackAssociationMatch[] = []
const claim = (candidate: CandidateMatch) => {
if (usedTracks.has(candidate.trackIndex) || usedDetections.has(candidate.detectionIndex)) return false
usedTracks.add(candidate.trackIndex)
usedDetections.add(candidate.detectionIndex)
matches.push(candidate)
return true
}
// Resolve unambiguous mutual-best pairs first. This prevents a merely decent
// early detection from stealing a track that another detection matches much better.
candidates.forEach((candidate) => {
if (
bestByTrack.get(candidate.trackIndex) === candidate
&& bestByDetection.get(candidate.detectionIndex) === candidate
) claim(candidate)
})
// Fill remaining pairs globally by quality rather than detector iteration order.
candidates.forEach(claim)
return {
matches,
unmatchedTrackIndices: tracks.map((_, index) => index).filter((index) => !usedTracks.has(index)),
unmatchedDetectionIndices: detections.map((_, index) => index).filter((index) => !usedDetections.has(index)),
}
}

View File

@ -0,0 +1,116 @@
import 'fake-indexeddb/auto'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
acknowledgeOutboxEvent,
clearScanHistory,
countDistinctScanValues,
database,
deleteScan,
failOutboxEvent,
getInstallationId,
getOutboxStats,
getPendingOutbox,
recordScan,
restoreScan,
serializeScanHistory,
startPalletLabelSession,
} from './database'
describe('scan persistence', () => {
beforeEach(async () => {
await database.delete()
await database.open()
})
it('marks repeated values as duplicates inside storage', async () => {
const first = await recordScan('ABC-123', 'Code 128')
const second = await recordScan('ABC-123', 'Code 128')
expect(first.isDuplicate).toBe(false)
expect(second.isDuplicate).toBe(true)
await expect(database.scans.count()).resolves.toBe(2)
})
it('writes the scan and durable outbox event together', async () => {
const scan = await recordScan('SYNC-ME', 'QR Code')
const pending = await getPendingOutbox()
expect(pending).toHaveLength(1)
expect(pending[0]).toMatchObject({
aggregateId: scan.id,
eventType: 'scan.captured',
attempts: 0,
schemaVersion: 1,
})
})
it('creates one stable installation UUID per IndexedDB installation', async () => {
const first = await getInstallationId()
const second = await getInstallationId()
expect(second).toBe(first)
})
it('keeps failed events pending and applies retry backoff', async () => {
await recordScan('RETRY-ME', 'Code 128')
const [event] = await getPendingOutbox()
expect(event).toBeDefined()
await failOutboxEvent(event!.id, 'network unavailable')
const stats = await getOutboxStats()
expect(stats.pending).toBe(1)
expect(stats.errors).toBe(1)
expect((await database.outbox.get(event!.id))?.attempts).toBe(1)
})
it('marks only explicitly acknowledged events synced', async () => {
await recordScan('ONE', 'Code 128')
await recordScan('TWO', 'Code 128')
const events = await database.outbox.toArray()
await acknowledgeOutboxEvent(events[0]!.id)
const stats = await getOutboxStats()
expect(stats.synced).toBe(1)
expect(stats.pending).toBe(1)
})
it('queues a local pallet session without any remote dependency', async () => {
const session = await startPalletLabelSession(160)
expect(session.status).toBe('active')
const event = await database.outbox.where('aggregateId').equals(session.id).first()
expect(event?.eventType).toBe('pallet.session.started')
})
it('preserves and compares decoded payloads exactly', async () => {
const spaced = await recordScan(' ABC-123 ', 'Code 128')
const plain = await recordScan('ABC-123', 'Code 128')
expect(spaced.rawValue).toBe(' ABC-123 ')
expect(plain.isDuplicate).toBe(false)
})
it('deletes and restores a scan record', async () => {
const scan = await recordScan('RESTORE-ME', 'QR Code')
await deleteScan(scan.id)
await expect(database.scans.get(scan.id)).resolves.toBeUndefined()
await restoreScan(scan)
await expect(database.scans.get(scan.id)).resolves.toEqual(scan)
})
it('counts remaining distinct values independently of historical duplicate flags', async () => {
const first = await recordScan('ABC', 'Code 128')
await recordScan('ABC', 'Code 128')
await deleteScan(first.id)
const remaining = await database.scans.toArray()
expect(remaining[0]?.isDuplicate).toBe(true)
expect(countDistinctScanValues(remaining)).toBe(1)
})
it('exports exact scan values and metadata as JSON', async () => {
const scan = await recordScan(' 00123\n', 'Code 128')
expect(JSON.parse(serializeScanHistory([scan]))).toEqual([scan])
})
afterEach(async () => {
await clearScanHistory()
})
})

View File

@ -0,0 +1,386 @@
import Dexie, { type EntityTable } from 'dexie'
import type { CartonLabelMajorityField, CartonLabelScan } from './cartonLabelConformity'
import type { LabelFields } from './labelOcr'
export const FIELDLENS_SCHEMA_VERSION = 1 as const
export type ScanRecord = {
id: string
rawValue: string
format: string
isDuplicate: boolean
scannedAt: string
syncStatus: 'local' | 'synced'
}
export type EanPalletOcrRecord = {
ean: string
fields: LabelFields
rawText: string
confidence: number
comparisonSource: string
comparisonStatus: 'not-run' | 'pass' | 'fail' | 'review'
}
export type EanPalletSessionRecord = {
id: string
targetCount: number
scans: string[]
startedAt: string
updatedAt: string
completedAt?: string
status: 'active' | 'complete'
conforms?: boolean
consensusEan?: string
uniqueEanCount?: number
ocr?: EanPalletOcrRecord
}
export type PalletLabelSessionRecord = {
id: string
targetCount: number
labels: CartonLabelScan[]
startedAt: string
updatedAt: string
completedAt?: string
status: 'active' | 'complete'
conforms?: boolean
resultStatus?: 'pass' | 'fail' | 'review'
mismatchCartons?: number
majorityFields?: CartonLabelMajorityField[]
}
export type OutboxRecord = {
id: string
aggregateId: string
eventType: 'scan.captured' | 'pallet.session.started' | 'pallet.observation.captured' | 'pallet.session.completed'
schemaVersion: number
payload: unknown
createdAt: string
attempts: number
nextAttemptAt?: string
syncedAt?: string
lastError?: string
}
export type MetadataRecord = {
key: string
value: unknown
}
class FieldLensDatabase extends Dexie {
scans!: EntityTable<ScanRecord, 'id'>
eanPalletSessions!: EntityTable<EanPalletSessionRecord, 'id'>
palletLabelSessions!: EntityTable<PalletLabelSessionRecord, 'id'>
outbox!: EntityTable<OutboxRecord, 'id'>
metadata!: EntityTable<MetadataRecord, 'key'>
constructor() {
super('fieldlens-pwa')
this.version(1).stores({
scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus',
})
this.version(2).stores({
scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus',
eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status',
})
this.version(3).stores({
scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus',
eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status',
palletLabelSessions: 'id, startedAt, updatedAt, completedAt, status',
})
this.version(4).stores({
scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus',
eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status',
palletLabelSessions: 'id, startedAt, updatedAt, completedAt, status',
outbox: 'id, aggregateId, eventType, createdAt, attempts, nextAttemptAt, syncedAt',
metadata: 'key',
})
}
}
export const database = new FieldLensDatabase()
function outboxEvent(
aggregateId: string,
eventType: OutboxRecord['eventType'],
payload: unknown,
createdAt = new Date().toISOString(),
): OutboxRecord {
return {
id: crypto.randomUUID(),
aggregateId,
eventType,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
payload,
createdAt,
attempts: 0,
}
}
export async function recordScan(rawValue: string, format: string) {
return database.transaction('rw', database.scans, database.outbox, async () => {
const existing = await database.scans.where('rawValue').equals(rawValue).first()
const nextRecord: ScanRecord = {
id: crypto.randomUUID(),
rawValue,
format,
isDuplicate: Boolean(existing),
scannedAt: new Date().toISOString(),
syncStatus: 'local',
}
await database.scans.add(nextRecord)
await database.outbox.add(outboxEvent(nextRecord.id, 'scan.captured', {
id: nextRecord.id,
rawValue: nextRecord.rawValue,
format: nextRecord.format,
duplicate: nextRecord.isDuplicate,
capturedAt: nextRecord.scannedAt,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, nextRecord.scannedAt))
return nextRecord
})
}
export async function startEanPalletSession(targetCount: number) {
const now = new Date().toISOString()
const record: EanPalletSessionRecord = {
id: crypto.randomUUID(),
targetCount,
scans: [],
startedAt: now,
updatedAt: now,
status: 'active',
}
await database.transaction('rw', database.eanPalletSessions, database.outbox, async () => {
await database.eanPalletSessions.add(record)
await database.outbox.add(outboxEvent(record.id, 'pallet.session.started', {
id: record.id,
workflowType: 'ean',
targetCount,
startedAt: now,
status: 'active',
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, now))
})
return record
}
export async function saveEanPalletScans(id: string, scans: string[]) {
const now = new Date().toISOString()
return database.transaction('rw', database.eanPalletSessions, database.outbox, async () => {
const existing = await database.eanPalletSessions.get(id)
const previousCount = existing?.scans.length ?? 0
await database.eanPalletSessions.update(id, { scans, updatedAt: now })
for (let index = previousCount; index < scans.length; index += 1) {
const ean = scans[index]
if (!ean) continue
const observationId = crypto.randomUUID()
await database.outbox.add(outboxEvent(id, 'pallet.observation.captured', {
id: observationId,
palletSessionId: id,
cartonIndex: index + 1,
ean,
capturedAt: now,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, now))
}
})
}
export async function finishEanPalletSession(
id: string,
details: Pick<EanPalletSessionRecord, 'conforms' | 'consensusEan' | 'uniqueEanCount'>,
) {
const now = new Date().toISOString()
return database.transaction('rw', database.eanPalletSessions, database.outbox, async () => {
const session = await database.eanPalletSessions.get(id)
const updated = {
...details,
status: 'complete' as const,
completedAt: now,
updatedAt: now,
}
const result = await database.eanPalletSessions.update(id, updated)
await database.outbox.add(outboxEvent(id, 'pallet.session.completed', {
id,
workflowType: 'ean',
targetCount: session?.targetCount ?? 0,
startedAt: session?.startedAt ?? now,
completedAt: now,
status: 'complete',
conforms: details.conforms,
resultStatus: details.conforms ? 'pass' : 'fail',
consensusEan: details.consensusEan,
uniqueEanCount: details.uniqueEanCount,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, now))
return result
})
}
export function saveEanPalletOcr(id: string, ocr: EanPalletOcrRecord) {
return database.eanPalletSessions.update(id, {
ocr,
updatedAt: new Date().toISOString(),
})
}
export async function startPalletLabelSession(targetCount: number) {
const now = new Date().toISOString()
const record: PalletLabelSessionRecord = {
id: crypto.randomUUID(),
targetCount,
labels: [],
startedAt: now,
updatedAt: now,
status: 'active',
}
await database.transaction('rw', database.palletLabelSessions, database.outbox, async () => {
await database.palletLabelSessions.add(record)
await database.outbox.add(outboxEvent(record.id, 'pallet.session.started', {
id: record.id,
workflowType: 'carton-label',
targetCount,
startedAt: now,
status: 'active',
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, now))
})
return record
}
export async function savePalletLabelScans(id: string, labels: CartonLabelScan[]) {
const now = new Date().toISOString()
return database.transaction('rw', database.palletLabelSessions, database.outbox, async () => {
const existing = await database.palletLabelSessions.get(id)
const previousCount = existing?.labels.length ?? 0
await database.palletLabelSessions.update(id, { labels, updatedAt: now })
for (let index = previousCount; index < labels.length; index += 1) {
const label = labels[index]
if (!label) continue
await database.outbox.add(outboxEvent(id, 'pallet.observation.captured', {
id: label.id,
palletSessionId: id,
cartonIndex: index + 1,
fieldsJson: label.fields,
rawText: label.rawText,
confidence: label.confidence,
capturedAt: label.capturedAt,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, label.capturedAt))
}
})
}
export async function finishPalletLabelSession(
id: string,
details: Pick<PalletLabelSessionRecord, 'conforms' | 'resultStatus' | 'mismatchCartons' | 'majorityFields'>,
) {
const now = new Date().toISOString()
return database.transaction('rw', database.palletLabelSessions, database.outbox, async () => {
const session = await database.palletLabelSessions.get(id)
const updated = {
...details,
status: 'complete' as const,
completedAt: now,
updatedAt: now,
}
const result = await database.palletLabelSessions.update(id, updated)
await database.outbox.add(outboxEvent(id, 'pallet.session.completed', {
id,
workflowType: 'carton-label',
targetCount: session?.targetCount ?? 0,
startedAt: session?.startedAt ?? now,
completedAt: now,
status: 'complete',
conforms: details.conforms,
resultStatus: details.resultStatus,
mismatchCartons: details.mismatchCartons,
majorityFields: details.majorityFields,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, now))
return result
})
}
export async function getInstallationId() {
return database.transaction('rw', database.metadata, async () => {
const existing = await database.metadata.get('installationId')
if (typeof existing?.value === 'string' && existing.value) return existing.value
const installationId = crypto.randomUUID()
await database.metadata.put({ key: 'installationId', value: installationId })
return installationId
})
}
export async function setLocalMetadata(key: string, value: unknown) {
await database.metadata.put({ key, value })
}
export async function getLocalMetadata<T>(key: string) {
return (await database.metadata.get(key))?.value as T | undefined
}
export async function getPendingOutbox(limit = 50, now = new Date()) {
const records = await database.outbox.filter((record) =>
!record.syncedAt && (!record.nextAttemptAt || new Date(record.nextAttemptAt) <= now),
).toArray()
return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt)).slice(0, limit)
}
export async function getOutboxStats() {
const records = await database.outbox.toArray()
return {
pending: records.filter((record) => !record.syncedAt).length,
errors: records.filter((record) => !record.syncedAt && Boolean(record.lastError)).length,
synced: records.filter((record) => Boolean(record.syncedAt)).length,
}
}
export async function acknowledgeOutboxEvent(id: string) {
await database.outbox.update(id, {
syncedAt: new Date().toISOString(),
lastError: undefined,
nextAttemptAt: undefined,
})
}
export async function failOutboxEvent(id: string, error: string) {
const record = await database.outbox.get(id)
if (!record || record.syncedAt) return
const attempts = record.attempts + 1
const delayMs = Math.min(5 * 60_000, 2_000 * (2 ** Math.min(attempts - 1, 7)))
await database.outbox.update(id, {
attempts,
lastError: error.slice(0, 300),
nextAttemptAt: new Date(Date.now() + delayMs).toISOString(),
})
}
export function clearScanHistory() {
return database.scans.clear()
}
export function deleteScan(id: string) {
return database.scans.delete(id)
}
export function restoreScan(scan: ScanRecord) {
return database.scans.put(scan)
}
export function countDistinctScanValues(scans: ScanRecord[]) {
return new Set(scans.map((scan) => scan.rawValue)).size
}
export function serializeScanHistory(scans: ScanRecord[]) {
return JSON.stringify(scans, null, 2)
}

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { rotateNormalizedRect } from './deviceOrientation'
describe('rotateNormalizedRect', () => {
const rect = { x: 0.1, y: 0.2, width: 0.3, height: 0.15 }
it('rotates an EAN detection 90 degrees clockwise in normalized camera space', () => {
expect(rotateNormalizedRect(rect, 90)).toEqual({
x: 0.65,
y: 0.1,
width: 0.15,
height: 0.3,
})
})
it('rotates an EAN detection 270 degrees clockwise in normalized camera space', () => {
const rotated = rotateNormalizedRect(rect, 270)
expect(rotated.x).toBeCloseTo(0.2)
expect(rotated.y).toBeCloseTo(0.6)
expect(rotated.width).toBeCloseTo(0.15)
expect(rotated.height).toBeCloseTo(0.3)
})
it('leaves automatic orientation coordinates unchanged', () => {
expect(rotateNormalizedRect(rect, 0)).toEqual(rect)
})
})

View File

@ -0,0 +1,265 @@
export type DeviceHoldOrientation = 'portrait' | 'landscape'
export type DetectionRotation = 0 | 90 | 270
export type NormalizedRect = {
x: number
y: number
width: number
height: number
}
export const DEVICE_HOLD_EVENT = 'fieldlens:device-hold'
export const DETECTION_ROTATION_EVENT = 'fieldlens:detection-rotation'
let detectionRotation: DetectionRotation = 0
let detectionBoxTimer: number | null = null
export function getDeviceHoldOrientation(): DeviceHoldOrientation {
const type = window.screen.orientation?.type
if (type?.startsWith('landscape')) return 'landscape'
if (type?.startsWith('portrait')) return 'portrait'
const viewport = window.visualViewport
const width = viewport?.width ?? window.innerWidth
const height = viewport?.height ?? window.innerHeight
return width > height ? 'landscape' : 'portrait'
}
export function getDetectionRotation() {
return detectionRotation
}
export function rotateNormalizedRect(rect: NormalizedRect, rotation: DetectionRotation): NormalizedRect {
if (rotation === 90) {
return {
x: 1 - rect.y - rect.height,
y: rect.x,
width: rect.height,
height: rect.width,
}
}
if (rotation === 270) {
return {
x: rect.y,
y: 1 - rect.x - rect.width,
width: rect.height,
height: rect.width,
}
}
return rect
}
function readPercent(value: string) {
if (!value.endsWith('%')) return null
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? parsed / 100 : null
}
function writeBoxCoordinate(box: HTMLElement, property: string, value: number) {
const next = `${value * 100}%`
if (box.style.getPropertyValue(property) !== next) box.style.setProperty(property, next)
}
function updateDetectionBoxCoordinates() {
if (detectionRotation === 0) return
document.querySelectorAll<HTMLElement>('.carton-video-coordinate-space .carton-batch-box').forEach((box) => {
// React owns the raw detector coordinates in left/top/width/height. Keep
// those values untouched and publish corrected coordinates through custom
// properties so tracking/OCR continue to operate in camera space.
const x = readPercent(box.style.left)
const y = readPercent(box.style.top)
const width = readPercent(box.style.width)
const height = readPercent(box.style.height)
if (x === null || y === null || width === null || height === null) return
const rotated = rotateNormalizedRect({ x, y, width, height }, detectionRotation)
writeBoxCoordinate(box, '--fieldlens-box-left', rotated.x)
writeBoxCoordinate(box, '--fieldlens-box-top', rotated.y)
writeBoxCoordinate(box, '--fieldlens-box-width', rotated.width)
writeBoxCoordinate(box, '--fieldlens-box-height', rotated.height)
})
}
function clearDetectionBoxCoordinates() {
document.querySelectorAll<HTMLElement>('.carton-video-coordinate-space .carton-batch-box').forEach((box) => {
box.style.removeProperty('--fieldlens-box-left')
box.style.removeProperty('--fieldlens-box-top')
box.style.removeProperty('--fieldlens-box-width')
box.style.removeProperty('--fieldlens-box-height')
})
}
function syncDetectionBoxTracking() {
if (detectionRotation === 0) {
if (detectionBoxTimer !== null) {
window.clearInterval(detectionBoxTimer)
detectionBoxTimer = null
}
clearDetectionBoxCoordinates()
return
}
updateDetectionBoxCoordinates()
if (detectionBoxTimer === null) {
// Detector tracks update every ~360 ms. Sampling the rendered coordinates
// a little faster keeps the corrected boxes smooth without a DOM observer
// feedback loop.
detectionBoxTimer = window.setInterval(updateDetectionBoxCoordinates, 120)
}
}
function updateDetectionButtons() {
document.querySelectorAll<HTMLButtonElement>('.fieldlens-detection-rotate').forEach((button) => {
const nextRotation = String(detectionRotation)
const nextTitle = detectionRotation === 0
? 'Rotate detection positions if phone rotation lock is on'
: `Detection positions corrected ${detectionRotation}°. Tap to rotate again.`
const nextValue = detectionRotation === 0 ? 'AUTO' : `${detectionRotation}°`
if (button.dataset.rotation !== nextRotation) button.dataset.rotation = nextRotation
if (button.title !== nextTitle) button.title = nextTitle
if (button.getAttribute('aria-label') !== nextTitle) button.setAttribute('aria-label', nextTitle)
const value = button.querySelector<HTMLElement>('[data-detection-rotation-value]')
if (value && value.textContent !== nextValue) value.textContent = nextValue
})
}
function publishDetectionRotation() {
const nextRotation = String(detectionRotation)
if (document.documentElement.dataset.detectionRotation !== nextRotation) {
document.documentElement.dataset.detectionRotation = nextRotation
}
updateDetectionButtons()
syncDetectionBoxTracking()
window.dispatchEvent(new CustomEvent<DetectionRotation>(DETECTION_ROTATION_EVENT, { detail: detectionRotation }))
}
export function rotateDetectionOverlay() {
detectionRotation = detectionRotation === 0 ? 90 : detectionRotation === 90 ? 270 : 0
publishDetectionRotation()
refreshCameraGeometry()
return detectionRotation
}
export function resetDetectionRotation() {
if (detectionRotation === 0) return
detectionRotation = 0
publishDetectionRotation()
}
function publishDeviceHoldOrientation() {
const orientation = getDeviceHoldOrientation()
if (document.documentElement.dataset.deviceHold !== orientation) {
document.documentElement.dataset.deviceHold = orientation
}
window.dispatchEvent(new CustomEvent<DeviceHoldOrientation>(DEVICE_HOLD_EVENT, { detail: orientation }))
}
function refreshCameraGeometry() {
// iOS can announce a hold change before the video element exposes its new
// intrinsic dimensions. LabelCheckView recalculates its cover rectangle on
// resize; repeat that signal after the browser settles without touching the
// camera constraints, preview transform, or field of view.
window.requestAnimationFrame(() => {
updateDetectionBoxCoordinates()
window.dispatchEvent(new Event('resize'))
})
window.setTimeout(() => {
updateDetectionBoxCoordinates()
window.dispatchEvent(new Event('resize'))
}, 120)
window.setTimeout(() => {
updateDetectionBoxCoordinates()
window.dispatchEvent(new Event('resize'))
}, 320)
window.setTimeout(() => {
updateDetectionBoxCoordinates()
window.dispatchEvent(new Event('resize'))
}, 650)
}
function attachCameraBridges() {
const attachedVideos = new WeakSet<HTMLVideoElement>()
let attachQueued = false
const attach = () => {
let changed = false
document.querySelectorAll('video').forEach((video) => {
if (attachedVideos.has(video)) return
attachedVideos.add(video)
video.addEventListener('resize', refreshCameraGeometry)
changed = true
})
document.querySelectorAll<HTMLElement>('.carton-batch-camera').forEach((camera) => {
if (camera.querySelector('.fieldlens-detection-rotate')) return
const button = document.createElement('button')
button.className = 'fieldlens-detection-rotate'
button.type = 'button'
button.innerHTML = '<span aria-hidden="true">↻</span><small data-detection-rotation-value>AUTO</small>'
button.addEventListener('click', rotateDetectionOverlay)
camera.appendChild(button)
changed = true
})
if (changed) {
updateDetectionButtons()
updateDetectionBoxCoordinates()
}
}
const scheduleAttach = () => {
if (attachQueued) return
attachQueued = true
window.requestAnimationFrame(() => {
attachQueued = false
attach()
})
}
attach()
const observer = new MutationObserver(scheduleAttach)
observer.observe(document.documentElement, { childList: true, subtree: true })
return () => observer.disconnect()
}
export function startDeviceHoldTracking() {
const handlePhysicalOrientationChange = () => {
// When the browser really rotates, its own camera/display geometry becomes
// authoritative again. Manual correction is only for orientation-lock cases.
resetDetectionRotation()
publishDeviceHoldOrientation()
refreshCameraGeometry()
}
const handleViewportChange = () => {
publishDeviceHoldOrientation()
refreshCameraGeometry()
}
publishDeviceHoldOrientation()
publishDetectionRotation()
const detachCameraBridges = attachCameraBridges()
window.addEventListener('orientationchange', handlePhysicalOrientationChange)
window.screen.orientation?.addEventListener?.('change', handlePhysicalOrientationChange)
window.visualViewport?.addEventListener('resize', handleViewportChange)
return () => {
detachCameraBridges()
if (detectionBoxTimer !== null) {
window.clearInterval(detectionBoxTimer)
detectionBoxTimer = null
}
window.removeEventListener('orientationchange', handlePhysicalOrientationChange)
window.screen.orientation?.removeEventListener?.('change', handlePhysicalOrientationChange)
window.visualViewport?.removeEventListener('resize', handleViewportChange)
}
}

View File

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { extractEanFromScan, isValidEan, summarizeEanScans } from './eanPallet'
describe('isValidEan', () => {
it('accepts valid EAN, UPC and GTIN check digits', () => {
expect(isValidEan('4006381333931')).toBe(true)
expect(isValidEan('96385074')).toBe(true)
expect(isValidEan('036000291452')).toBe(true)
expect(isValidEan('04006381333931')).toBe(true)
})
it('rejects invalid lengths and check digits', () => {
expect(isValidEan('4006381333932')).toBe(false)
expect(isValidEan('12345')).toBe(false)
})
})
describe('extractEanFromScan', () => {
it('returns direct EAN values unchanged', () => {
expect(extractEanFromScan('4006381333931')).toBe('4006381333931')
expect(extractEanFromScan('96385074')).toBe('96385074')
})
it('canonicalizes UPC-A to EAN-13', () => {
expect(extractEanFromScan('036000291452')).toBe('0036000291452')
})
it('extracts a GS1 AI 01 GTIN from 2D and element-string payloads', () => {
expect(extractEanFromScan('(01)04006381333931(10)LOT123')).toBe('4006381333931')
expect(extractEanFromScan('https://example.com/01/04006381333931/10/LOT123')).toBe('4006381333931')
})
it('does not treat an unrelated marketing QR as an EAN', () => {
expect(extractEanFromScan('https://example.com/find-out-more')).toBe('')
})
})
describe('summarizeEanScans', () => {
it('passes only when every scanned label has the same EAN', () => {
expect(summarizeEanScans(['4006381333931', '4006381333931', '4006381333931'])).toMatchObject({
total: 3,
conforms: true,
consensusEan: '4006381333931',
counts: [{ ean: '4006381333931', count: 3 }],
})
})
it('finds the majority EAN without assuming the first scan is correct', () => {
const summary = summarizeEanScans([
'96385074',
'4006381333931',
'4006381333931',
'4006381333931',
])
expect(summary.conforms).toBe(false)
expect(summary.consensusEan).toBe('4006381333931')
expect(summary.counts).toEqual([
{ ean: '4006381333931', count: 3 },
{ ean: '96385074', count: 1 },
])
})
})

View File

@ -0,0 +1,104 @@
export type EanCount = {
ean: string
count: number
}
export type EanPalletSummary = {
total: number
conforms: boolean
consensusEan: string
counts: EanCount[]
}
const GTIN_LENGTHS = new Set([8, 12, 13, 14])
export function normalizeEan(value: string) {
return value.replace(/\D/g, '')
}
function hasValidGtinCheckDigit(value: string) {
if (!GTIN_LENGTHS.has(value.length) || !/^\d+$/.test(value)) return false
const digits = [...value].map(Number)
const checkDigit = digits.at(-1) ?? -1
const body = digits.slice(0, -1)
const weightedTotal = [...body].reverse().reduce((total, digit, index) => total + digit * (index % 2 === 0 ? 3 : 1), 0)
const expectedCheckDigit = (10 - (weightedTotal % 10)) % 10
return checkDigit === expectedCheckDigit
}
export function canonicalizeEan(value: string) {
const gtin = normalizeEan(value)
if (!hasValidGtinCheckDigit(gtin)) return gtin
// UPC-A is the same trade item number as EAN-13 with a leading zero.
if (gtin.length === 12) return `0${gtin}`
// GS1 commonly encodes an EAN-13 as GTIN-14 with indicator digit zero.
if (gtin.length === 14 && gtin.startsWith('0')) return gtin.slice(1)
return gtin
}
export function isValidEan(value: string) {
return hasValidGtinCheckDigit(normalizeEan(value))
}
/**
* Extract a trade item code from the raw payload returned by a barcode reader.
* Supports direct EAN/UPC/GTIN values plus common GS1 QR, Data Matrix and Code 128 payloads.
* Returns a canonical EAN/GTIN value, or an empty string when the decoded symbol contains
* no usable trade item number.
*/
export function extractEanFromScan(rawValue: string) {
const raw = rawValue.trim()
if (!raw) return ''
const compactNumeric = raw.replace(/[\s-]/g, '')
if (/^\d+$/.test(compactNumeric) && hasValidGtinCheckDigit(compactNumeric)) {
return canonicalizeEan(compactNumeric)
}
// GS1 Application Identifier 01 carries a 14-digit GTIN. This covers element strings,
// Digital Link style URLs/query strings and scanner payloads containing the symbology prefix.
const gs1Candidates = [
...raw.matchAll(/\(01\)\s*(\d{14})/g),
...raw.matchAll(/\/01\/(\d{14})(?:\D|$)/g),
...raw.matchAll(/[?&]01=(\d{14})(?:\D|$)/g),
...raw.matchAll(/01(\d{14})/g),
]
for (const match of gs1Candidates) {
const candidate = match[1]
if (candidate && hasValidGtinCheckDigit(candidate)) return canonicalizeEan(candidate)
}
// Some 2D codes carry a larger text payload with the GTIN as a standalone field.
// Prefer longer GTIN forms and only accept candidates with a valid check digit.
const embedded = [...raw.matchAll(/(?:^|\D)(\d{12,14})(?!\d)/g)]
.map((match) => match[1])
.filter((candidate): candidate is string => Boolean(candidate) && hasValidGtinCheckDigit(candidate))
.sort((a, b) => b.length - a.length)
return embedded[0] ? canonicalizeEan(embedded[0]) : ''
}
export function summarizeEanScans(scans: string[]): EanPalletSummary {
const totals = new Map<string, number>()
scans.forEach((value) => {
const ean = canonicalizeEan(value)
if (!ean) return
totals.set(ean, (totals.get(ean) ?? 0) + 1)
})
const counts = [...totals.entries()]
.map(([ean, count]) => ({ ean, count }))
.sort((a, b) => b.count - a.count || a.ean.localeCompare(b.ean))
return {
total: scans.length,
conforms: counts.length === 1 && scans.length > 0,
consensusEan: counts[0]?.ean ?? '',
counts,
}
}

View File

@ -0,0 +1,51 @@
import type { LabelFields } from './labelOcr'
export type ExpectedLabelSpec = { ean: string; fields: LabelFields }
export type LabelExpectationLookup = {
sourceId: string
sourceLabel: string
rows: ExpectedLabelSpec[]
error?: string
}
export type LabelExpectationProvider = {
id: string
label: string
isAvailable: () => boolean
lookup: (ean: string) => Promise<ExpectedLabelSpec[]>
}
const providers: LabelExpectationProvider[] = []
export function registerLabelExpectationProvider(provider: LabelExpectationProvider) {
const index = providers.findIndex((candidate) => candidate.id === provider.id)
if (index >= 0) providers.splice(index, 1, provider)
else providers.push(provider)
return () => unregisterLabelExpectationProvider(provider.id)
}
export function unregisterLabelExpectationProvider(id: string) {
const index = providers.findIndex((provider) => provider.id === id)
if (index >= 0) providers.splice(index, 1)
}
export function getAvailableLabelExpectationProviders() {
return providers.filter((provider) => provider.isAvailable()).map(({ id, label }) => ({ id, label }))
}
export async function lookupExpectedLabels(ean: string): Promise<LabelExpectationLookup> {
const provider = providers.find((candidate) => candidate.isAvailable())
if (!provider) return { sourceId: 'local', sourceLabel: 'Local only', rows: [] }
try {
return { sourceId: provider.id, sourceLabel: provider.label, rows: await provider.lookup(ean) }
} catch (error) {
return {
sourceId: provider.id,
sourceLabel: provider.label,
rows: [],
error: error instanceof Error ? error.message : `${provider.label} lookup failed.`,
}
}
}

View File

@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import {
compareLabelFields,
getCartonLabelIdentity,
hasCartonLabelIdentity,
isValidLabelFieldValue,
parseLabelText,
sanitizeLabelFields,
type LabelFields,
} from './labelOcr'
describe('parseLabelText', () => {
it('extracts the carton label fields used by the label-check flow', () => {
const fields = parseLabelText(`
Variety: Hass Grade: CLASS 1
Grower: 34000 Packer: PK0405
Pack Date: 8/09/26 Pick Date: 5/09/26
Count: 24 Size: 24
Region Northland
TempPC: 40594960
`)
expect(fields).toEqual({
ppin: '',
grower: '34000',
variety: 'Hass',
grade: 'CLASS 1',
packer: 'PK0405',
pickDate: '05/09/2026',
packDate: '08/09/2026',
size: '24',
count: '24',
region: 'Northland',
tempPc: '40594960',
})
})
it('extracts PPIN and two-column white carton label values', () => {
const fields = parseLabelText(`
Variety: HASS AVOCADO Count: 48
Grade: CLASS 1 Packer: PK0405
Pick Date: 03/09/26 Pack Date: 07/09/26
Size: 24 PPIN: 04809
`)
expect(fields).toMatchObject({
ppin: '04809',
variety: 'HASS AVOCADO',
grade: 'CLASS 1',
packer: 'PK0405',
pickDate: '03/09/2026',
packDate: '07/09/2026',
size: '24',
count: '48',
})
})
it('accepts PPIN, grower, or strong carton structure as carton-label identity', () => {
const ppinFields = parseLabelText('PPIN: 123456 Variety: HASS Count: 48 Packer: PK0405 Size: 24')
const growerFields = parseLabelText('Grower No: 34000 Variety: HASS Count: 48')
const structuralFields = parseLabelText('Variety: HASS Count: 48')
const weakFields = parseLabelText('Size: 24 Count: 48')
expect(getCartonLabelIdentity({ fields: ppinFields, rawText: 'PPIN: 123456 Variety: HASS Count: 48' })).toEqual({ kind: 'ppin', value: '123456' })
expect(getCartonLabelIdentity({ fields: growerFields, rawText: 'Grower No: 34000 Variety: HASS Count: 48' })).toEqual({ kind: 'grower', value: '34000' })
expect(getCartonLabelIdentity({ fields: structuralFields, rawText: 'Variety: HASS Count: 48' })).toEqual({ kind: 'structure', value: 'variety,count' })
expect(getCartonLabelIdentity({ fields: weakFields, rawText: 'Size: 24 Count: 48' })).toBeNull()
})
it('keeps weak OCR retryable instead of prematurely marking a carton grey', () => {
const emptyFields = parseLabelText('')
const partialFields = parseLabelText('Size: 24 Count: 48')
const readableNonCarton = 'PROMOTIONAL SHIPPING NOTICE '.repeat(5)
expect(hasCartonLabelIdentity({ fields: emptyFields, rawText: '' })).toBe(true)
expect(hasCartonLabelIdentity({ fields: partialFields, rawText: 'Size: 24 Count: 48' })).toBe(true)
expect(hasCartonLabelIdentity({ fields: parseLabelText(readableNonCarton), rawText: readableNonCarton })).toBe(false)
})
it('rejects partial identity and packer reads before conformity', () => {
expect(isValidLabelFieldValue('grower', '34')).toBe(false)
expect(isValidLabelFieldValue('packer', 'PKO40')).toBe(false)
expect(isValidLabelFieldValue('grower', '34000')).toBe(true)
expect(isValidLabelFieldValue('packer', 'PK0405')).toBe(true)
const fields = parseLabelText('Grower: 34 Variety: HASS Grade: CLASS 1 Packer: PKO40 Size: 24')
expect(sanitizeLabelFields(fields)).toMatchObject({ grower: '', packer: '', variety: 'HASS', grade: 'CLASS 1', size: '24' })
})
})
describe('compareLabelFields', () => {
it('normalizes dates and text while preserving mismatches', () => {
const expected: LabelFields = {
ppin: '04809',
variety: 'HASS',
grower: '34000',
grade: 'CLASS 1',
packer: 'PK0405',
pickDate: '5/9/26',
packDate: '8/9/26',
size: '24',
count: '24',
region: 'Northland',
tempPc: '40594960',
}
const actual = { ...expected, packDate: '09/09/2026', pickDate: '05/09/2026' }
const comparisons = compareLabelFields(expected, actual)
expect(comparisons.find((item) => item.field === 'pickDate')?.status).toBe('match')
expect(comparisons.find((item) => item.field === 'packDate')?.status).toBe('mismatch')
})
})

View File

@ -0,0 +1,363 @@
const TESSERACT_SCRIPT = 'https://cdn.jsdelivr.net/npm/tesseract.js@6/dist/tesseract.min.js'
type TesseractProgress = {
status?: string
progress?: number
}
type TesseractResult = { data: { text: string; confidence: number } }
type TesseractWorker = {
recognize: (image: Blob | string) => Promise<TesseractResult>
setParameters?: (parameters: Record<string, string>) => Promise<unknown>
terminate?: () => Promise<unknown>
}
type TesseractApi = {
recognize: (
image: Blob | string,
language?: string,
options?: { logger?: (message: TesseractProgress) => void },
) => Promise<TesseractResult>
createWorker?: (
languages?: string | string[],
oem?: number,
options?: { logger?: (message: TesseractProgress) => void },
) => Promise<TesseractWorker>
}
type OcrWorkerSlot = {
worker: TesseractWorker
busy: boolean
progress: { current?: (progress: number, status: string) => void }
}
declare global {
interface Window {
Tesseract?: TesseractApi
}
}
export type LabelFieldKey = 'ppin' | 'grower' | 'variety' | 'grade' | 'packer' | 'pickDate' | 'packDate' | 'size' | 'count' | 'region' | 'tempPc'
export type LabelFields = Record<LabelFieldKey, string>
export type LabelOcrResult = {
fields: LabelFields
rawText: string
confidence: number
}
export type LabelFieldComparison = {
field: LabelFieldKey
label: string
expected: string
actual: string
status: 'match' | 'mismatch' | 'unread' | 'ignored'
}
export const LABEL_FIELD_KEYS: LabelFieldKey[] = ['ppin', 'grower', 'variety', 'grade', 'packer', 'pickDate', 'packDate', 'size', 'count', 'region', 'tempPc']
export const LABEL_FIELD_LABELS: Record<LabelFieldKey, string> = {
ppin: 'PPIN',
grower: 'Grower',
variety: 'Variety',
grade: 'Grade',
packer: 'Packer',
pickDate: 'Pick date',
packDate: 'Pack date',
size: 'Size',
count: 'Count',
region: 'Region',
tempPc: 'TempPC',
}
let workerPoolPromise: Promise<OcrWorkerSlot[]> | null = null
function emptyFields(): LabelFields {
return {
ppin: '',
grower: '',
variety: '',
grade: '',
packer: '',
pickDate: '',
packDate: '',
size: '',
count: '',
region: '',
tempPc: '',
}
}
function capture(text: string, pattern: RegExp) {
return text.match(pattern)?.[1]?.trim().replace(/\s+/g, ' ') ?? ''
}
function normalizeDate(value: string) {
const parts = value.trim().replace(/[.-]/g, '/').split('/')
if (parts.length !== 3) return value.trim()
const [day, month, year] = parts
const fullYear = year.length === 2 ? `20${year}` : year
return `${day.padStart(2, '0')}/${month.padStart(2, '0')}/${fullYear}`
}
function validDate(value: string) {
const normalized = normalizeDate(value)
const match = normalized.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
if (!match) return false
const day = Number(match[1])
const month = Number(match[2])
const year = Number(match[3])
const date = new Date(Date.UTC(year, month - 1, day))
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
}
export function normalizeLabelFieldValue(field: LabelFieldKey, value: string) {
if (!value) return ''
if (field === 'pickDate' || field === 'packDate') return normalizeDate(value)
if (field === 'tempPc' || field === 'count') return value.replace(/\D/g, '')
if (field === 'size') return value.replace(/\s/g, '').toUpperCase()
return value.toUpperCase().replace(/[^A-Z0-9]/g, '')
}
export function isValidLabelFieldValue(field: LabelFieldKey, value: string) {
const trimmed = value.trim()
if (!trimmed) return false
const normalized = normalizeLabelFieldValue(field, trimmed)
if (field === 'ppin' || field === 'grower') return /^[A-Z0-9-]{4,16}$/.test(trimmed.toUpperCase())
if (field === 'packer') {
if (normalized.startsWith('PK')) return /^PK[A-Z0-9]{4,8}$/.test(normalized)
return /^[A-Z0-9]{5,12}$/.test(normalized)
}
if (field === 'grade') return /^(?:CLASS)?[123]$/.test(normalized)
if (field === 'pickDate' || field === 'packDate') return validDate(trimmed)
if (field === 'count') {
const count = Number(normalized)
return /^\d{1,3}$/.test(normalized) && count >= 1 && count <= 999
}
if (field === 'size') return /^\d{1,3}(?:\/\d{1,3})?$/.test(normalized)
if (field === 'tempPc') return /^\d{7,10}$/.test(normalized)
if (field === 'variety') return normalized.length >= 3 && normalized.length <= 32
if (field === 'region') return normalized.length >= 2 && normalized.length <= 30
return true
}
export function sanitizeLabelFields(fields: LabelFields): LabelFields {
return LABEL_FIELD_KEYS.reduce<LabelFields>((clean, field) => {
clean[field] = isValidLabelFieldValue(field, fields[field]) ? fields[field].trim() : ''
return clean
}, emptyFields())
}
export function getCartonLabelIdentity(ocr: Pick<LabelOcrResult, 'fields' | 'rawText'>) {
const ppin = ocr.fields.ppin.trim() || capture(ocr.rawText, /\bP\s*P\s*I\s*N(?:\s*(?:No|Number))?\s*:?\s*([A-Z0-9-]+)/i)
if (isValidLabelFieldValue('ppin', ppin)) return { kind: 'ppin' as const, value: ppin }
const grower = ocr.fields.grower.trim()
if (isValidLabelFieldValue('grower', grower)) return { kind: 'grower' as const, value: grower }
// A moving pallet sweep will occasionally lose the PPIN/grower token while
// still reading several unmistakable carton-label fields. Do not permanently
// grey those labels after a single weak identity read. Requiring at least one
// strong carton-specific field plus a second validated field keeps generic
// white stickers out while allowing the normal minimum-field gate to decide
// whether this read is accepted or retried.
const fields = sanitizeLabelFields(ocr.fields)
const evidenceFields = LABEL_FIELD_KEYS.filter((field) => field !== 'ppin' && field !== 'grower' && fields[field].trim())
const strongEvidenceFields: LabelFieldKey[] = ['variety', 'grade', 'packer', 'pickDate', 'packDate', 'tempPc']
const strongEvidence = strongEvidenceFields.filter((field) => fields[field].trim())
if (strongEvidence.length >= 1 && evidenceFields.length >= 2) {
return { kind: 'structure' as const, value: evidenceFields.join(',') }
}
return null
}
export function hasCartonLabelIdentity(ocr: Pick<LabelOcrResult, 'fields' | 'rawText'>) {
if (getCartonLabelIdentity(ocr)) return true
const fields = sanitizeLabelFields(ocr.fields)
const evidenceFields = LABEL_FIELD_KEYS.filter((field) => field !== 'ppin' && field !== 'grower' && fields[field].trim())
if (evidenceFields.length > 0) return true
// A weak crop or motion blur commonly produces little or no usable OCR text.
// Treat that as uncertain and let the scanner retry instead of permanently
// painting a real carton label grey. Only confidently readable, substantial
// non-carton text should be classified as a non-target label.
const text = ocr.rawText.replace(/\s+/g, ' ').trim()
if (text.length < 96) return true
return /\b(?:P\s*P\s*I\s*N|Grower|Variety|Grade|Packer|Pick\s*Date|Pack\s*Date|Size|Count|Region|Temp\s*P?C)\b/i.test(text)
}
export function parseLabelText(rawText: string): LabelFields {
const text = rawText
.replace(/\r/g, '')
.replace(/[|]/g, ' ')
.replace(/\bPick\s*Dale\b/gi, 'Pick Date')
.replace(/\bPack\s*Dale\b/gi, 'Pack Date')
const fields = emptyFields()
const datePattern = '(\\d{1,2}[\\/.-]\\d{1,2}[\\/.-]\\d{2,4})'
const nextKey = '(?=\\s+(?:Grade|Grower(?:\\s*(?:No|Number|Code))?|PPIN(?:\\s*(?:No|Number))?|Packer|Pick\\s*Date|Pack\\s*Date|Size|Count|Region|Temp\\s*P?C|Variety)\\b|\\n|$)'
fields.ppin = capture(text, /\bP\s*P\s*I\s*N(?:\s*(?:No|Number))?\s*:?\s*([A-Z0-9-]+)/i)
fields.grower = capture(text, /\bGrower(?:\s*(?:No|Number|Code))?\s*:?\s*([A-Z0-9-]+)/i)
fields.variety = capture(text, new RegExp(`\\bVariety\\s*:?\\s*([A-Za-z][A-Za-z0-9 -]{0,28}?)${nextKey}`, 'i'))
fields.grade = capture(text, /\bGrade\s*:?\s*(CLASS\s*[A-Z0-9]+)/i)
fields.packer = capture(text, /\bPacker\s*:?\s*([A-Z0-9-]+)/i)
fields.pickDate = normalizeDate(capture(text, new RegExp(`\\bPick\\s*Date\\s*:?\\s*${datePattern}`, 'i')))
fields.packDate = normalizeDate(capture(text, new RegExp(`\\bPack\\s*Date\\s*:?\\s*${datePattern}`, 'i')))
fields.size = capture(text, /\bSize\s*:?\s*([0-9]+(?:\s*\/\s*[0-9]+)?)/i).replace(/\s/g, '')
fields.count = capture(text, /\bCount\s*:?\s*([0-9]+)/i)
fields.region = capture(text, new RegExp(`\\bRegion\\s*:?\\s*([A-Za-z][A-Za-z ]{1,28}?)${nextKey}`, 'i'))
fields.tempPc = capture(text, /\bTemp\s*P?C\s*:?\s*([0-9]{7,10})/i)
return fields
}
export function compareLabelFields(expected: LabelFields, actual: LabelFields): LabelFieldComparison[] {
return LABEL_FIELD_KEYS.map((field) => {
const expectedValue = expected[field].trim()
const actualValue = actual[field].trim()
let status: LabelFieldComparison['status'] = 'ignored'
if (expectedValue && !actualValue) status = 'unread'
else if (expectedValue && actualValue) {
status = normalizeLabelFieldValue(field, expectedValue) === normalizeLabelFieldValue(field, actualValue) ? 'match' : 'mismatch'
}
return {
field,
label: LABEL_FIELD_LABELS[field],
expected: expectedValue,
actual: actualValue,
status,
}
})
}
function loadTesseract() {
if (window.Tesseract) return Promise.resolve(window.Tesseract)
return new Promise<TesseractApi>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${TESSERACT_SCRIPT}"]`)
const script = existing ?? document.createElement('script')
const handleLoad = () => window.Tesseract ? resolve(window.Tesseract) : reject(new Error('OCR engine did not initialize.'))
const handleError = () => reject(new Error('OCR engine could not be loaded. Check the network connection.'))
script.addEventListener('load', handleLoad, { once: true })
script.addEventListener('error', handleError, { once: true })
if (!existing) {
script.src = TESSERACT_SCRIPT
script.async = true
document.head.append(script)
}
})
}
async function createWorkerPool() {
const tesseract = await loadTesseract()
if (!tesseract.createWorker) return []
const workerCount = (navigator.hardwareConcurrency ?? 2) >= 4 ? 2 : 1
return Promise.all(Array.from({ length: workerCount }, async () => {
const progress: OcrWorkerSlot['progress'] = {}
const worker = await tesseract.createWorker?.('eng', 1, {
logger: (message) => {
if (typeof message.progress === 'number') {
progress.current?.(Math.round(message.progress * 100), message.status ?? 'Reading label')
}
},
})
if (!worker) throw new Error('OCR worker could not be created.')
await worker.setParameters?.({
tessedit_pageseg_mode: '6',
preserve_interword_spaces: '1',
})
return { worker, busy: false, progress }
}))
}
async function getWorkerPool() {
if (!workerPoolPromise) {
workerPoolPromise = createWorkerPool().catch((error) => {
workerPoolPromise = null
throw error
})
}
return workerPoolPromise
}
async function acquireWorker() {
const slots = await getWorkerPool()
if (!slots.length) return null
while (true) {
const slot = slots.find((candidate) => !candidate.busy)
if (slot) {
slot.busy = true
return slot
}
await new Promise((resolve) => window.setTimeout(resolve, 20))
}
}
export async function warmLabelOcr() {
await getWorkerPool()
}
async function prepareImage(image: Blob): Promise<Blob> {
if (typeof createImageBitmap !== 'function') return image
try {
const bitmap = await createImageBitmap(image)
const maxDimension = 1200
const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height))
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(bitmap.width * scale))
canvas.height = Math.max(1, Math.round(bitmap.height * scale))
const context = canvas.getContext('2d')
if (!context) return image
context.filter = 'grayscale(1) contrast(1.5) brightness(1.04)'
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
bitmap.close()
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92))
return blob ?? image
} catch {
return image
}
}
export async function recognizeLabel(image: Blob, onProgress?: (progress: number, status: string) => void): Promise<LabelOcrResult> {
const prepared = await prepareImage(image)
const tesseract = await loadTesseract()
const slot = await acquireWorker().catch(() => null)
let result: TesseractResult
if (slot) {
slot.progress.current = onProgress
try {
result = await slot.worker.recognize(prepared)
} finally {
slot.progress.current = undefined
slot.busy = false
}
} else {
result = await tesseract.recognize(prepared, 'eng', {
logger: (message) => {
if (typeof message.progress === 'number') onProgress?.(Math.round(message.progress * 100), message.status ?? 'Reading label')
},
})
}
return {
fields: parseLabelText(result.data.text),
rawText: result.data.text,
confidence: result.data.confidence,
}
}

View File

@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defaultScannerPreferences, loadScannerPreferences, resetScannerPreferences, saveScannerPreferences } from './preferences'
const values = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
})
describe('scanner preferences', () => {
afterEach(() => values.clear())
it('round-trips scanner settings', () => {
const preferences = {
...defaultScannerPreferences,
autoAdvance: false,
confirmationFrames: 3 as const,
payloadRules: { enabled: true, expectedLength: 12, requiredPrefix: 'FL-' },
}
saveScannerPreferences(preferences)
expect(loadScannerPreferences()).toEqual(preferences)
})
it('falls back to defaults for invalid stored data', () => {
localStorage.setItem('fieldlens-scanner-preferences', '{invalid')
expect(loadScannerPreferences()).toEqual(defaultScannerPreferences)
expect(resetScannerPreferences()).toEqual(defaultScannerPreferences)
})
})

View File

@ -0,0 +1,50 @@
import type { PayloadRules, ScanFormatMode } from './barcodes'
export interface ScannerPreferences {
autoAdvance: boolean
soundEnabled: boolean
formatMode: ScanFormatMode
confirmationFrames: 2 | 3
minimumSize: number
payloadRules: PayloadRules
}
const STORAGE_KEY = 'fieldlens-scanner-preferences'
export const defaultScannerPreferences: ScannerPreferences = {
autoAdvance: true,
soundEnabled: false,
formatMode: 'standard',
confirmationFrames: 2,
minimumSize: 6,
payloadRules: { enabled: true, expectedLength: 0, requiredPrefix: '' },
}
export function loadScannerPreferences(): ScannerPreferences {
try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<ScannerPreferences>
return {
autoAdvance: typeof stored.autoAdvance === 'boolean' ? stored.autoAdvance : defaultScannerPreferences.autoAdvance,
soundEnabled: typeof stored.soundEnabled === 'boolean' ? stored.soundEnabled : defaultScannerPreferences.soundEnabled,
formatMode: stored.formatMode === 'standard' || stored.formatMode === 'products' || stored.formatMode === 'matrix' || stored.formatMode === 'all' ? stored.formatMode : defaultScannerPreferences.formatMode,
confirmationFrames: stored.confirmationFrames === 3 ? 3 : 2,
minimumSize: typeof stored.minimumSize === 'number' ? Math.min(20, Math.max(0, stored.minimumSize)) : defaultScannerPreferences.minimumSize,
payloadRules: {
enabled: typeof stored.payloadRules?.enabled === 'boolean' ? stored.payloadRules.enabled : defaultScannerPreferences.payloadRules.enabled,
expectedLength: typeof stored.payloadRules?.expectedLength === 'number' ? Math.min(256, Math.max(0, stored.payloadRules.expectedLength)) : defaultScannerPreferences.payloadRules.expectedLength,
requiredPrefix: typeof stored.payloadRules?.requiredPrefix === 'string' ? stored.payloadRules.requiredPrefix : defaultScannerPreferences.payloadRules.requiredPrefix,
},
}
} catch {
return structuredClone(defaultScannerPreferences)
}
}
export function saveScannerPreferences(preferences: ScannerPreferences) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences))
}
export function resetScannerPreferences() {
localStorage.removeItem(STORAGE_KEY)
return structuredClone(defaultScannerPreferences)
}

View File

@ -0,0 +1,15 @@
let scanAudioContext: AudioContext | null = null
export function enableScanAudio() {
scanAudioContext ??= new AudioContext()
void scanAudioContext.resume()
return scanAudioContext
}
export function disableScanAudio() {
void scanAudioContext?.suspend()
}
export function getScanAudio() {
return scanAudioContext
}

View File

@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { startDeviceHoldTracking } from './lib/deviceOrientation'
import './ui-polish.css'
import './ios-safe-area.css'
import './phone-landscape.css'
import './detection-rotation.css'
startDeviceHoldTracking()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,173 @@
/* Keep a phone in the mobile UI family when its wide landscape viewport crosses
* the normal 760px desktop breakpoint. Camera capture is untouched; only layout
* classification changes. Device hold is published by deviceOrientation.ts. */
@media (pointer: coarse) and (orientation: landscape) and (max-height: 500px) {
html[data-device-hold='landscape'] .app-shell {
display: block;
padding-bottom: calc(58px + env(safe-area-inset-bottom));
}
html[data-device-hold='landscape'] .sidebar {
display: none;
}
html[data-device-hold='landscape'] main {
width: 100%;
min-width: 0;
}
html[data-device-hold='landscape'] .mobile-nav {
position: fixed;
z-index: 20;
left: 0;
right: 0;
bottom: 0;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
height: calc(58px + env(safe-area-inset-bottom));
padding: 0 max(8px, env(safe-area-inset-right)) env(safe-area-inset-bottom) max(8px, env(safe-area-inset-left));
border-top: 1px solid #34413d;
background: var(--ink);
}
html[data-device-hold='landscape'] .mobile-nav button {
display: grid;
min-width: 0;
place-items: center;
align-content: center;
gap: 2px;
padding: 0 2px;
border: 0;
color: #8f9a96;
background: transparent;
font-size: 7px;
font-weight: 800;
}
html[data-device-hold='landscape'] .mobile-nav button.active {
color: white;
}
html[data-device-hold='landscape'] .mobile-nav button.active svg {
color: var(--yellow);
}
html[data-device-hold='landscape'] .workspace-view {
width: 100%;
max-width: none;
padding: 12px max(12px, env(safe-area-inset-right)) 28px max(12px, env(safe-area-inset-left));
}
/* During an EAN sweep, preserve the same camera-first mobile composition used
* in portrait. Rotating the phone must not promote the page to desktop UI. */
@supports selector(:has(*)) {
html[data-device-hold='landscape'] .carton-label-view:has(.carton-label-scan-panel) {
width: 100%;
max-width: none;
min-height: calc(100dvh - 58px - env(safe-area-inset-bottom));
padding: env(safe-area-inset-top) 0 0 !important;
gap: 0;
background: var(--ink);
}
html[data-device-hold='landscape'] .carton-label-view:has(.carton-label-scan-panel) .label-check-heading,
html[data-device-hold='landscape'] .carton-label-view:has(.carton-label-scan-panel) .label-stepper {
display: none;
}
html[data-device-hold='landscape'] .carton-label-view:has(.carton-label-scan-panel) .carton-label-scan-panel {
border: 0;
border-radius: 0;
}
}
html[data-device-hold='landscape'] .carton-section-head {
min-height: 46px;
padding: 6px max(10px, env(safe-area-inset-right)) 6px max(10px, env(safe-area-inset-left));
}
html[data-device-hold='landscape'] .carton-section-head strong {
margin-top: 1px;
font-size: 11px;
}
html[data-device-hold='landscape'] .carton-section-head small {
margin-top: 1px;
font-size: 6.5px;
}
html[data-device-hold='landscape'] .carton-section-head .quiet-button {
min-height: 32px;
padding: 0 9px;
font-size: 8px;
}
html[data-device-hold='landscape'] .carton-batch-stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
html[data-device-hold='landscape'] .carton-batch-stats > div {
padding: 5px 8px;
}
html[data-device-hold='landscape'] .carton-batch-stats span {
font-size: 5.5px;
}
html[data-device-hold='landscape'] .carton-batch-stats strong {
font-size: 10px;
}
html[data-device-hold='landscape'] .carton-label-camera {
min-height: max(180px, calc(100dvh - 196px - env(safe-area-inset-top) - env(safe-area-inset-bottom))) !important;
}
html[data-device-hold='landscape'] .carton-batch-instruction {
top: 7px;
max-width: min(470px, calc(100% - 24px));
padding: 6px 9px;
}
html[data-device-hold='landscape'] .carton-batch-instruction strong {
font-size: 10px;
}
html[data-device-hold='landscape'] .carton-batch-instruction small {
font-size: 6px;
}
html[data-device-hold='landscape'] .carton-batch-instruction em {
margin-top: 3px;
font-size: 5.5px;
}
html[data-device-hold='landscape'] .carton-live-fields {
top: 58px;
right: max(8px, env(safe-area-inset-right));
width: min(30%, 210px);
max-height: 88px;
}
html[data-device-hold='landscape'] .carton-batch-ocr-status {
left: max(8px, env(safe-area-inset-left));
bottom: 8px;
width: min(210px, 32%);
}
html[data-device-hold='landscape'] .carton-section-actions {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 6px;
padding: 6px max(10px, env(safe-area-inset-right)) 6px max(10px, env(safe-area-inset-left));
}
html[data-device-hold='landscape'] .carton-section-actions > div {
min-width: 0;
}
html[data-device-hold='landscape'] .carton-section-actions .primary-button,
html[data-device-hold='landscape'] .carton-section-actions .quiet-button {
min-height: 36px;
padding: 0 10px;
font-size: 8px;
}
}

View File

@ -0,0 +1,161 @@
/* Final responsive pass: keep operational screens dense, predictable and phone-first. */
body { overflow-x: clip; }
/* Camera restart / scan-mode transition ------------------------------------------------ */
.camera-stage video { transition: opacity .18s ease, filter .18s ease, transform .18s ease; }
.camera-stage.scanner-starting video { opacity: .48; filter: blur(5px) saturate(.65); transform: scale(1.015); }
.camera-stage.scanner-starting::before {
content: '';
position: absolute;
z-index: 7;
inset: 0;
background: rgba(18, 27, 25, .36);
pointer-events: auto;
}
.camera-stage.scanner-starting::after {
content: 'Preparing scanner…';
position: absolute;
z-index: 8;
top: 50%;
left: 50%;
min-width: 190px;
padding: 13px 16px 13px 42px;
transform: translate(-50%, -50%);
border: 1px solid rgba(255,255,255,.24);
border-radius: 7px;
color: white;
background:
radial-gradient(circle at 21px 50%, var(--yellow) 0 5px, rgba(244,211,94,.18) 6px 10px, transparent 11px),
rgba(23,33,31,.92);
box-shadow: 0 12px 34px rgba(0,0,0,.28);
backdrop-filter: blur(10px);
font-size: 11px;
font-weight: 900;
letter-spacing: .02em;
text-align: left;
animation: scanner-loading-pulse 1.15s ease-in-out infinite;
}
.camera-stage.scanner-starting .scan-frame,
.camera-stage.scanner-starting .detection-point,
.camera-stage.scanner-starting .camera-instruction { opacity: .18; }
.camera-stage.scanner-starting .camera-tools { opacity: .5; pointer-events: none; }
@keyframes scanner-loading-pulse {
0%, 100% { box-shadow: 0 12px 34px rgba(0,0,0,.28), 0 0 0 0 rgba(244,211,94,.14); }
50% { box-shadow: 0 12px 34px rgba(0,0,0,.28), 0 0 0 7px rgba(244,211,94,0); }
}
/* Restore Code Lab card containment lost in the earlier shell cleanup ------------------ */
.code-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: white; }
.code-card-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 13px 14px 10px; }
.code-card-heading strong { font-family: Georgia, serif; font-size: 17px; font-weight: 500; }
.code-card-heading span { color: var(--green); font-size: 8px; font-weight: 900; white-space: nowrap; }
.barcode-canvas { min-width: 0; }
.barcode-canvas canvas { display: block; max-width: 100%; height: auto; }
.code-card code { display: block; padding: 9px 14px 13px; overflow-wrap: anywhere; color: #5e6965; font-size: 10px; }
/* Settings section structure ------------------------------------------------------------ */
.settings-section { overflow: hidden; border-radius: 7px; }
.settings-section > header { display: flex; align-items: center; gap: 11px; min-height: 64px; padding: 12px 15px; border-bottom: 1px solid var(--line); }
.settings-section > header h2 { margin: 0; padding: 0; border: 0; font-family: Georgia, serif; font-size: 18px; font-weight: 500; }
.settings-section > header p { margin: 3px 0 0; color: #77817d; font-size: 9px; }
.settings-icon { display: grid; flex: 0 0 auto; width: 34px; height: 34px; place-items: center; border-radius: 5px; color: var(--ink); background: var(--yellow); }
.settings-section .view-actions select { min-width: 0; }
.switch-control { position: relative; width: 42px; height: 23px; padding: 0; border: 1px solid #aab2ad; border-radius: 999px; background: #d8dcd6; transition: background .16s ease, border-color .16s ease; }
.switch-control span { position: absolute; top: 2px; left: 2px; width: 17px; height: 17px; border-radius: 50%; background: white; box-shadow: 0 1px 3px rgba(0,0,0,.22); transition: transform .16s ease; }
.switch-control[aria-checked='true'] { border-color: var(--green); background: var(--green); }
.switch-control[aria-checked='true'] span { transform: translateX(19px); }
.device-facts > div { display: grid; grid-template-columns: 24px minmax(0,1fr) auto; align-items: center; gap: 10px; }
.device-facts svg { width: 16px; color: var(--green); }
/* EAN quick select --------------------------------------------------------------------- */
.ean-setup-card .ean-custom-row { grid-template-columns: minmax(0, 1fr) auto; width: min(100%, 452px); }
.ean-setup-card .ean-custom-row label { grid-column: 1 / -1; }
.ean-setup-card .ean-custom-row .compact-input { width: 100%; min-width: 0; max-width: none; }
.ean-setup-card .ean-custom-row .primary-button { white-space: nowrap; }
@media (max-width: 760px) {
main { width: 100%; min-width: 0; }
/* Camera loader should feel like part of the camera rather than a page transition. */
.camera-stage.scanner-starting::after { top: 54%; min-width: 176px; padding: 12px 14px 12px 39px; font-size: 10px; }
/* Keep workspace pages on one consistent mobile gutter. */
.workspace-view { width: 100%; max-width: none; padding: 24px 14px 42px; }
.view-heading { gap: 12px; margin-bottom: 18px; }
.view-heading h1 { margin: 5px 0 6px; font-size: 34px; line-height: 1.02; }
.view-heading p { font-size: 11px; line-height: 1.4; }
/* EAN setup: no overlap between the selector and Start button. */
.label-check-view { gap: 11px; }
.ean-setup-card { padding: 20px 14px; }
.ean-setup-card h2 { font-size: 25px; }
.ean-setup-card .ean-custom-row { grid-template-columns: minmax(0,1fr) auto; gap: 8px; }
.ean-setup-card .ean-custom-row label { grid-column: 1 / -1; }
.ean-setup-card .ean-custom-row .compact-input { width: 100%; max-width: none; min-height: 42px; }
.ean-setup-card .ean-custom-row .primary-button { min-height: 42px; padding: 0 15px; }
.ean-ar-toggle { margin-top: 1px; }
/* Code lab: full viewport width, contained canvases, less vertical waste. */
.code-lab { width: 100%; max-width: none; }
.code-lab .view-heading { margin-bottom: 18px; }
.code-lab .view-heading .primary-button { min-height: 44px; }
.code-grid { width: 100%; gap: 12px; }
.code-card { width: 100%; }
.code-card-heading { padding: 11px 12px 8px; }
.code-card-heading strong { font-size: 16px; }
.barcode-canvas { width: 100%; min-height: 118px; padding: 10px; overflow: hidden; }
.barcode-canvas canvas { max-width: 100% !important; height: auto !important; }
.code-card code { padding: 7px 12px 11px; font-size: 9px; }
/* Settings: same gutter/rhythm as the other workspaces. */
.settings-view { width: 100%; max-width: none; }
.settings-view .view-heading { margin-bottom: 16px; }
.settings-view .view-heading > .quiet-button { min-height: 40px; width: auto; justify-self: start; }
.settings-layout { grid-template-columns: 1fr; gap: 12px; }
.settings-section:nth-child(2), .settings-device { grid-column: 1; grid-row: auto; }
.settings-section > header { min-height: 58px; padding: 10px 13px; }
.settings-section > header h2 { font-size: 17px; }
.settings-icon { width: 32px; height: 32px; }
.setting-row { grid-template-columns: minmax(0,1fr) auto; gap: 12px; min-height: 62px; padding: 11px 13px; }
.setting-row strong { font-size: 11px; }
.setting-row small { margin-top: 3px; font-size: 9px; }
.setting-row select,
.setting-row .compact-input,
.setting-range input { width: 130px; max-width: 38vw; min-height: 38px; }
.setting-row .quiet-button,
.setting-row a.quiet-button { min-height: 38px; padding: 0 11px; font-size: 10px; }
.setting-row > .view-actions { display: grid; grid-template-columns: minmax(0,1fr) auto; grid-column: 1 / -1; width: 100%; gap: 7px; }
.setting-row > .view-actions select { width: 100%; max-width: none; }
.device-facts { gap: 9px; padding: 13px; }
.device-facts div { padding-bottom: 9px; }
@supports selector(:has(*)) {
.setting-row:has(> select),
.setting-row:has(> .compact-input),
.setting-row.setting-range { grid-template-columns: minmax(0,1fr) 130px; }
}
}
@media (max-width: 390px) {
.workspace-view { padding-left: 12px; padding-right: 12px; }
.view-heading h1 { font-size: 32px; }
.ean-setup-card .ean-custom-row { grid-template-columns: 1fr; }
.ean-setup-card .ean-custom-row .primary-button { width: 100%; }
.setting-row { padding: 10px 12px; }
.setting-row select,
.setting-row .compact-input,
.setting-range input { width: 116px; max-width: 36vw; }
@supports selector(:has(*)) {
.setting-row:has(> select),
.setting-row:has(> .compact-input),
.setting-row.setting-range { grid-template-columns: 1fr; }
.setting-row:has(> select) > select,
.setting-row:has(> .compact-input) > .compact-input,
.setting-row.setting-range > input { width: 100%; max-width: none; }
}
}