Data Model
Every type the app persists is a plain TypeScript interface in src/types.ts, serialized to
JSON in one localStorage key. There is no server and no database — the data model is the
persisted object. This page documents that object faithfully against the source; for how it is
loaded and seeded see Architecture, and for the constant catalog see
Settings & Constants.
Persisted shape
src/storage.ts reads and writes a single key, signquote.v1, holding one PersistedState.
// src/types.ts
export interface PersistedState {
settings: Settings
quotes: Quote[]
meta: { schemaVersion: number; nextQuoteNumber: number }
}settings— the shop’s single global “rate card” (see Settings).quotes— every saved quote, newest appended.meta.schemaVersion— currently1. On load,loadState()validatesparsed.meta.schemaVersion === 1(plus an arrayquotesand a presentsettings); anything else — corrupt JSON, a stale version, unavailable storage — falls through to a fresh reseed.meta.nextQuoteNumber— the next human-facing quote number to hand out. The seed state ships with two quotes (1001,1002) andnextQuoteNumber: 1003.
Storage never throws to the UI. saveState swallows write failures (private-mode quota, etc.),
and loadState swallows parse/validation failures by reseeding. The store is therefore never
empty on first run.
Quote
export interface Quote {
id: string
number: number // 1001, 1002, ...
createdAt: string // ISO
customer: Customer
config: SignConfig
pricing: PricingResult // snapshot; recomputed on every config change and on open
pdfMode: 'itemized' | 'total_only'
}A Quote bundles who (customer), what (config — the Table A snapshot), and how
much (pricing). The id is a stable internal key (the seeds use seed-cafe-1001 /
seed-law-1002); number is the 1001+ value shown to people.
pricing is a recomputed snapshot, not stored truth. It is the output of
computePricing(config, settings) and is regenerated on every config change and on open — see the
relations note below.
export interface Customer {
name: string // required before PDF
company?: string
email?: string
phone?: string
jobAddress?: string
}Only name is required, and only at the point of producing a PDF.
SignConfig
SignConfig is a discriminated union keyed on productCategory. Both variants share a
BaseSignConfig, then add fields specific to their family.
export type SignConfig = ChannelLetterConfig | DimensionalLetterConfig
export type ProductCategory = 'channel_letter' | 'dimensional_letter'Shared base fields
| Field | Type | Notes |
|---|---|---|
signTextLabel | string | Display-only, never priced [A1] |
letterCount | number | Integer 1–100 |
letterHeight | number | Inches; CL 6–60, DL 6–24 [A2] |
wallSurface | WallSurface | See enum below |
installHeightAccess | InstallAccess | See enum below |
permitRequired | boolean | |
engineeringRequired | boolean | |
markupMultiplier | number | 1.5–3.0, step 0.05; CL cost-plus path / DL dealer markup |
Shared enums:
| Type | Allowed values |
|---|---|
WallSurface | 'drywall' | 'brick' | 'block' | 'EIFS' | 'stone' | 'metal' | 'concrete' |
InstallAccess | 'ground_ladder' | 'bucket' | 'lift' | 'crane' |
ChannelLetterConfig
export interface ChannelLetterConfig extends BaseSignConfig {
productCategory: 'channel_letter'
illuminationType: IlluminationType
returnDepth: ReturnDepth
faceMaterial: FaceMaterial
ledProductId: string
mountType: ClMountType
}| Field | Type | Allowed values / notes |
|---|---|---|
illuminationType | IlluminationType | 'front_lit' | 'halo_reverse' |
returnDepth | ReturnDepth | 3 | 4 | 5 | 6 (inches) |
faceMaterial | FaceMaterial | 'white_acrylic_3_16' | 'colored_acrylic' — rendered only for front_lit; halo has no acrylic face |
ledProductId | string | References an id in Settings.ledProducts |
mountType | ClMountType | 'flush_stud' | 'raceway' |
DimensionalLetterConfig
export interface DimensionalLetterConfig extends BaseSignConfig {
productCategory: 'dimensional_letter'
dimMaterial: DimMaterial
dimFinish: DimFinish
dimMount: DimMount
}Dimensional letters are implicitly non-illuminated — there is no illumination field.
| Field | Type | Allowed values |
|---|---|---|
dimMaterial | DimMaterial | 'routed_pvc_quarter' | 'flat_acrylic_quarter' | 'flat_aluminum_quarter' | 'cast_aluminum' | 'cast_bronze' |
dimFinish | DimFinish | 'standard_included' | 'painted_custom_color' | 'polished' |
dimMount | DimMount | 'flush_stud' | 'spacer_standoff' | 'rail' |
PricingResult
pricing on a Quote is a PricingResult. The lineItems and total are common to both
families; the rest are family-specific and several are owner-only — they must never render in
the quote editor or the PDF (the role boundary, tested as AC-10).
export interface PricingResult {
lineItems: LineItem[]
total: number // full precision; round to whole dollars at display time [A18]
// Channel letters only:
fabPerInch?: number
fabCostPlus?: number
costSubtotal?: number // the "floor" — owner-only
marginPct?: number
badge?: MarginBadge
derived?: ClDerived
clBreakdown?: ClCostBreakdown // owner-only (Settings preview)
// Dimensional letters only:
dl?: DlBreakdown
}
export interface LineItem {
key: string // fabrication | supplied | install | permit | engineering | design | minimum
label: string
amount: number
}
export type MarginBadge = 'green' | 'amber' | 'red'| Field | Family | Owner-only? |
|---|---|---|
lineItems, total | both | no — these drive the editor and PDF |
fabPerInch, fabCostPlus | CL | no |
marginPct, badge | CL | no — the margin badge is the one cost-derived signal salespeople see |
costSubtotal | CL | yes — the cost “floor” |
clBreakdown | CL | yes — full decomposition, Settings preview only |
derived (ClDerived) | CL | mixed — derived.installHours is owner-only |
dl (DlBreakdown) | DL | mixed — dl.installHours is owner-only |
costSubtotal, clBreakdown, derived.installHours, and dl.installHours are explicitly
flagged owner-only in src/types.ts. They live in the persisted snapshot but must only surface
on the Settings preview panel — never in the editor or the PDF. See AC-10.
Channel-letter detail shapes
export interface ClDerived {
modsPerLetter: number
jobModules: number
totalWatts: number
psCount: number
installHours: number // owner-facing only — never render in editor/PDF (AC-10)
}
export interface ClCostBreakdown {
perLetter: {
coil: number
face: number
backing: number
trimCap: number
leds: number
wireStuds: number
total: number
}
job: {
lettersMaterial: number
powerSupplies: number
raceway: number
wasteAllowance: number
materialTotal: number
}
labor: {
sizeFactor: number
cncBendHours: number
assemblyHours: number
cncBendCost: number
assemblyCost: number
burden: number
laborCost: number
}
markupMultiplier: number
}Dimensional-letter detail shape
export interface DlBreakdown {
gridBasePerLetter: number // lerp result before multipliers
finishMult: number
mountMult: number
basePerLetter: number // after finish & mount multipliers
wholesale: number
heightClamped: boolean // height outside the 6–24" grid was clamped [A17]
installHours: number // owner-facing only
installCapApplied: boolean // the taxonomy's "2× wholesale" cap kicked in
}Settings
Settings is the shop’s single global rate card — one per store, edited on the Settings screen.
export interface Settings {
shop: ShopInfo
constants: Constants
ledProducts: LedProduct[]
dimGrid: DimGridRow[]
}shop(ShopInfo) — branding and quote boilerplate:name,address,phone,email, optionallogoDataUrl,quoteValidityDays, andterms.constants(Constants) — the Table B cost constants (per-inch rates, material costs, LED/power, labor, install, fees, margin thresholds, DL multipliers). The full catalog with seeded values is documented in Settings & Constants rather than repeated here.ledProducts(LedProduct[]) — the LED catalog a CLledProductIdpoints into.dimGrid(DimGridRow[]) — the per-letter price grid for dimensional letters at the 6/12/18/24-inch anchors.
export interface LedProduct {
id: string
label: string
costPerModule: number
wattsPerModule: number
densityPerFt: number
note: string
}
export interface DimGridRow {
materialId: DimMaterial
label: string
thicknessNote: string // baked into the preset, read-only in the form [A4]
baseFinishNote: string // the finish already included in the grid price [A5]
prices: { h6: number; h12: number; h18: number; h24: number }
}Seed quotes
buildSeedState() in src/data/defaults.ts ships two quotes that reproduce the taxonomy’s worked
examples exactly (see Acceptance & Testing). Their pricing is computed at seed time
with computePricing(config, settings), so the seed is self-checking.
#1001 — CAFE (channel letter, AC-1)
Customer Maria Torres, Cafe Luna; createdAt 2026-06-08T15:00:00.000Z; pdfMode: 'itemized'.
export const SEED_CAFE_CONFIG: ChannelLetterConfig = {
productCategory: 'channel_letter',
signTextLabel: 'CAFE',
letterCount: 4,
letterHeight: 18,
wallSurface: 'brick',
installHeightAccess: 'bucket',
permitRequired: true,
engineeringRequired: false,
markupMultiplier: 2,
illuminationType: 'front_lit',
returnDepth: 5,
faceMaterial: 'white_acrylic_3_16',
ledProductId: 'sloan_prism12',
mountType: 'flush_stud',
}#1002 — LAW OFFICE (dimensional letter, AC-2)
Customer David Chen, Chen & Associates LLP; createdAt 2026-06-09T16:30:00.000Z;
pdfMode: 'itemized'.
export const SEED_LAW_OFFICE_CONFIG: DimensionalLetterConfig = {
productCategory: 'dimensional_letter',
signTextLabel: 'LAW OFFICE',
letterCount: 8,
letterHeight: 12,
wallSurface: 'drywall',
installHeightAccess: 'ground_ladder',
permitRequired: false,
engineeringRequired: false,
markupMultiplier: 1.5,
dimMaterial: 'cast_aluminum',
dimFinish: 'standard_included',
dimMount: 'flush_stud',
}Relations & snapshot semantics
The model has no foreign keys. A Quote embeds a full copy of its config and its pricing
(both deep-cloned at seed time via structuredClone). The only catalog reference is the value
config.ledProductId, matched by id against Settings.ledProducts at compute time.
The single global rate card is Settings.constants (with ledProducts and dimGrid). Because
each quote stores a pricing snapshot, editing Settings does not retroactively change
prices on already-saved quotes. The snapshot is recomputed on every config change and on open, so
re-opening a quote re-prices it against the current settings — the saved snapshot is a cache,
not the source of truth.