Covers all 7 nav sections with layout descriptions, all 11 dialog types, recurring widget patterns (split pane, tabbed view, table, gallery, card, field editor, modal dialog, non-modal overlay, full-page sub-view, canvas, lightbox) with every instance listed, complete template inventory (41 entries), and a primitive taxonomy for a future higher-level UI representation. Linked from CLAUDE.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
14 KiB
CLAUDE.md — Electronics Inventory
Agent orientation file. Read this before touching code.
What this project is
A self-hosted electronics inventory web app. Tracks components, PDFs/datasheets, physical storage grids (photographed and de-perspectived), and bins. Single-user, no auth. Node.js + Express 5 backend, vanilla JS SPA frontend, flat NDJSON key-value store for persistence.
File map
server.mjs Entry point. All Express routes. ~940 lines.
lib/
storage.mjs KV store CRUD wrappers (one section per entity type).
Read this to understand what data exists and its prefix.
kv-store.mjs Flat NDJSON key-value store (Simple_KeyValue_Store class).
Auto-loads on construct, auto-flushes with debounce.
grid-image.mjs Image processing: de-perspective a photo into a grid of
cell images using sharp. compute_bin_size() lives here.
ids.mjs generate_id() — timestamp-base36 + random suffix.
(Planned: migrate to sequential integers.)
public/
app.mjs SPA. All rendering, routing, dialog logic. ~2600 lines.
Sections separated by // --- comments.
lib/api.mjs All fetch wrappers. Read this for the full API surface.
lib/dom.mjs Tiny DOM helpers: qs(), clone(), show(), hide().
views/grid-setup.mjs Grid_Setup class — canvas corner editor with pan/zoom.
Used for both grid source images and bin corner editing.
templates.html All HTML templates (id="t-*"). Injected into body at init.
style.css All styles. Single file (planned: split per section).
index.html Shell. Loads app.mjs as module. Nav buttons hardcoded.
tools/
mv-sync.c / mv-sync renameat2(RENAME_NOREPLACE) binary for atomic rename
without overwrite. Used by settle_image_filename().
Makefile Builds mv-sync.
data/
inventory.ndjson The database. All entities in one flat KV file.
images/ All uploaded and processed images.
pdfs/ Uploaded PDF files.
thumbs/ PDF thumbnails (generated by pdftoppm).
KV store — key prefixes
| Prefix | Entity | Storage function family |
|---|---|---|
f: |
Field definitions | get_field / set_field |
c: |
Components | get_component / set_component |
i: |
Inventory entries | get_inventory_entry / set_inventory_entry |
d: |
Grid drafts | get_grid_draft / set_grid_draft |
s: |
Source images | get_source_image / add_source_image |
g: |
Grid images | get_grid_image / set_grid_image |
ct: |
Component templates | get_component_template / set_component_template |
pdf: |
PDF files | get_pdf / set_pdf |
bt: |
Bin types | get_bin_type / set_bin_type |
bin: |
Bins | get_bin / set_bin |
All list_*() functions do a full-scan startsWith(prefix) over the store.
Data model shapes
Field definition (f:)
{
id: string, // generate_id()
name: string, // e.g. 'resistance'
unit: string, // e.g. 'Ω' — optional
description: string,
created_at: number, // ms timestamp
}
Component (c:)
{
id: string,
name: string,
description: string,
fields: { [field_id]: string }, // values keyed by field definition id
images: string[], // filenames in data/images/
file_ids: string[], // linked PDF ids
created_at: number,
updated_at: number,
}
Inventory entry (i:)
{
id: string,
component_id: string,
location_type: 'physical' | 'bom' | 'digital' | 'grid',
location_ref: string, // free text for physical/bom/digital
quantity: string,
notes: string,
grid_id: string | null, // set when location_type === 'grid'
grid_row: number | null,
grid_col: number | null,
images: string[],
created_at: number,
updated_at: number,
}
Source image (s:)
{
id: string, // filename in data/images/ (used as key too)
original_name: string,
width: number,
height: number,
uses: ('grid' | 'bin')[], // which features reference this image
created_at: number,
}
Grid draft (d:)
{
id: string,
source_id: string, // source image filename
rows: number,
cols: number,
corners: [{x,y}, {x,y}, {x,y}, {x,y}], // TL, TR, BR, BL in image coords
created_at: number,
updated_at: number,
}
Grid image (g:) — result of processing a grid draft
{
id: string,
source_id: string,
rows: number,
cols: number,
corners: [{x,y}, ...],
panels: [[{ filename, component_id?, notes? }, ...], ...], // [row][col]
created_at: number,
updated_at: number,
}
Component template (ct:)
{
id: string,
name: string,
formatter: string, // JS function body string, compiled at runtime
created_at: number,
updated_at: number,
}
PDF (pdf:)
{
id: string,
display_name: string,
filename: string, // in data/pdfs/
thumb_prefix: string, // in data/thumbs/ — pdftoppm output prefix
created_at: number,
}
Bin type (bt:)
{
id: string,
name: string,
phys_w: number, // mm
phys_h: number, // mm
description: string,
fields: { [field_id]: string },
created_at: number,
updated_at: number,
}
Bin (bin:)
{
id: string,
name: string,
type_id: string | null, // ref to bin type
source_id: string, // source image filename (always kept)
source_w: number,
source_h: number,
corners: [{x,y}, {x,y}, {x,y}, {x,y}], // TL, TR, BR, BL in image coords
phys_w: number | null, // mm — null means infer from corners
phys_h: number | null,
image_filename: string | null, // processed output in data/images/; null if not yet processed
bin_w: number | null, // px dimensions of processed output
bin_h: number | null,
fields: { [field_id]: string },
contents: [ // embedded content items
{
id: string,
type: 'component' | 'item',
component_id: string | null, // set when type === 'component'
name: string | null, // set when type === 'item'
quantity: string,
notes: string,
created_at: number,
}
],
created_at: number,
updated_at: number,
}
API routes (server.mjs)
All responses: { ok: true, ...data } or { ok: false, error: string }.
GET /api/fields
POST /api/fields body: { name, unit?, description? }
PUT /api/fields/:id
DELETE /api/fields/:id
GET /api/components
POST /api/components body: { name, description?, fields? }
GET /api/components/:id
PUT /api/components/:id body: { name?, description?, fields?, file_ids? }
DELETE /api/components/:id
POST /api/components/:id/images multipart: images[]
DELETE /api/components/:id/images/:img_id
GET /api/inventory
POST /api/inventory body: { component_id, location_type, ... }
PUT /api/inventory/:id
DELETE /api/inventory/:id
POST /api/inventory/:id/images multipart: images[]
DELETE /api/inventory/:id/images/:img_id
GET /api/grid-drafts
POST /api/grid-drafts body: { source_id, rows, cols }
PUT /api/grid-drafts/:id
DELETE /api/grid-drafts/:id
GET /api/source-images
POST /api/source-images multipart: images[] — creates source records (uses: ['grid'])
PUT /api/source-images/:id body: { uses }
DELETE /api/source-images/:id guarded: refused if any grid/bin still references it
GET /api/grid-images
GET /api/grid-images/:id
POST /api/grid-images body: { draft_id } — processes draft → grid image
PUT /api/grid-images/:id/panels/:pi body: { component_id?, notes? }
DELETE /api/grid-images/:id
GET /api/component-templates
POST /api/component-templates body: { name, formatter }
PUT /api/component-templates/:id
DELETE /api/component-templates/:id
GET /api/pdfs
POST /api/pdfs multipart: file, display_name, filename
PUT /api/pdfs/:id body: { display_name, filename }
DELETE /api/pdfs/:id guarded: refused if any component references it
GET /api/bin-types
POST /api/bin-types body: { name, phys_w, phys_h, description?, fields? }
PUT /api/bin-types/:id
DELETE /api/bin-types/:id guarded: refused if any bin references it
GET /api/bins
GET /api/bins/:id
POST /api/bins multipart: image, name?, type_id? — upload + create
POST /api/bins/from-source body: { source_id, name?, type_id? }
PUT /api/bins/:id body: { name?, type_id?, fields? }
PUT /api/bins/:id/corners body: { corners, phys_w?, phys_h? } — triggers reprocess
DELETE /api/bins/:id only deletes processed image_filename, not source
POST /api/bins/:id/contents body: { type, component_id?, name?, quantity, notes }
PUT /api/bins/:id/contents/:cid body: { quantity?, notes?, name? }
DELETE /api/bins/:id/contents/:cid
POST /api/maintenance/purge-missing-sources removes source KV entries whose files are gone
POST /api/maintenance/pdf-thumbs regenerates missing PDF thumbnails
Frontend (app.mjs) structure
Module-level state variables at the top (all_components, all_fields, etc.).
All loaded once at startup via parallel API calls, mutated in place on changes.
Key patterns:
clone('t-template-id')— clones a<template>into a live elementqs(el, '#id')— scoped querySelector- Dialog callbacks stored as module-level
let x_dialog_callback = null, set by the open function, called by the init-registered submit/save handler render()— top-level re-render, called after navigation or data changesnavigate(path)— pushes history + calls render()build_field_editor(rows_el, sel_el, new_btn_el, initial_fields)— shared helper that wires up field row editing; returns{ get_fields() }
Section layout (by line range, approximate):
1–90 Imports, state vars, startup data load
91–250 Helper functions (field rendering, search, formatters)
251–700 Components section (list, detail panel, edit dialog)
701–1000 Inventory section
1001–1600 Grids section (list, draft editor, grid viewer)
1601–1900 Component templates, field definitions dialogs
1901–2000 Images admin section
2001–2450 Bins section (list, source list, types list, bin editor)
2450–2500 Routing (parse_url, navigate, render)
2500–end init() — dialog injection, event handler registration
Image file lifecycle
Upload → multer writes temp file to data/images/<generate_id()><ext>
→ settle_image_filename() renames to original filename using
rename_no_replace (atomic, no overwrite); falls back to temp name
→ source image KV record created with id = final filename
Grid processing:
source image + corners + rows/cols → sharp perspective transform
→ one cell image per panel → filenames stored in grid_image.panels[r][c]
Bin processing:
source image + corners → single de-perspectived image
→ stored as bin.image_filename (separate from bin.source_id)
→ source_id is never deleted when bin is deleted; only image_filename is
Code style (owner preferences)
- Indentation: tabs, display width 4
- Braces: always, even single-line bodies —
if (x) { return; } - Naming:
lower_snek_casefunctions/locals/singletons,CAPITAL_SNEK_CASEtop-level constants,Title_Snek_Caseclasses - Quotes: single quotes preferred
- Modules:
.mjsextension always - State: stateful components must be classes; no bare module-level variables for app state (module scope is for constants and exports only)
- IDs: prefer sequential integers over timestamp+random (migration pending)
- No CDN URLs in HTML; vendor libs via make build from node_modules
- Error handling: try/catch ENOENT, never existsSync-then-read (TOCTOU)
- No over-engineering: don't add helpers, abstractions, or error handling for scenarios that can't happen; three similar lines beats a premature helper
Git conventions (this project)
git config user.name 'mikael-lovqvists-claude-agent'
git config user.email 'mikaels.claude.agent@efforting.tech'
- Small, logical commits — one concern per commit
- Stage files explicitly by name, never
git add -Aorgit add . - Always
git statusand read every line before staging - Commit message: imperative mood, explain why not what
UI structure
See ui-structure.md for the full inventory of sections,
widget patterns, dialogs, templates, and a taxonomy of primitives relevant to
designing a higher-level UI representation.
Known limitations / planned rewrite notes
See future-plans.md for full detail. Key points relevant
to coding decisions:
app.mjswill be split into per-section view modules- KV store will become hierarchical (no more prefix convention)
- IDs will migrate to sequential integers
- CSS will be split per section with a build-step concatenation
- SSE for live updates across devices
- Generic fields on all entity types (already done for components, bins, bin types)
- A complete rewrite is planned; this codebase is the learning prototype