- 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>
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. |
The Deep Link Problem for PWAs
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:
- If Capacitor native app is installed: Android App Links / iOS Universal Links intercept → opens app
- If PWA is installed on Android: Chrome's intent filter for the PWA scope intercepts → opens PWA
- 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):
- Look up in local database first (maybe you've already cataloged it)
- If not found, optionally query an open product database (Open Food Facts, etc.)
- 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
- Each device maintains a local Automerge document with all inventory data
- Automerge-repo handles sync between devices via a WebSocket relay server
- Offline: Changes accumulate locally in the Automerge doc. When back online, automerge-repo syncs automatically — merges are conflict-free by design
- 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 |