diff --git a/src/lib/components/Scanner.svelte b/src/lib/components/Scanner.svelte
new file mode 100644
index 0000000..b3e2446
--- /dev/null
+++ b/src/lib/components/Scanner.svelte
@@ -0,0 +1,67 @@
+
+
+
+ {#if error}
+
+ {error}
+
+ {/if}
+
+
+
+
+ Point your camera at a QR code, Code 128, or DataMatrix barcode
+
+
diff --git a/src/lib/printing/labelSheet.ts b/src/lib/printing/labelSheet.ts
new file mode 100644
index 0000000..a6493b0
--- /dev/null
+++ b/src/lib/printing/labelSheet.ts
@@ -0,0 +1,72 @@
+import bwipjs from 'bwip-js';
+import jsPDF from 'jspdf';
+
+export interface LabelSheetOptions {
+ cols?: number;
+ rows?: number;
+ cellWidth?: number;
+ cellHeight?: number;
+ marginX?: number;
+ marginY?: number;
+ qrSize?: number;
+}
+
+const DEFAULTS: Required = {
+ cols: 5,
+ rows: 10,
+ cellWidth: 38,
+ cellHeight: 27,
+ marginX: 8,
+ marginY: 10,
+ qrSize: 18,
+};
+
+export async function generateLabelSheetPDF(
+ ids: string[],
+ options: LabelSheetOptions = {}
+): Promise {
+ const opts = { ...DEFAULTS, ...options };
+ const doc = new jsPDF('p', 'mm', 'a4');
+ const perPage = opts.cols * opts.rows;
+
+ for (let i = 0; i < ids.length; i++) {
+ const col = i % opts.cols;
+ const row = Math.floor(i / opts.cols) % opts.rows;
+ const page = Math.floor(i / perPage);
+
+ if (i > 0 && i % perPage === 0) {
+ doc.addPage();
+ }
+
+ const x = opts.marginX + col * opts.cellWidth;
+ const y = opts.marginY + row * opts.cellHeight;
+
+ // Generate QR code as PNG via canvas
+ const canvas = document.createElement('canvas');
+ bwipjs.toCanvas(canvas, {
+ bcid: 'qrcode',
+ text: `https://haus.toph.so/${ids[i]}`,
+ scale: 3,
+ includetext: false,
+ });
+
+ const qrDataUrl = canvas.toDataURL('image/png');
+ doc.addImage(qrDataUrl, 'PNG', x + 1, y + 1, opts.qrSize, opts.qrSize);
+
+ // ID text
+ doc.setFontSize(8);
+ doc.setFont('courier', 'normal');
+ doc.text(ids[i], x + opts.qrSize + 2, y + opts.qrSize / 2 + 1);
+ }
+
+ return doc.output('blob');
+}
+
+export function downloadBlob(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/lib/printing/printServer.test.ts b/src/lib/printing/printServer.test.ts
new file mode 100644
index 0000000..28fe1d2
--- /dev/null
+++ b/src/lib/printing/printServer.test.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { printLabels, checkPrintServer, type PrintServerConfig } from './printServer';
+
+const config: PrintServerConfig = { baseUrl: 'http://printer.local:3030' };
+
+beforeEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('printLabels', () => {
+ it('sends POST request with labels and format', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({ ok: true });
+ vi.stubGlobal('fetch', mockFetch);
+
+ const labels = [
+ { id: 'abc1234', uri: 'https://haus.toph.so/abc1234' },
+ { id: 'def5678', uri: 'https://haus.toph.so/def5678' },
+ ];
+
+ await printLabels(config, labels, 'sheet');
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'http://printer.local:3030/api/print',
+ expect.objectContaining({
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ labels, format: 'sheet' }),
+ })
+ );
+ });
+
+ it('defaults to sheet format', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({ ok: true });
+ vi.stubGlobal('fetch', mockFetch);
+
+ await printLabels(config, []);
+
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+ expect(body.format).toBe('sheet');
+ });
+
+ it('throws on non-ok response', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: false, statusText: 'Service Unavailable' })
+ );
+
+ await expect(printLabels(config, [])).rejects.toThrow('Print failed: Service Unavailable');
+ });
+});
+
+describe('checkPrintServer', () => {
+ it('returns true when server responds OK', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
+ expect(await checkPrintServer(config)).toBe(true);
+ });
+
+ it('returns false when server responds with error', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
+ expect(await checkPrintServer(config)).toBe(false);
+ });
+
+ it('returns false when fetch throws', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
+ expect(await checkPrintServer(config)).toBe(false);
+ });
+});
diff --git a/src/lib/printing/printServer.ts b/src/lib/printing/printServer.ts
new file mode 100644
index 0000000..5f0bcbe
--- /dev/null
+++ b/src/lib/printing/printServer.ts
@@ -0,0 +1,36 @@
+export interface PrintServerConfig {
+ baseUrl: string;
+}
+
+export interface PrintLabel {
+ id: string;
+ uri: string;
+}
+
+export async function printLabels(
+ config: PrintServerConfig,
+ labels: PrintLabel[],
+ format: 'sheet' | 'strip' = 'sheet'
+): Promise {
+ const response = await fetch(`${config.baseUrl}/api/print`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ labels, format }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Print failed: ${response.statusText}`);
+ }
+}
+
+export async function checkPrintServer(config: PrintServerConfig): Promise {
+ try {
+ const response = await fetch(`${config.baseUrl}/api/health`, {
+ method: 'GET',
+ signal: AbortSignal.timeout(3000),
+ });
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/lib/scanning/detector.ts b/src/lib/scanning/detector.ts
new file mode 100644
index 0000000..9c9266d
--- /dev/null
+++ b/src/lib/scanning/detector.ts
@@ -0,0 +1,131 @@
+import { Capacitor } from '@capacitor/core';
+import { parseItemId } from './parser';
+
+export interface ScanResult {
+ itemId: string;
+ rawValue: string;
+ format: string;
+}
+
+export async function scanBarcode(): Promise {
+ if (Capacitor.isNativePlatform()) {
+ return scanWithMLKit();
+ } else {
+ return scanWithWebAPI();
+ }
+}
+
+async function scanWithMLKit(): Promise {
+ const { BarcodeScanner, BarcodeFormat } = await import(
+ '@capacitor-mlkit/barcode-scanning'
+ );
+
+ const { barcodes } = await BarcodeScanner.scan({
+ formats: [
+ BarcodeFormat.QrCode,
+ BarcodeFormat.Code128,
+ BarcodeFormat.DataMatrix,
+ ],
+ });
+
+ for (const barcode of barcodes) {
+ const id = parseItemId(barcode.rawValue ?? '');
+ if (id) {
+ return {
+ itemId: id,
+ rawValue: barcode.rawValue ?? '',
+ format: barcode.format?.toString() ?? 'unknown',
+ };
+ }
+ }
+ return null;
+}
+
+declare class BarcodeDetector {
+ constructor(options?: { formats: string[] });
+ detect(source: ImageBitmapSource): Promise>;
+ static getSupportedFormats(): Promise;
+}
+
+async function scanWithWebAPI(): Promise {
+ if (typeof window === 'undefined') return null;
+
+ const hasBarcodeAPI = 'BarcodeDetector' in window;
+ if (!hasBarcodeAPI) {
+ return null;
+ }
+
+ const detector = new BarcodeDetector({
+ formats: ['qr_code', 'code_128', 'data_matrix'],
+ });
+
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: 'environment' },
+ });
+
+ const video = document.createElement('video');
+ video.srcObject = stream;
+ video.autoplay = true;
+ await video.play();
+
+ try {
+ // Attempt detection for up to 10 seconds at ~10fps
+ const maxAttempts = 100;
+ for (let i = 0; i < maxAttempts; i++) {
+ const codes = await detector.detect(video);
+ for (const code of codes) {
+ const id = parseItemId(code.rawValue);
+ if (id) {
+ return {
+ itemId: id,
+ rawValue: code.rawValue,
+ format: code.format,
+ };
+ }
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ } finally {
+ stream.getTracks().forEach((t) => t.stop());
+ video.remove();
+ }
+
+ return null;
+}
+
+export async function checkCameraPermission(): Promise {
+ if (Capacitor.isNativePlatform()) {
+ const { BarcodeScanner } = await import(
+ '@capacitor-mlkit/barcode-scanning'
+ );
+ const result = await BarcodeScanner.checkPermissions();
+ return result.camera === 'granted';
+ }
+
+ try {
+ const result = await navigator.permissions.query({
+ name: 'camera' as PermissionName,
+ });
+ return result.state === 'granted';
+ } catch {
+ return false;
+ }
+}
+
+export async function requestCameraPermission(): Promise {
+ if (Capacitor.isNativePlatform()) {
+ const { BarcodeScanner } = await import(
+ '@capacitor-mlkit/barcode-scanning'
+ );
+ const result = await BarcodeScanner.requestPermissions();
+ return result.camera === 'granted';
+ }
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true });
+ stream.getTracks().forEach((t) => t.stop());
+ return true;
+ } catch {
+ return false;
+ }
+}