24 KiB
SolidHaus — Claude Code Implementation Guide
This document provides step-by-step implementation instructions for Claude Code.
Overview
- Framework: SvelteKit 2 (Svelte 5 with runes) + Capacitor
- Goal: Local-first, multi-user household inventory app with barcode scanning
- Key deps: idb, nanoid, bwip-js, @capacitor-mlkit/barcode-scanning, automerge, @inrupt/solid-client
Priority: Phase 1 — Core MVP
Build the app incrementally. Each task builds on the previous.
Task 1: Project Scaffold
Create a SvelteKit project with Capacitor and Tailwind CSS.
npx sv create solidhaus
cd solidhaus
# Static adapter for Capacitor
npm install -D @sveltejs/adapter-static
# Capacitor
npm install @capacitor/core @capacitor/cli
npx cap init solidhaus so.toph.solidhaus --web-dir build
npm install @capacitor/android @capacitor/ios
npx cap add android
npx cap add ios
# Barcode scanner
npm install @capacitor-mlkit/barcode-scanning
# Core deps
npm install idb nanoid date-fns bwip-js jspdf
# Solid (Phase 3, but install now)
npm install @inrupt/solid-client @inrupt/solid-client-authn-browser @inrupt/vocab-common-rdf
# Automerge (Phase 2, but install now)
npm install @automerge/automerge @automerge/automerge-repo @automerge/automerge-repo-network-websocket @automerge/automerge-repo-storage-indexeddb
# Dev
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;
export const prerender = false;
vite.config.ts:
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
],
});
capacitor.config.ts:
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'so.toph.solidhaus',
appName: 'SolidHaus',
webDir: 'build',
server: {
androidScheme: 'https'
}
};
export default config;
File structure:
Follow the project structure defined in PROJECT_SPECIFICATION.md §12.
Task 2: Data Layer (IndexedDB)
Create src/lib/data/db.ts using the idb library.
Database Schema:
import { openDB, DBSchema } from 'idb';
interface SolidHausDB extends DBSchema {
items: {
key: string; // shortId
value: {
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 (consumables/perishables)
currentQuantity: number | null;
originalQuantity: number | null;
quantityUnit: string | null;
lowThreshold: number | null;
expiryDate: string | null;
// Barcode
barcodeFormat: 'qr' | 'datamatrix' | 'code128';
barcodeUri: string;
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 tier
storageTier: 'hot' | 'warm' | 'cold';
storageContainerId: string | null;
storageContainerLabel: string | null;
// Label
labelPrinted: boolean;
labelPrintedAt: string | null;
labelBatchId: string | null;
// Metadata
createdAt: string;
updatedAt: string;
tags: string[];
createdBy: string | null;
};
indexes: {
'by-name': string;
'by-category': string;
'by-location': string;
'by-type': string;
'by-custody': string;
};
};
locations: {
key: string;
value: {
id: string;
name: string;
description: string;
parentId: string | null;
locationType: 'house' | 'floor' | 'room' | 'furniture' | 'shelf' | 'drawer' | 'box' | 'wall' | 'outdoor';
defaultStorageTier: 'hot' | 'warm' | 'cold' | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
};
indexes: {
'by-parent': string;
'by-name': string;
};
};
sightings: {
key: string;
value: {
id: string;
itemId: string;
locationId: string;
timestamp: string;
sightingType: 'scan' | 'manual' | 'camera-detect' | 'audit-verify';
confidence: 'confirmed' | 'inferred' | 'assumed';
notes: string;
createdBy: string | null;
};
indexes: {
'by-item': string;
'by-location': string;
'by-timestamp': string;
};
};
photos: {
key: string;
value: {
id: string;
itemId: string;
blob: Blob;
thumbnail: Blob | null;
createdAt: string;
};
indexes: {
'by-item': string;
};
};
preGeneratedIds: {
key: string;
value: {
id: string;
generatedAt: string;
assignedTo: string | null;
batchId: string;
};
indexes: {
'by-batch': string;
'by-assigned': string;
};
};
settings: {
key: string;
value: {
key: string;
value: unknown;
};
};
}
const DB_NAME = 'solidhaus';
const DB_VERSION = 1;
export async function getDB() {
return openDB<SolidHausDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Items
const itemStore = db.createObjectStore('items', { keyPath: 'shortId' });
itemStore.createIndex('by-name', 'name');
itemStore.createIndex('by-category', 'category');
itemStore.createIndex('by-location', 'lastSeenAt');
itemStore.createIndex('by-type', 'itemType');
itemStore.createIndex('by-custody', 'custodyState');
// Locations
const locStore = db.createObjectStore('locations', { keyPath: 'id' });
locStore.createIndex('by-parent', 'parentId');
locStore.createIndex('by-name', 'name');
// Sightings
const sightStore = db.createObjectStore('sightings', { keyPath: 'id' });
sightStore.createIndex('by-item', 'itemId');
sightStore.createIndex('by-location', 'locationId');
sightStore.createIndex('by-timestamp', 'timestamp');
// Photos
const photoStore = db.createObjectStore('photos', { keyPath: 'id' });
photoStore.createIndex('by-item', 'itemId');
// Pre-generated IDs
const pregenStore = db.createObjectStore('preGeneratedIds', { keyPath: 'id' });
pregenStore.createIndex('by-batch', 'batchId');
pregenStore.createIndex('by-assigned', 'assignedTo');
// Settings
db.createObjectStore('settings', { keyPath: 'key' });
},
});
}
CRUD Functions:
Create src/lib/data/items.ts, locations.ts, sightings.ts, labels.ts with standard CRUD:
getAll(),getById(),create(),update(),remove()- Each function gets the DB instance via
getDB()and performs the operation.
ID Generator:
// src/lib/utils/id.ts
import { customAlphabet } from 'nanoid';
const ALPHABET = '23456789abcdefghjkmnpqrstuvwxyz';
export const generateItemId = customAlphabet(ALPHABET, 7);
export const generateSightingId = () => `s_${generateItemId()}`;
Task 3: Barcode Scanner Component
Create src/lib/components/Scanner.svelte.
Requirements:
- On native (Capacitor): use @capacitor-mlkit/barcode-scanning
- On web: use Barcode Detection API with ZXing fallback
- Detect QR Code, Code 128, DataMatrix, EAN-13
- Parse detected value through
parseItemId() - Haptic feedback on successful scan (Capacitor Haptics)
Implementation approach:
// src/lib/scanning/detector.ts
import { Capacitor } from '@capacitor/core';
import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning';
import { parseItemId } from './parser';
export async function scanBarcode(): Promise<string | null> {
if (Capacitor.isNativePlatform()) {
// Native: use ML Kit
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 id;
}
return null;
} else {
// Web: use Barcode Detection API
return scanWithWebAPI();
}
}
async function scanWithWebAPI(): Promise<string | null> {
if (!('BarcodeDetector' in window)) {
// Load ZXing fallback
// Use html5-qrcode library
return null;
}
const detector = new BarcodeDetector({
formats: ['qr_code', 'code_128', 'data_matrix'],
});
// Get camera stream
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
// Create video element, detect from frames
// ... (continuous detection loop at ~10fps)
return null;
}
ID Parsing:
// src/lib/scanning/parser.ts
const VALID_CHARS = /^[23456789a-hjkmnp-z]{7}$/;
export function parseItemId(rawValue: string): string | null {
// HTTPS URI
const httpsMatch = rawValue.match(/haus\.toph\.so\/([23456789a-hjkmnp-z]{7})$/);
if (httpsMatch) return httpsMatch[1];
// Custom scheme
const schemeMatch = rawValue.match(/^haus:\/\/([23456789a-hjkmnp-z]{7})$/);
if (schemeMatch) return schemeMatch[1];
// Raw ID
if (VALID_CHARS.test(rawValue)) return rawValue;
return null;
}
Task 4: Item Management
ItemForm Component (src/lib/components/ItemForm.svelte)
Fields: name, description, category, brand, serialNumber, color, purchaseDate, itemType, location, storageTier.
For consumable/perishable types, show additional fields: currentQuantity, originalQuantity, quantityUnit, lowThreshold, expiryDate.
Auto-generate shortId on create (or pre-fill if associating a pre-printed label).
ItemDetail Component (src/lib/components/ItemCard.svelte)
Show: name, category, custody badge, location, confidence badge, storage tier, sighting history. For consumables: quantity bar with percentage.
Actions: Check In/Out (contextual), Edit, Print Label, View History.
ItemList Component (src/routes/items/+page.svelte)
Filterable list: search, category filter, custody state filter, type filter. Sort by name, last seen, custody state.
Task 5: Location Management
LocationTree Component (src/lib/components/LocationTree.svelte)
Collapsible tree view using parentId relationships. Show item count per location. Tap to browse items in that location.
LocationPicker Component (src/lib/components/LocationPicker.svelte)
Used in forms and scan flows. Shows tree with radio selection. Quick-access "recent locations" at top.
Default Locations:
Pre-populate on first run:
const DEFAULT_LOCATIONS = [
{ id: 'home', name: 'Home', parentId: null, type: 'house' },
{ id: 'eg', name: 'Erdgeschoss', parentId: 'home', type: 'floor' },
{ id: 'og', name: 'Obergeschoss', parentId: 'home', type: 'floor' },
{ id: 'keller', name: 'Keller', parentId: 'home', type: 'floor' },
{ id: 'kueche', name: 'Küche', parentId: 'eg', type: 'room' },
{ id: 'wohnzimmer', name: 'Wohnzimmer', parentId: 'eg', type: 'room' },
{ id: 'flur', name: 'Flur', parentId: 'eg', type: 'room' },
{ id: 'schlafzimmer', name: 'Schlafzimmer', parentId: 'og', type: 'room' },
{ id: 'buero', name: 'Büro', parentId: 'og', type: 'room' },
{ id: 'bad', name: 'Bad', parentId: 'og', type: 'room' },
{ id: 'werkstatt', name: 'Werkstatt', parentId: 'keller', type: 'room' },
];
Task 6: Label Sheet Generation
Label Sheet PDF (src/lib/printing/labelSheet.ts)
Generate A4 PDF of QR code stickers:
import bwipjs from 'bwip-js';
import jsPDF from 'jspdf';
export async function generateLabelSheetPDF(ids: string[]): Promise<Blob> {
const doc = new jsPDF('p', 'mm', 'a4');
const COLS = 5;
const ROWS = 10;
const CELL_W = 38; // mm
const CELL_H = 27; // mm
const MARGIN_X = 8;
const MARGIN_Y = 10;
const QR_SIZE = 18; // mm
for (let i = 0; i < ids.length; i++) {
const col = i % COLS;
const row = Math.floor(i / COLS) % ROWS;
const page = Math.floor(i / (COLS * ROWS));
if (i > 0 && i % (COLS * ROWS) === 0) {
doc.addPage();
}
const x = MARGIN_X + col * CELL_W;
const y = MARGIN_Y + row * CELL_H;
// Generate QR code as PNG
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, QR_SIZE, QR_SIZE);
// ID text
doc.setFontSize(8);
doc.setFont('courier', 'normal');
doc.text(ids[i], x + QR_SIZE + 2, y + QR_SIZE / 2 + 1);
}
return doc.output('blob');
}
Print Server Client (src/lib/printing/printServer.ts)
export interface PrintServerConfig {
baseUrl: string; // e.g., "http://printer.local:3030"
}
export async function printLabels(
config: PrintServerConfig,
labels: { id: string; uri: string }[],
format: 'sheet' | 'strip' = 'sheet',
): Promise<void> {
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}`);
}
}
Task 7: App Shell & Navigation
Layout (src/routes/+layout.svelte)
<script lang="ts">
import BottomNav from '$lib/components/BottomNav.svelte';
import { page } from '$app/stores';
let { children } = $props();
</script>
<div class="flex flex-col h-screen bg-slate-900 text-white">
<main class="flex-1 overflow-y-auto pb-16">
{@render children()}
</main>
<BottomNav currentPath={$page.url.pathname} />
</div>
BottomNav (src/lib/components/BottomNav.svelte)
<script lang="ts">
let { currentPath = '/' } = $props();
const tabs = [
{ path: '/scan', icon: '📷', label: 'Scan' },
{ path: '/items', icon: '📦', label: 'Items' },
{ path: '/locations', icon: '📍', label: 'Places' },
{ path: '/labels', icon: '🏷️', label: 'Labels' },
{ path: '/settings', icon: '⋯', label: 'More' },
];
</script>
<nav class="fixed bottom-0 left-0 right-0 bg-slate-800 border-t border-slate-700 flex justify-around py-2 z-50">
{#each tabs as tab}
<a
href={tab.path}
class="flex flex-col items-center px-3 py-1 text-xs
{currentPath.startsWith(tab.path) ? 'text-blue-400' : 'text-slate-400'}"
>
<span class="text-xl">{tab.icon}</span>
<span>{tab.label}</span>
</a>
{/each}
</nav>
Task 8: State Management (Svelte 5 Runes)
Use Svelte 5 runes for reactive state. Create stores in src/lib/stores/:
// src/lib/stores/inventory.svelte.ts
import { getDB } from '$lib/data/db';
class InventoryStore {
items = $state<Item[]>([]);
locations = $state<Location[]>([]);
loading = $state(true);
checkedOutItems = $derived(
this.items.filter(i => i.custodyState === 'checked-out')
);
overdueItems = $derived(
this.items.filter(i =>
i.custodyState === 'checked-out' &&
i.checkedOutSince &&
Date.now() - new Date(i.checkedOutSince).getTime() > 7 * 86400000
)
);
lowStockItems = $derived(
this.items.filter(i =>
i.currentQuantity != null &&
i.lowThreshold != null &&
i.currentQuantity <= i.lowThreshold
)
);
async loadAll() {
const db = await getDB();
this.items = await db.getAll('items');
this.locations = await db.getAll('locations');
this.loading = false;
}
async createItem(item: Item) {
const db = await getDB();
await db.put('items', item);
this.items = [...this.items, item];
}
async updateItem(shortId: string, updates: Partial<Item>) {
const db = await getDB();
const existing = await db.get('items', shortId);
if (!existing) return;
const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() };
await db.put('items', updated);
this.items = this.items.map(i => i.shortId === shortId ? updated : i);
}
}
export const inventory = new InventoryStore();
Design Guidelines
Color Palette (Dark theme, slate-based):
bg-slate-900— main backgroundbg-slate-800— cards, surfacesbg-slate-700— hover, borderstext-blue-400— primary actionstext-emerald-400— confirmed, checked intext-amber-400— likely, warningtext-red-400— unknown, danger
Confidence Badges:
- Confirmed →
bg-emerald-500/20 text-emerald-400+ ✓ - Likely →
bg-amber-500/20 text-amber-400+ ◷ - Assumed →
bg-slate-500/20 text-slate-400+ ? - Unknown →
bg-red-500/20 text-red-400+ !
Mobile Considerations:
- Touch targets: min 44×44px
- Bottom sheet modals for scan results
- Haptic feedback on scan
- Skeleton loading states
- Monospace font for IDs:
font-mono text-lg tracking-wider
Testing Approach
Unit Tests:
- ID generation (uniqueness, format validation)
- ID parsing (HTTPS, haus://, raw)
- Confidence decay logic
- Quantity formatting
Integration Tests:
- IndexedDB CRUD operations
- Check-in/check-out state transitions
- Pre-generated ID → item association
E2E Tests (Playwright):
- Create item flow
- Scan → check out → check in cycle
- Generate label sheet
- Search and filter
Non-Functional Requirements
- First paint < 1s on mobile
- Scanner frame processing ≥ 10fps
- IndexedDB operations < 50ms
- Full offline capability (all features work without network)
- Multi-device sync via Automerge (eventual consistency)
- Accessible (ARIA labels, screen reader support)
Key Algorithms
Confidence Decay
export function getConfidence(lastSeenTimestamp: string | null): ConfidenceLevel {
if (!lastSeenTimestamp) return 'unknown';
const daysSince = differenceInDays(new Date(), new Date(lastSeenTimestamp));
if (daysSince <= 30) return 'confirmed';
if (daysSince <= 90) return 'likely';
if (daysSince <= 180) return 'assumed';
return 'unknown';
}
Sighting Processing
async function processSighting(itemId: string, locationId: string, type: SightingMethod) {
const now = new Date().toISOString();
await createSighting({ id: generateSightingId(), itemId, locationId, timestamp: now, sightingType: type, confidence: 'confirmed', notes: '' });
await updateItem(itemId, { lastSeenAt: locationId, lastSeenTimestamp: now, locationConfidence: 'confirmed', updatedAt: now });
}
Check-Out Processing
async function processCheckOut(itemId: string, reason: CheckOutReason, fromLocationId: string, toLocationId: string | null, note = '') {
const now = new Date().toISOString();
await updateItem(itemId, {
custodyState: 'checked-out',
checkedOutSince: now,
checkedOutReason: reason,
checkedOutFrom: fromLocationId,
checkedOutTo: toLocationId,
checkedOutNote: note,
lastSeenAt: toLocationId ?? fromLocationId,
lastSeenTimestamp: now,
locationConfidence: 'confirmed',
});
}
Check-In Processing
async function processCheckIn(itemId: string, returnToLocationId: string) {
const now = new Date().toISOString();
await updateItem(itemId, {
custodyState: 'checked-in',
checkedOutSince: null,
checkedOutReason: null,
checkedOutFrom: null,
checkedOutTo: null,
checkedOutNote: null,
lastSeenAt: returnToLocationId,
lastSeenTimestamp: now,
locationConfidence: 'confirmed',
supposedToBeAt: returnToLocationId,
});
}
Dashboard Queries
function getCheckedOutItems(): Item[] {
return allItems.filter(i => i.custodyState === 'checked-out');
}
function getOverdueItems(maxDays = 7): Item[] {
const cutoff = subDays(new Date(), maxDays);
return allItems.filter(i => i.custodyState === 'checked-out' && i.checkedOutSince && new Date(i.checkedOutSince) < cutoff);
}
function getLentItems(): Item[] {
return allItems.filter(i => i.custodyState === 'checked-out' && i.checkedOutReason === 'lent');
}
function getItemsInContainer(containerId: string): Item[] {
return allItems.filter(i => i.storageContainerId === containerId);
}
Forgejo PR Workflow
Every substantive session is tracked in .claude/request-log.jsonl and backed by a Forgejo PR.
When to create a branch + PR
Create a branch and PR for:
- New features (
feat/) - Bug fixes (
fix/) - Refactors (
refactor/) - Dependency or config changes (
chore/)
Skip branching/PR for:
- Questions, explanations, code reading
- Trivial one-liner fixes that are best committed directly to the current branch
Branch naming
feat/YYYYMMDD-short-kebab-description
fix/YYYYMMDD-short-kebab-description
chore/YYYYMMDD-short-kebab-description
refactor/YYYYMMDD-short-kebab-description
Check the current branch first:
git rev-parse --abbrev-ref HEAD
If already on a feature branch from this session, continue on it. Only create a new branch for a new distinct task.
Workflow for each task
-
Create branch (if needed):
git checkout -b feat/$(date +%Y%m%d)-short-description -
Do the work — write code, run tests, iterate.
-
Commit with conventional commit messages:
git add <files> git commit -m "feat: short description" -
Push:
git push -u origin HEAD -
Create PR:
bash .claude/scripts/create-pr.sh \ "feat: short description" \ "$(cat <<'EOF' ## Summary - What was done and why ## Test plan - [ ] Manual test steps 🤖 Generated with Claude Code EOF )"This also updates
.claude/request-log.jsonlwith the PR number. -
Commit the updated request log (as part of the PR or separately):
git add .claude/request-log.jsonl git commit -m "chore: log request #<req-id>"
Show history
When the user asks for history, recent work, or what's been done:
bash .claude/scripts/show-history.sh
bash .claude/scripts/show-history.sh --filter open
bash .claude/scripts/show-history.sh --last 10
tea CLI quick reference
tea pr list # list open PRs
tea pr view <number> # view a PR
tea pr merge <number> # merge a PR
tea issue create # create an issue
tea repo info # show repo details
tea login list # show configured Forgejo instances
Request log format
.claude/request-log.jsonl — one JSON object per line, git-tracked:
{
"id": "req_20260226_143012_a3f9b",
"timestamp": "2026-02-26T14:30:12Z",
"prompt": "Add dark mode support",
"session": "abc123",
"branch": "feat/20260226-dark-mode",
"pr_number": 42,
"pr_url": "https://forge.example.com/user/repo/pulls/42",
"status": "open"
}
Possible statuses: pending (no PR yet), open, merged, closed.