Object ID — Architecture & Developer Reference
Object ID is a mobile-first web app for a solo fine art appraiser (Kathy Poppers, ASA) to capture object identification data and photographs on-site, then edit and export at her desk.
Single user. No frameworks. No build step. Every decision favors reliability and simplicity over engineering ambition — the app is deployed on shared hosting and must work on a iPhone over cellular.
The primary UX constraint: Kathy previously lost 20 images and object data from a Word mobile app failure. Upload confirmation integrity — the green checkmark — is the most important UI element in the app.
| Layer | Technology | Notes |
|---|---|---|
| Backend | PHP 8+ | Vanilla, no framework |
| Database | SQLite (WAL mode) | Single file in /data/ |
| Frontend | Vanilla HTML + CSS + JS | No npm, no build step |
| Image processing | GD library | Built into PHP on shared hosts |
| Hosting | Namecheap Stellar Business | cPanel, shared, PHP-FPM |
| Auth | Single-user bcrypt session | 8-hour timeout |
PHP's built-in server is all you need:
cd "/path/to/OID Cap App" php -S localhost:8080 index.php
The router in index.php checks php_sapi_name() === 'cli-server' and serves static files directly — no .htaccess rewriting needed locally.
Login: username kathy, password objectid2026.
The SQLite database is created automatically on first run at data/objectid.sqlite. The uploads/ directory must be writable.
All requests route through index.php. No framework, no middleware stack — just a match statement.
config.php provides the DB singleton, auth helpers, CSRF token, and field config. DB is initialized (or migrated) on every connection.X-Content-Type-Options, X-Frame-Options: DENY, CSP (default-src 'self' with allowances for inline scripts/styles, blob: and data: for images)./api/* require auth, then dispatch to the appropriate api/*.php file which reads the request, operates on the DB, and returns JSON./login and /guide/*), then dispatch to the matching views/*.php template which renders a full HTML page.All API endpoints return JSON with a consistent envelope:
{ "success": true, "data": { ... } } // success
{ "success": false, "error": "message" } // failure
HTTP status codes are set appropriately (200, 201, 400, 401, 403, 404, 405, 409, 500).
One SQLite file. Eight tables. Schema lives in config.php — initialized on first run, migrated forward on every connection.
| Table | Purpose |
|---|---|
clients | Name, abbreviation (used in filenames), appraisal type |
objects | Art piece metadata — 19 fields (artist, title, medium, dimensions, condition, identification info, valuation info, literary citations, printer, publisher, etc.) |
photos | Uploaded images with EXIF data (GPS, timestamp), linked to object via CASCADE delete |
client_field_preferences | Per-client toggle: which fields are visible, above-fold, big-input, and their sort order |
comparables | Market comparable records (sale price, date, seller, lot number, etc.), linked to object |
comparable_photos | Photos for comparable records |
comparable_pdfs | PDF attachments for comparables |
time_entries | Time tracking entries — start/stop times, client, description, project (see Time Tracker Subsystem) |
initializeSchema(PDO $db) creates all tables and indexes with CREATE TABLE IF NOT EXISTS — safe to call on any existing DB.
migrateSchema(PDO $db) runs on every connection. Each migration is an ALTER TABLE ADD COLUMN wrapped in a try/catch — if the column already exists SQLite throws an error which is silently ignored, making every migration idempotent.
The same new-table CREATE TABLE IF NOT EXISTS statements also appear in migrateSchema() so databases that existed before a feature was added get the new tables automatically.
PRAGMA foreign_keys = ON — enforced on every connection.PRAGMA journal_mode = WAL — better concurrent read/write; falls back silently if unsupported.
The most critical subsystem. The green checkmark may only appear after all four conditions are met: file validated, file written, DB record committed, API returned success.
<input type="file" accept="image/*" capture="environment"> opens the iPhone camera. User selects a shot type, upload starts via fetch() + FormData.PHOTO_MAX_UPLOAD_SIZE (15 MB), detects MIME type server-side (not by extension). HEIC/HEIF is rejected immediately with a user-readable error and recovery instructions.DateTimeOriginal, and orientation are read before any processing. Orientation is corrected so photos display right-side-up regardless of how the phone was held.photo_number is calculated, canonical filename is generated.uploads/{client_abbr}/{item_number}/.{ success: true, photo_id, canonical_filename }.Pattern: {CLIENT}_{ITEM}_{PHOTO}_{TYPE}.jpg
WAITT_003_02_verso.jpg PEMB_001_01_overall.jpg
clients.client_abbreviationoverall | signature | condition | verso | detail | otherBillable time tracking per client. Designed as a portable kit — the core logic and API have no coupling to this app's specific UI.
time_entries| id | INTEGER PK | |
| client_id | INTEGER | REFERENCES clients(id) ON DELETE SET NULL — entries survive client deletion |
| started_at | TEXT NOT NULL | ISO 8601 UTC, e.g. 2026-05-22T14:30:00Z |
| stopped_at | TEXT | NULL = currently running. Only one running row permitted at a time (enforced in API). |
| description | TEXT DEFAULT '' | Freeform; editable while running |
| project | TEXT DEFAULT '' | Optional freeform project name; autocomplete draws from past entries for the same client |
| idle_stopped | INTEGER DEFAULT 0 | 1 if stopped by idle detection (reserved for future reporting) |
| created_at / updated_at | TEXT | updated_at set on every PUT |
api/time-entries.php| Method | Params | Behavior |
|---|---|---|
| GET | ?running=1 | Return the active entry + client_name/abbreviation. Used by nav bar on every page load. |
| GET | ?projects=1&client_id=X | Return distinct project names for a client (autocomplete datalist). |
| GET | ?client_id=X&from=&to= | List entries. Defaults to last 30 days. client_id is optional. |
| POST | action=start | Start timer. Fails with 409 if one already running. Accepts client_id, project, description. |
| POST | action=stop | Stop running timer. Optional stopped_at for idle/recovery stops at a past time. |
| POST | action=create | Manual entry. Requires client_id, started_at, stopped_at. |
| POST | action=discard | Delete running timer without saving (for overnight recovery "Discard" option). |
| PUT | id + fields | Edit any field on any entry. Body is URL-encoded. |
| DELETE | ?id=X | Delete an entry. |
The timer JS is split into two files with a clear boundary:
assets/js/timer.js — portable core, no DOM coupling. Can be dropped into any project that has App.api(). Provides:
formatElapsed(seconds) → "1:23:45", formatDuration(seconds) → "1h 23m"elapsedSince(iso), durationBetween(start, stop), dateKey(iso), formatTime(iso), formatDate(iso)saveState(entry), clearState(), getState() — key oid-timer-statestart(), stop(), discard(), createManual(), update(), deleteEntry(), fetchRunning(), fetchEntries(), fetchProjects()assets/js/timer-nav.js — nav bar DOM + idle detection. IIFE. Renders #timer-nav in the nav bar, polls the API on page load, and manages the idle/recovery lifecycle:
renderRunning(entry) — green dot + ticking elapsed + client abbreviationrenderIdle() — subtle clock circlemousemove / keydown / touchstart / scroll / click throttled to once per 30s. After 5 minutes of inactivity, shows a non-blocking banner offering to stop at lastActivityAt.lastActivityAt is written to localStorage on beforeunload so it survives tab close.window.TimerNav for the timer page to call after start/stop.Source of truth: DB. stopped_at IS NULL = running. One running row at a time, enforced in API.
localStorage supplement — stores {id, started_at, client_id, client_name, client_abbreviation, project}. On page load, the nav bar renders immediately from localStorage (no flash), then confirms with the API. If the API says nothing is running but localStorage says yes, localStorage is cleared (stale from a crash or double-tab).
The elapsed counter uses Date.now() - Date.parse(started_at) computed each second — no drift, no adjustment needed.
timer.js and timer-nav.js are included in every authenticated view right after app.js. They are not in layout.php because layout only outputs the head and nav, while scripts are loaded at the bottom of each view's body.
Single user. Password is stored as a bcrypt hash in config.php under AUTH_PASSWORD_HASH. Generate with:
php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT);"
Session cookie is Secure, HttpOnly, SameSite=Strict. Sessions expire after 8 hours of inactivity (SESSION_TIMEOUT constant).
Every state-changing request (POST, PUT, DELETE) requires a CSRF token, validated via validateCsrf() in config.php. The token is embedded in the page as a <meta name="csrf-token"> tag and sent either in the request body as csrf_token or as the X-CSRF-TOKEN header (used for XHR and PUT requests).
Set in index.php on every response:
X-Content-Type-Options: nosniff X-Frame-Options: DENY Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:
unsafe-inline is required because styles and scripts are in <style>/<script> blocks within views (no separate nonce). data: in img-src is needed for the CSS background-image SVG used for select dropdown arrows.
The /data/ directory (containing the SQLite file) is blocked by an .htaccess rewrite rule. If the database is above the webroot this rule is redundant but harmless. Never link to or serve data/objectid.sqlite directly.
Apple design language adapted for a forms-and-lists utility app. All tokens are CSS custom properties on :root in app.css. No hardcoded values anywhere in component CSS.
| Decision | Value | Reason |
|---|---|---|
| Body font size | 17px | Apple standard (not 16px) |
| Font weights | 400 (body), 600 (headlines) | Apple skips 500 — no medium weight anywhere |
| Touch target | 44px min-height | Apple HIG minimum |
| Card shadows | None by default | Apple uses shadows sparingly |
| Button shape | Pill (border-radius: 9999px) | Apple pill CTAs |
| Press state | transform: scale(0.97) | Apple tactile feedback pattern |
| Capture mode bg | #f5f5f7 | Visually distinct from data-entry forms (white) |
| Accent | #0066cc | Apple Action Blue |
| Success / checkmark | #34c759 | Apple system green |
Instead of relying on @media (prefers-reduced-motion), the app uses a body.reduce-motion CSS class toggled via a setting stored in localStorage (key: oid-reduce-motion). The class is applied in app.js before first render. This gives Kathy explicit control regardless of her OS setting.
A @media print block in app.css hides the nav, bottom bar, action buttons, lightbox, and timer UI. Object detail pages print in single-column format at 11pt with no transitions or shadows.
Plain text with field labels matching Kathy's report style. Empty fields are omitted. Ends with a photo filename list. Generated by api/export.php via action=object_text.
All objects concatenated with --- separators and a client/date header. Designed for pasting into ChatGPT or any LLM. Generated via action=client_text.
Generated on-the-fly via ZipArchive, streamed directly — not written to disk first. Contains:
photos/ — all object photosdata/ — one markdown file per object, named {num}_{artist}_{title}.md{Client}_all_objects.md — combined markdown{Client}_objects.csv — spreadsheet-ready, all fields as columnsFull backup ZIP containing the SQLite database file and all uploads. Generated via action=backup. Intended for off-site archiving after each job.
Path-based, no framework. Client and object IDs are always numeric in URLs — never client names or titles. The match(true) block in index.php uses preg_match() for parameterized routes and strict string equality for static routes.
MAX(item_number) + 1 at insert time), zero-padded to 3 digits in filenamesgetDefaultFieldConfig() in config.php defines all 19 object fields with their default visibility/fold/input-size settings. getFieldConfig(PDO, clientId) merges client-specific overrides from client_field_preferences, assigns sort order, and returns the sorted array. Views use this to render only the relevant fields in the right order.
Conventional commits: feat:, fix:, docs:, refactor:, chore:. QA fixes use fix(qa): ISSUE-NNN — description.
No CDNs, no npm packages, no Google Fonts. The app is entirely self-contained so it works offline after first load and deploys as a flat file copy.
Use find to create a clean ZIP excluding git, dev docs, the database, and uploads:
find . \ -not -path "./.git/*" \ -not -path "./data/*.sqlite*" \ -not -path "./uploads/*" \ -not -path "./.claude/*" \ -not -path "./artifacts/*" \ -not -name "CLAUDE.md" \ -not -name ".DS_Store" \ -type f | zip /tmp/ObjectID_deploy.zip -@
A pre-built package is kept in artifacts/ObjectID_deploy_{YYYY-MM-DD}.zip.
Namecheap Stellar Business (cPanel). PHP-FPM/CGI — php_value directives in .htaccess do not work. Use .user.ini instead:
upload_max_filesize=15M post_max_size=20M max_execution_time=60
data/ directory outside the webroot if possible, or ensure it's blocked by the .htaccess rewrite rule.uploads/ writable by PHP (chmod 755 or via cPanel File Manager).AUTH_PASSWORD_HASH in config.php with a production bcrypt hash.Upload changed files over FTP/SFTP. The migrateSchema() function handles any schema changes automatically on the next request — no manual SQL needed.