kammer/ADDENDUM_V2.md
Christopher Mühl d017987553 chore: scaffold SvelteKit project with Capacitor and Tailwind
- SvelteKit 2 (Svelte 5) with TypeScript, static adapter (SPA mode)
- Capacitor config for iOS/Android (so.toph.solidhaus)
- Tailwind CSS v4 via Vite plugin
- All dependencies: idb, nanoid, bwip-js, jspdf, Automerge, Solid
- Route stubs: dashboard, scan, items, locations, labels, settings
- Type definitions and ID generator utility
- Project specification and ontology files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:50:10 +01:00

26 KiB

SolidHaus — Addendum: Pre-printed Labels, Item Types, Consumables, Svelte, Multi-User

Supersedes / amends: PROJECT_SPECIFICATION.md, CLAUDE_CODE_GUIDE.md Date: 2026-02-26 (v2)


1. Pre-Printed Label Sheets (Not On-Demand)

The Concept

Instead of printing one label at a time when creating items, pre-generate sheets of labels with unused IDs. You peel off a label, stick it on an item, then scan it in the app to associate it with the item's data. This is similar to how asset tags work in enterprise IT.

ID Format Change

Old: 6-character nanoid New: 7-character nanoid from a 30-char alphabet (no ambiguous chars)

import { customAlphabet } from 'nanoid';
const ALPHABET = '23456789abcdefghjkmnpqrstuvwxyz'; // lowercase, no 0/o/1/i/l
const generateId = customAlphabet(ALPHABET, 7);
// Examples: "za3rbam", "k7xhw2n", "p4fvt8c"
  • 30^7 ≈ 21.8 billion combinations — effectively infinite for household use
  • Lowercase is easier to type manually and reads better in URIs
  • 7 chars gives more headroom for pre-generating batches without collision worry

Label Sheet Generation

Generate a PDF of label sheets (A4 or letter), each cell containing:

┌────────────────┐
│ ▄▄▄▄▄  haus:// │
│ █    █ za3rbam │
│ █    █         │
│ ▀▀▀▀▀         │
└────────────────┘

For Brother P-touch tape, generate a continuous strip:

┌──────┬──────┬──────┬──────┬──────┬──────┐
│ QR   │ QR   │ QR   │ QR   │ QR   │ QR   │
│za3rb │k7xhw │p4fvt │m2bck │r9gnj │w5dtx │
└──────┴──────┴──────┴──────┴──────┴──────┘

Pre-Generation Workflow

1. [App: Settings > Label Sheets]
2. [Generate 50 new IDs]  → creates 50 "unassigned" ID records in DB
3. [Download PDF / Print to P-touch]
4. [Physically apply labels to items around the house]
5. [Scan label with app → "New item with ID za3rbam" → Enter name/details]

The IDs exist in the database as "unassigned" entries until scanned and linked to an item. This means scanning an unknown-but-valid ID (one that matches the format) prompts: "This looks like a SolidHaus label. Create a new item?"

Implementation

interface PreGeneratedId {
  id: string;         // the 7-char nanoid
  generatedAt: string;
  assignedTo: string | null;  // null = unassigned, else item shortId
  batchId: string;    // which print batch this came from
}

// New IndexedDB store
preGeneratedIds: {
  key: string;
  value: PreGeneratedId;
  indexes: {
    'by-batch': string;
    'by-assigned': string;
  };
}

2. URI Scheme: haus://

Format

haus://za3rbam
  • Protocol: haus:// — short, memorable, unique enough
  • Path: the 7-character item ID
  • Full example in QR code: haus://za3rbam

How QR Scanning + Deep Linking Works

Platform Behavior when scanning haus://za3rbam
Android (Capacitor native app) Intent filter catches haus:// scheme → opens app directly to item
iOS (Capacitor native app) URL scheme registered in Info.plist → opens app directly to item
Android (installed PWA via Chrome) PWA won't catch custom scheme. Needs workaround (see below).
iOS (Safari PWA) Won't work. Custom schemes need native app.
Any phone, no app installed Camera shows "haus://za3rbam" as text. User must manually open app.

Custom URI schemes (haus://) do not work with PWAs. PWAs can only handle https:// URLs on Android (via Web App Manifest scope + intent filters that Chrome creates when installing PWA).

Dual-encoding solution: Encode QR codes with an HTTPS URL that falls back gracefully:

https://haus.toph.so/za3rbam

This web address:

  1. If Capacitor native app is installed: Android App Links / iOS Universal Links intercept → opens app
  2. If PWA is installed on Android: Chrome's intent filter for the PWA scope intercepts → opens PWA
  3. If nothing installed: Opens browser → landing page says "Install SolidHaus" with item preview

The QR code itself contains https://haus.toph.so/za3rbam. The app's ID parser extracts the 7-char ID from either format:

export function parseItemId(rawValue: string): string | null {
  // Pattern 1: HTTPS URI — https://haus.toph.so/za3rbam
  const httpsMatch = rawValue.match(/haus\.toph\.so\/([23456789a-hjkmnp-z]{7})$/);
  if (httpsMatch) return httpsMatch[1];

  // Pattern 2: Custom scheme — haus://za3rbam
  const schemeMatch = rawValue.match(/^haus:\/\/([23456789a-hjkmnp-z]{7})$/);
  if (schemeMatch) return schemeMatch[1];

  // Pattern 3: Raw ID
  const rawMatch = rawValue.match(/^[23456789a-hjkmnp-z]{7}$/);
  if (rawMatch) return rawMatch[0];

  return null;
}

Recommendation

Use https://haus.toph.so/{id} as the canonical QR content. Register haus:// as a custom scheme in the Capacitor native app for bonus direct-launch capability. The HTTPS URL works everywhere and degrades gracefully.


3. Item Types & Lifecycle

Type Taxonomy

type ItemType =
  | 'durable'      // Long-lasting items (tools, furniture, electronics)
  | 'consumable'   // Items that get used up (batteries, tape, screws)
  | 'disposable'   // Single-use items (paper plates, trash bags)
  | 'perishable'   // Food/drink with expiry (pantry, fridge, freezer)
  | 'media'        // Books, DVDs, games
  | 'clothing'     // Wearable items
  | 'document'     // Important papers, manuals, certificates
  | 'container'    // Boxes, bins — items that hold other items
  ;

RDF Mapping

solidhaus:ItemType a rdfs:Class ;
    rdfs:label "Item Type"@en .

solidhaus:Durable a solidhaus:ItemType ;
    rdfs:label "Durable"@en ;
    rdfs:comment "Long-lasting items: tools, furniture, electronics, appliances."@en .

solidhaus:Consumable a solidhaus:ItemType ;
    rdfs:label "Consumable"@en ;
    rdfs:comment "Items that deplete with use: batteries, tape, screws, ink, cleaning supplies."@en .

solidhaus:Disposable a solidhaus:ItemType ;
    rdfs:label "Disposable"@en ;
    rdfs:comment "Single-use items: paper plates, trash bags, wipes."@en .

solidhaus:Perishable a solidhaus:ItemType ;
    rdfs:label "Perishable"@en ;
    rdfs:comment "Food and drink with expiration dates."@en .

solidhaus:itemType a rdf:Property ;
    rdfs:domain schema:IndividualProduct ;
    rdfs:range solidhaus:ItemType .

Quantitative Properties (for Consumables & Perishables)

Items that get used up need quantity tracking:

interface QuantityInfo {
  currentAmount: number | null;   // e.g., 750
  originalAmount: number | null;  // e.g., 1000
  unit: string;                   // e.g., "ml", "g", "pcs", "sheets", "%"
  lowThreshold: number | null;    // alert when below this (e.g., 100)
  expiryDate: string | null;      // ISO date, for perishables
}

RDF properties:

solidhaus:currentQuantity a rdf:Property ;
    rdfs:label "Current Quantity"@en ;
    rdfs:comment "Current amount remaining. Pair with quantityUnit."@en ;
    rdfs:domain schema:IndividualProduct ;
    rdfs:range xsd:decimal .

solidhaus:originalQuantity a rdf:Property ;
    rdfs:label "Original Quantity"@en ;
    rdfs:comment "Amount when full/new."@en ;
    rdfs:domain schema:IndividualProduct ;
    rdfs:range xsd:decimal .

solidhaus:quantityUnit a rdf:Property ;
    rdfs:label "Quantity Unit"@en ;
    rdfs:comment "Unit of measurement: ml, l, g, kg, pcs, sheets, %."@en ;
    rdfs:domain schema:IndividualProduct ;
    rdfs:range xsd:string .

solidhaus:lowThreshold a rdf:Property ;
    rdfs:label "Low Threshold"@en ;
    rdfs:comment "Alert when currentQuantity drops below this value."@en ;
    rdfs:domain schema:IndividualProduct ;
    rdfs:range xsd:decimal .

schema.org already has schema:weight and schema:size, but for dynamic tracking of "how much is left" we need the custom properties above.

Pantry / Kitchen Extension

With perishable items, this naturally extends to pantry management:

<items/olive_oil_01>
    a schema:IndividualProduct ;
    schema:name "Olivenöl — Bertolli Extra Vergine" ;
    solidhaus:itemType solidhaus:Consumable ;
    solidhaus:currentQuantity 400 ;
    solidhaus:originalQuantity 1000 ;
    solidhaus:quantityUnit "ml" ;
    solidhaus:lowThreshold 100 ;
    solidhaus:custodyState solidhaus:CheckedIn ;
    solidhaus:supposedToBeAt <locations/kueche-regal> ;
    solidhaus:storageTier solidhaus:HotStorage .

<items/flour_01>
    a schema:IndividualProduct ;
    schema:name "Mehl Type 405" ;
    solidhaus:itemType solidhaus:Perishable ;
    solidhaus:currentQuantity 800 ;
    solidhaus:originalQuantity 1000 ;
    solidhaus:quantityUnit "g" ;
    solidhaus:lowThreshold 200 ;
    schema:expires "2026-09-15"^^xsd:date ;
    solidhaus:supposedToBeAt <locations/kueche-schrank> .

Quick-Update UX for Consumables

When scanning a consumable, offer a fast "update quantity" flow:

[Scan olive oil bottle]
  → [Card shows: "Olivenöl Bertolli — 400ml / 1000ml (40%)"]
  → [Quick actions: "Almost empty" | "Half left" | "Just opened" | "Custom"]
  → [Tap "Almost empty" → sets to ~100ml]
  → [If below threshold → "Add to shopping list?" prompt]

4. Barcode Format Support

What Formats Can Be Read?

Format Barcode Detection API (Chrome) ZXing (fallback) Capacitor ML Kit Notes
QR Code Our primary format
DataMatrix Very compact, great for small labels
Code 128 Linear, good for tape labels
EAN-13/UPC Product barcodes (scan existing products)
Aztec
PDF417

All common formats are supported across all platforms. QR is the best default because:

  • Highest information density for small size
  • Error correction built in
  • Phone cameras auto-recognize and offer to open URLs
  • Works well even when slightly damaged or at angle

DataMatrix is a great alternative for very small labels (it's more compact than QR at small sizes).

Scanning Existing Product Barcodes

The app should also recognize standard EAN/UPC barcodes on commercial products. When scanning a product barcode (e.g., the EAN-13 on a bottle of olive oil):

  1. Look up in local database first (maybe you've already cataloged it)
  2. If not found, optionally query an open product database (Open Food Facts, etc.)
  3. Offer to create a new item pre-filled with product info

This is a nice-to-have for Phase 2+.


5. Framework: SvelteKit + Capacitor (Not React)

Why Svelte/SvelteKit Over React

Given the preference for Svelte, the recommended stack is:

Layer Technology
Framework SvelteKit 2 (Svelte 5 with runes)
Build Vite (built into SvelteKit)
Native wrapper Capacitor (Android + iOS)
Desktop Tauri 2 (optional, later)
Styling Tailwind CSS 4
State Svelte 5 runes ($state, $derived, $effect) — no external state library needed
Routing SvelteKit file-based routing
Barcode (native) @capacitor/barcode-scanner or @capacitor-mlkit/barcode-scanning
Barcode (web) Barcode Detection API + ZXing fallback
Local DB IndexedDB via idb (same as before)
CRDT Automerge (for multi-user, see §6)

SvelteKit + Capacitor Setup

This is a well-documented, production-proven path:

# Create SvelteKit project
npx sv create solidhaus
cd solidhaus

# Add static adapter (required for Capacitor)
npm install -D @sveltejs/adapter-static

# Add Capacitor
npm install @capacitor/core @capacitor/cli
npx cap init solidhaus so.toph.solidhaus --web-dir build

# Add platforms
npm install @capacitor/android @capacitor/ios
npx cap add android
npx cap add ios

# Add barcode scanner
npm install @capacitor/barcode-scanner
# or for ML Kit (more powerful):
npm install @capacitor-mlkit/barcode-scanning

# Other deps
npm install nanoid idb bwip-js date-fns
npm install -D tailwindcss @tailwindcss/vite

svelte.config.js:

import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

export default {
  preprocess: vitePreprocess(),
  kit: {
    adapter: adapter({
      fallback: 'index.html'  // SPA mode for Capacitor
    })
  }
};

src/routes/+layout.ts:

export const ssr = false;       // Capacitor needs client-side only
export const prerender = false;

Deep Linking with Capacitor

Register the custom URI scheme and HTTPS App Links:

capacitor.config.ts:

const config: CapacitorConfig = {
  appId: 'so.toph.solidhaus',
  appName: 'SolidHaus',
  webDir: 'build',
  server: {
    // For deep linking via HTTPS
    androidScheme: 'https'
  },
  plugins: {
    // App Links / Universal Links configuration
  }
};

AndroidManifest.xml additions:

<!-- Custom scheme: haus:// -->
<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="haus" />
</intent-filter>

<!-- HTTPS App Links: https://haus.toph.so/* -->
<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="https" android:host="haus.toph.so" />
</intent-filter>

Is There a "Svelte Native"?

Short answer: not really, not in a usable way.

  • NativeScript + Svelte existed but is stuck on Svelte 4 and not actively maintained for Svelte 5.
  • Lynx (ByteDance) has an agnostic core but only works with React currently. Svelte support requires a custom renderer API that's being worked on by the Mainmatter team but isn't ready.
  • Capacitor is the pragmatic choice — it wraps your web app in a native WebView with full access to native APIs (camera, Bluetooth, file system, deep links). It's not "truly native" rendering, but for an inventory app the performance difference is negligible.
  • Tauri 2 supports Android and iOS and could be an alternative to Capacitor, with Rust backend instead of Java/Swift. More bleeding-edge but the SvelteKit integration is officially documented.

Recommendation: SvelteKit + Capacitor. It's the most mature path for Svelte on mobile with native features (barcode scanning, deep links, Bluetooth).


6. Multi-User & Offline-First (Hard Requirement)

The Scenario

Multiple household members scan/update items independently:

  • Person A scans the drill in the workshop (phone offline in basement)
  • Person B logs the drill as lent to neighbor (phone online upstairs)
  • Both changes eventually sync and resolve

Architecture: CRDT + Solid Pod

With multi-user as a hard requirement, simple LWW is no longer sufficient. We need CRDTs.

Recommended approach: Automerge as the sync layer, Solid Pod as the persistence/sharing layer.

Device A (Phone)         Device B (Tablet)
     │                        │
     ▼                        ▼
┌─────────────┐        ┌─────────────┐
│ Local        │        │ Local        │
│ Automerge    │        │ Automerge    │
│ Document     │        │ Document     │
│ (IndexedDB)  │        │ (IndexedDB)  │
└──────┬──────┘        └──────┬──────┘
       │                       │
       ▼                       ▼
   ┌──────────────────────────────┐
   │      Sync Server             │
   │  (automerge-repo-server      │
   │   on NixOS infrastructure)   │
   └──────────────┬───────────────┘
                  │
                  ▼
            ┌───────────┐
            │ Solid Pod  │
            │ (RDF export│
            │  periodic) │
            └───────────┘

How It Works

  1. Each device maintains a local Automerge document with all inventory data
  2. Automerge-repo handles sync between devices via a WebSocket relay server
  3. Offline: Changes accumulate locally in the Automerge doc. When back online, automerge-repo syncs automatically — merges are conflict-free by design
  4. Solid Pod integration: A periodic job serializes the Automerge state to RDF/Turtle and writes it to the Solid Pod. This is the "canonical" linked data representation. The Solid Pod is the long-term archive and interoperability layer, not the real-time sync layer.

Why Not Use Solid Pod Directly for Sync?

Solid Pods use HTTP REST (LDP). They're great for storage and sharing but not for real-time multi-device sync:

  • No built-in conflict resolution
  • Polling-based (Solid Notifications is still maturing)
  • High latency for offline→online sync bursts

CRDTs via Automerge solve the hard sync problem; Solid solves the data ownership/interoperability problem. They're complementary.

Automerge Document Structure

import { next as Automerge } from '@automerge/automerge';

interface InventoryDoc {
  items: { [shortId: string]: Item };
  locations: { [id: string]: Location };
  sightings: { [id: string]: Sighting };
  preGeneratedIds: { [id: string]: PreGeneratedId };
}

// Create and modify
let doc = Automerge.init<InventoryDoc>();
doc = Automerge.change(doc, 'Add new item', d => {
  d.items['za3rbam'] = {
    shortId: 'za3rbam',
    name: 'Bosch Drill',
    custodyState: 'checked-in',
    // ...
  };
});

Auth & Permissions

  • Each household member authenticates with their Solid WebID
  • The Automerge sync server can use simple shared-secret or Solid-based auth
  • All household members have read/write access to the shared inventory
  • Personal items can be marked as visibility: 'private' (only visible to owner)

Sync Server (NixOS)

The automerge-repo sync server is a small Node.js process:

# NixOS module sketch
services.solidhaus-sync = {
  enable = true;
  port = 3030;
  package = pkgs.nodejs;
  # automerge-repo-network-websocket server
};

This runs alongside your existing Solid pod (CSS) and other infrastructure.

Future: NextGraph

If NextGraph reaches production readiness, it could replace the entire Automerge + Solid Pod stack with a single integrated solution: CRDT-native RDF, E2EE, P2P sync, SPARQL queries — all in one. Keep monitoring.


7. Updated Project Structure (SvelteKit)

solidhaus/
├── src/
│   ├── routes/
│   │   ├── +layout.svelte          # App shell, bottom nav
│   │   ├── +layout.ts              # SSR disabled
│   │   ├── +page.svelte            # Dashboard
│   │   ├── scan/
│   │   │   └── +page.svelte        # Scanner view
│   │   ├── items/
│   │   │   ├── +page.svelte        # Item list
│   │   │   ├── new/+page.svelte    # Create item
│   │   │   └── [id]/
│   │   │       ├── +page.svelte    # Item detail
│   │   │       └── edit/+page.svelte
│   │   ├── locations/
│   │   │   └── +page.svelte        # Location tree
│   │   ├── labels/
│   │   │   └── +page.svelte        # Label sheet generator + print queue
│   │   └── settings/
│   │       └── +page.svelte        # Settings, sync status, pod config
│   ├── lib/
│   │   ├── components/
│   │   │   ├── Scanner.svelte
│   │   │   ├── ItemCard.svelte
│   │   │   ├── ItemForm.svelte
│   │   │   ├── LocationPicker.svelte
│   │   │   ├── LocationTree.svelte
│   │   │   ├── QuantitySlider.svelte   # For consumables
│   │   │   ├── ConfidenceBadge.svelte
│   │   │   ├── CustodyBadge.svelte
│   │   │   ├── LabelPreview.svelte
│   │   │   ├── BottomNav.svelte
│   │   │   └── PhotoCapture.svelte
│   │   ├── data/
│   │   │   ├── db.ts                # IndexedDB
│   │   │   ├── items.ts
│   │   │   ├── locations.ts
│   │   │   ├── sightings.ts
│   │   │   └── labels.ts           # Pre-generated ID management
│   │   ├── sync/
│   │   │   ├── automerge.ts         # Automerge doc management
│   │   │   ├── repo.ts             # automerge-repo setup
│   │   │   └── solid-export.ts     # Periodic RDF export to Solid Pod
│   │   ├── solid/
│   │   │   ├── auth.ts
│   │   │   ├── rdf.ts
│   │   │   └── pod.ts
│   │   ├── scanning/
│   │   │   ├── detector.ts          # Barcode detection (native + web)
│   │   │   └── parser.ts           # ID extraction from barcodes
│   │   ├── printing/
│   │   │   ├── labelSheet.ts        # PDF label sheet generation
│   │   │   ├── tapeStrip.ts        # P-touch tape strip generation
│   │   │   └── printServer.ts      # HTTP print server client
│   │   ├── stores/
│   │   │   ├── inventory.svelte.ts  # Svelte 5 runes-based stores
│   │   │   ├── scan.svelte.ts
│   │   │   ├── sync.svelte.ts
│   │   │   └── settings.svelte.ts
│   │   ├── types/
│   │   │   ├── item.ts
│   │   │   ├── location.ts
│   │   │   ├── sighting.ts
│   │   │   └── quantity.ts
│   │   ├── ontology/
│   │   │   ├── solidhaus.ts         # Namespace constants
│   │   │   └── namespaces.ts
│   │   └── utils/
│   │       ├── id.ts                # nanoid generator
│   │       ├── confidence.ts
│   │       └── format.ts           # Quantity formatting ("400ml / 1L")
│   └── app.html
├── static/
│   ├── manifest.json
│   └── icons/
├── ontology/
│   └── solidhaus.ttl
├── android/                         # Capacitor Android project
├── ios/                            # Capacitor iOS project
├── svelte.config.js
├── capacitor.config.ts
├── vite.config.ts
├── tailwind.config.ts
├── package.json
└── tsconfig.json

8. Updated IndexedDB Schema (Items)

interface Item {
  shortId: string;                // 7-char nanoid
  name: string;
  description: string;
  category: string;
  brand: string;
  serialNumber: string;
  color: string;
  purchaseDate: string | null;

  // Item type
  itemType: 'durable' | 'consumable' | 'disposable' | 'perishable'
          | 'media' | 'clothing' | 'document' | 'container';

  // Quantity (for consumables/perishables)
  currentQuantity: number | null;
  originalQuantity: number | null;
  quantityUnit: string | null;       // "ml", "g", "pcs", "sheets", "%"
  lowThreshold: number | null;
  expiryDate: string | null;         // ISO date

  // Barcode
  barcodeUri: string;                // "https://haus.toph.so/za3rbam"
  barcodeFormat: 'qr' | 'datamatrix' | 'code128';
  photoIds: string[];

  // Location tracking
  lastSeenAt: string | null;
  lastSeenTimestamp: string | null;
  lastUsedAt: string | null;
  supposedToBeAt: string | null;
  locationConfidence: 'confirmed' | 'likely' | 'assumed' | 'unknown';

  // Custody state
  custodyState: 'checked-in' | 'checked-out';
  checkedOutSince: string | null;
  checkedOutReason: 'in-use' | 'in-transit' | 'lent' | 'in-repair' | 'temporary' | 'consumed' | null;
  checkedOutFrom: string | null;
  checkedOutTo: string | null;
  checkedOutNote: string | null;

  // Storage
  storageTier: 'hot' | 'warm' | 'cold';
  storageContainerId: string | null;
  storageContainerLabel: string | null;

  // Label
  labelPrinted: boolean;
  labelPrintedAt: string | null;
  labelBatchId: string | null;       // which pre-gen batch

  // Metadata
  createdAt: string;
  updatedAt: string;
  tags: string[];
  createdBy: string | null;          // WebID of creator (multi-user)
}

9. Summary of All Decisions

Decision Choice Rationale
ID length 7 characters More headroom for pre-generation batches
ID alphabet Lowercase + digits, no ambiguous Easier to type in URIs
QR content https://haus.toph.so/{id} Works everywhere, degrades gracefully
Custom scheme haus:// (bonus, native only) Direct app launch when native app installed
Labels Pre-printed sheets/strips Faster than on-demand, batch workflow
Framework SvelteKit 5 + Capacitor Svelte preference, native features via Capacitor
Native mobile Capacitor (not Svelte Native) Svelte Native doesn't exist in usable form
Desktop Tauri 2 (future) Rust backend, SvelteKit frontend, officially supported
Multi-user sync Automerge (CRDT) Conflict-free offline-first sync between household members
Data ownership Solid Pod (RDF export) Long-term archive, interoperability, data sovereignty
Real-time sync automerge-repo WebSocket server Low-latency device-to-device sync
Barcode scanning (native) @capacitor-mlkit/barcode-scanning ML Kit is fast, supports all formats
Barcode scanning (web) Barcode Detection API + ZXing Progressive enhancement
Item types durable/consumable/disposable/perishable/... Naturally extends to pantry tracking
Quantity tracking currentQuantity + unit + threshold Consumables need "how much is left"
Printing Pre-gen PDF sheets + P-touch strips via print server Batch workflow, NixOS service