← Quick Start Guide
</>

Technical Guide

Object ID — Architecture & Developer Reference

Overview

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.

Stack

LayerTechnologyNotes
BackendPHP 8+Vanilla, no framework
DatabaseSQLite (WAL mode)Single file in /data/
FrontendVanilla HTML + CSS + JSNo npm, no build step
Image processingGD libraryBuilt into PHP on shared hosts
HostingNamecheap Stellar BusinesscPanel, shared, PHP-FPM
AuthSingle-user bcrypt session8-hour timeout
Not available on this host
Node.js, Python, Puppeteer, Docker, Composer (likely). Don't add dependencies that require them.

Local Development

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.

Architecture

All requests route through index.php. No framework, no middleware stack — just a match statement.

Request Flow

  1. Static file check — built-in server only. If the path resolves to a real file, serve it directly and return.
  2. Load configconfig.php provides the DB singleton, auth helpers, CSRF token, and field config. DB is initialized (or migrated) on every connection.
  3. Security headersX-Content-Type-Options, X-Frame-Options: DENY, CSP (default-src 'self' with allowances for inline scripts/styles, blob: and data: for images).
  4. API routes — paths matching /api/* require auth, then dispatch to the appropriate api/*.php file which reads the request, operates on the DB, and returns JSON.
  5. View routes — all other paths require auth (except /login and /guide/*), then dispatch to the matching views/*.php template which renders a full HTML page.

API Response Shape

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).

File Layout

index.php ← Router + security headers config.php ← DB singleton, auth, CSRF, field config, migrations .htaccess ← HTTPS redirect, rewrite rules, /data/ block .user.ini ← PHP limits for shared hosting (upload size, timeout) manifest.json ← PWA manifest for "Add to Home Screen" api/ auth.php ← Login / logout / session check clients.php ← Client CRUD objects.php ← Object CRUD (19 fields) photos.php ← Photo upload / replace / delete (transactional) _photo-helpers.php ← EXIF extraction, GD resize, HEIC detection export.php ← ZIP + clipboard export (object text, CSV, backup) field-preferences.php ← Per-client field visibility + order comparables.php ← Comparable sale records comparable-photos.php ← Photos for comparables comparable-pdfs.php ← PDF attachments for comparables time-entries.php ← Time tracking: start/stop/edit/delete/list views/ layout.php ← HTML shell: doctype, head, nav bar (incl. timer), scripts login.php clients.php ← Client list (home) client-detail.php ← Object list for a client object-form.php ← Create/edit object (shared template) object-detail.php ← View object + photo grid + comparables capture.php ← Mobile photo capture mode settings.php ← App settings (reduce-motion toggle) field-settings.php ← Per-client field config with drag reorder comparable-form.php comparable-detail.php time-entries.php ← Timer page: start/stop, entry list, manual entry assets/ css/app.css ← Single stylesheet, all design tokens as CSS custom properties js/app.js ← Global: App.api(), App.toast(), App.confirm(), CSRF, session js/capture.js ← Camera access, upload with confirmation js/export.js ← Clipboard copy, ZIP trigger js/timer.js ← Portable timer core: formatting, elapsed, localStorage, API calls js/timer-nav.js ← Nav bar DOM: running/idle states, idle detection, recovery dialog data/ objectid.sqlite ← Database (gitignored; created on first run) .htaccess ← Deny all — prevents direct HTTP access to the DB uploads/ ← Photo storage: {client_abbr}/{item_number}/ guide/ ← Static HTML docs (this file) seed/ ← Dev utilities: demo data seeder, deploy packer

Database

One SQLite file. Eight tables. Schema lives in config.php — initialized on first run, migrated forward on every connection.

Tables

TablePurpose
clientsName, abbreviation (used in filenames), appraisal type
objectsArt piece metadata — 19 fields (artist, title, medium, dimensions, condition, identification info, valuation info, literary citations, printer, publisher, etc.)
photosUploaded images with EXIF data (GPS, timestamp), linked to object via CASCADE delete
client_field_preferencesPer-client toggle: which fields are visible, above-fold, big-input, and their sort order
comparablesMarket comparable records (sale price, date, seller, lot number, etc.), linked to object
comparable_photosPhotos for comparable records
comparable_pdfsPDF attachments for comparables
time_entriesTime tracking entries — start/stop times, client, description, project (see Time Tracker Subsystem)

Initialization & Migrations

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.

SQLite pragmas
PRAGMA foreign_keys = ON — enforced on every connection.
PRAGMA journal_mode = WAL — better concurrent read/write; falls back silently if unsupported.

Photo Upload Pipeline

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.

Upload Flow

  1. Client<input type="file" accept="image/*" capture="environment"> opens the iPhone camera. User selects a shot type, upload starts via fetch() + FormData.
  2. Validate — server checks file size against 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.
  3. EXIF extraction — GPS coordinates, DateTimeOriginal, and orientation are read before any processing. Orientation is corrected so photos display right-side-up regardless of how the phone was held.
  4. GD processing — resized to 5000px long edge, output as JPEG at 85% quality.
  5. Transaction opens — next photo_number is calculated, canonical filename is generated.
  6. File saved to uploads/{client_abbr}/{item_number}/.
  7. DB record inserted, transaction commits. If either step 6 or 7 fails, the other is rolled back. Server returns { success: true, photo_id, canonical_filename }.

Canonical Filename

Pattern: {CLIENT}_{ITEM}_{PHOTO}_{TYPE}.jpg

WAITT_003_02_verso.jpg
PEMB_001_01_overall.jpg

Time Tracker Subsystem

Billable time tracking per client. Designed as a portable kit — the core logic and API have no coupling to this app's specific UI.

Schema — time_entries

idINTEGER PK
client_idINTEGERREFERENCES clients(id) ON DELETE SET NULL — entries survive client deletion
started_atTEXT NOT NULLISO 8601 UTC, e.g. 2026-05-22T14:30:00Z
stopped_atTEXTNULL = currently running. Only one running row permitted at a time (enforced in API).
descriptionTEXT DEFAULT ''Freeform; editable while running
projectTEXT DEFAULT ''Optional freeform project name; autocomplete draws from past entries for the same client
idle_stoppedINTEGER DEFAULT 01 if stopped by idle detection (reserved for future reporting)
created_at / updated_atTEXTupdated_at set on every PUT

API — api/time-entries.php

MethodParamsBehavior
GET?running=1Return the active entry + client_name/abbreviation. Used by nav bar on every page load.
GET?projects=1&client_id=XReturn 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.
POSTaction=startStart timer. Fails with 409 if one already running. Accepts client_id, project, description.
POSTaction=stopStop running timer. Optional stopped_at for idle/recovery stops at a past time.
POSTaction=createManual entry. Requires client_id, started_at, stopped_at.
POSTaction=discardDelete running timer without saving (for overnight recovery "Discard" option).
PUTid + fieldsEdit any field on any entry. Body is URL-encoded.
DELETE?id=XDelete an entry.

JavaScript Modules

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:

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:

State Persistence

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.

Script Loading

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.

Authentication & Security

Authentication

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).

Security Headers

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.

Data Directory

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.

Design System

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.

Key Decisions

DecisionValueReason
Body font size17pxApple standard (not 16px)
Font weights400 (body), 600 (headlines)Apple skips 500 — no medium weight anywhere
Touch target44px min-heightApple HIG minimum
Card shadowsNone by defaultApple uses shadows sparingly
Button shapePill (border-radius: 9999px)Apple pill CTAs
Press statetransform: scale(0.97)Apple tactile feedback pattern
Capture mode bg#f5f5f7Visually distinct from data-entry forms (white)
Accent#0066ccApple Action Blue
Success / checkmark#34c759Apple system green

Reduce Motion

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.

Print Styles

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.

Export Formats

Copy to Clipboard (per object)

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.

Copy All for AI (per client)

All objects concatenated with --- separators and a client/date header. Designed for pasting into ChatGPT or any LLM. Generated via action=client_text.

ZIP Download (per client)

Generated on-the-fly via ZipArchive, streamed directly — not written to disk first. Contains:

Backup

Full backup ZIP containing the SQLite database file and all uploads. Generated via action=backup. Intended for off-site archiving after each job.

Conventions

Routing

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.

Numbering

Field Configuration

getDefaultFieldConfig() 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.

Commit Style

Conventional commits: feat:, fix:, docs:, refactor:, chore:. QA fixes use fix(qa): ISSUE-NNN — description.

No External Dependencies

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.

Deployment

Build the Deployment Package

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.

Hosting Configuration

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

First Deploy

  1. Upload the ZIP contents to the webroot (or a subdirectory).
  2. Create a data/ directory outside the webroot if possible, or ensure it's blocked by the .htaccess rewrite rule.
  3. Make uploads/ writable by PHP (chmod 755 or via cPanel File Manager).
  4. Update AUTH_PASSWORD_HASH in config.php with a production bcrypt hash.
  5. Load the app — the database and all tables are created automatically on first request.

Updates

Upload changed files over FTP/SFTP. The migrateSchema() function handles any schema changes automatically on the next request — no manual SQL needed.