Architecture
SignQuote is a single-page browser app with no backend, no router, and no state library beyond
React context. Everything reduces to one equation — price = computePricing(config, settings) —
and the whole architecture exists to keep that equation pure, fast, and honest about who is allowed
to see what.
The two-table model, precisely
The spec fixes the architecture as two tables (spec §1). In the code they are two TypeScript
shapes in src/types.ts:
| Type | Lives in | Who edits it | |
|---|---|---|---|
| Table A | SignConfig | Quote.config, one per job | The salesperson |
| Table B | Settings.constants (plus ledProducts and dimGrid) | One global Settings object | The owner, once |
SignConfig is a discriminated union on productCategory:
export type SignConfig = ChannelLetterConfig | DimensionalLetterConfig
// productCategory: 'channel_letter' | 'dimensional_letter'Both arms extend BaseSignConfig (shared fields like letterCount, letterHeight, wallSurface,
markupMultiplier); each arm adds its own category-specific fields. Table B is the single global
rate card: Settings.constants (the Constants shape), Settings.ledProducts (an Array<LedProduct>),
and Settings.dimGrid (an Array<DimGridRow>). There are no foreign keys — a Quote embeds its own
config and a pricing snapshot, by design (spec §5).
The price is a pure function of the two:
export function computePricing(config: SignConfig, settings: Settings): PricingResultThat single signature is the contract between the two worlds. See Pricing Engine for what it computes and Data Model for every field.
Tech stack and why
The stack is the one recommended in spec §8, chosen for the fastest scaffold-to-demo path with zero infrastructure.
| Choice | Why (spec §8) |
|---|---|
| Vite + React 18 + TypeScript | Fastest scaffold; the pricing engine stays a pure TS module. |
| Tailwind CSS | Plain utility classes — no design-system dependency. |
@react-pdf/renderer | Produces a real downloadable PDF file (more credible in a demo than print-to-PDF) and previews in a modal. |
| Vitest | Pins computePricing to the taxonomy’s worked examples — see Testing. |
| localStorage | Persistence with no server, no env vars, no runtime network calls. |
What is deliberately absent (spec §7): no backend, no auth, no routing beyond the four screens, no state library beyond React context/hooks. Single-user, single-shop. Next.js was considered and rejected for the demo — a server runtime buys nothing here — and flagged as the natural choice later if the validated product needs accounts or sharing.
The simplicity is a feature. The whole app is there to make a believable number appear fast; every dependency that would slow the demo or cloud the pricing logic was left out on purpose.
Data flow
A single pass runs on every edit. There is no async step and nothing to wait on — recompute is a pure function and well under the spec’s 50 ms budget (spec §7).
Salesperson edits a form field (QuoteEditor)
│
▼
updateQuoteConfig(id, nextConfig) ← in AppContext
│
▼
computePricing(config, settings) ← pure, synchronous, < 50 ms
│
▼
Quote.pricing = fresh PricingResult snapshot (stored in state)
│ │
▼ ▼
PricePanel PdfPreviewModal
(live price, badge) (@react-pdf/renderer)Both the live price panel and the PDF render from the same PricingResult snapshot — they never
recompute independently, so what the salesperson sees is exactly what the PDF prints.
State management
AppProvider (in src/context/AppContext.tsx) is the single source of truth. It holds the entire
PersistedState plus the current View, and exposes everything through the useApp() hook:
| Method | What it does |
|---|---|
createQuote(category) | Seeds category defaults, navigates to the editor, returns the new id. |
updateQuoteConfig(id, config) | Replaces the config and recomputes the pricing snapshot. |
updateQuoteCustomer(id, customer) | Updates customer fields only (no reprice). |
setQuotePdfMode(id, pdfMode) | Toggles 'itemized' vs 'total_only'. |
recomputeQuote(id) | Re-prices against current constants — the “prices refresh on open” call. |
duplicateQuote(id) | Clones a quote with a fresh id/number and re-priced snapshot. |
deleteQuote(id) | Removes a quote. |
updateSettings(next) | Accepts a new Settings or a functional updater (race-safe for async writes). |
resetDemoData() | Reseeds state from defaults and returns to the quotes list. |
setView(v) / navigate | Changes the current screen (see the prune behavior below). |
The current screen is a View union — { name: 'quotes' }, { name: 'editor'; quoteId: string | null },
or { name: 'settings' }. App.tsx switches on view.name to render QuotesList, QuoteEditor, or
SettingsScreen. There is no URL router; this union is the navigation. The two implicit roles fall out
of it: the quote screens are the salesperson’s world, the Settings tab is the owner’s (spec §2).
Persistence
All persistence lives in src/storage.ts and uses a single, schema-versioned localStorage key,
signquote.v1.
loadState()reads the key, parses it, and validatesmeta.schemaVersion === 1with a quotes array and a settings object. Anything missing, corrupt, or unparseable falls through to a reseed — the app is never empty (spec §2).seedState()builds fresh demo data viabuildSeedState()(seeded with two example quotes andnextQuoteNumber: 1003) and writes it.saveState(state)writes the whole state back, wrapped in atry/catchso a disabled store (private mode) degrades quietly.
Saving is automatic: AppProvider runs an effect that calls saveState(state) whenever state
changes, so every mutation is persisted.
Snapshots vs. live constants
Each Quote carries its own pricing snapshot. Editing Settings does not retroactively rewrite
saved quote snapshots (spec §5). Instead, reopening a quote recomputes it against the current
constants — surfaced in the UI as “prices refresh on open.”
That recompute is a one-shot guard in QuoteEditor:
const recomputedFor = useRef<string | null>(null)
useEffect(() => {
if (quoteId !== null && recomputedFor.current !== quoteId) {
recomputedFor.current = quoteId
recomputeQuote(quoteId)
}
}, [quoteId])The ref makes the effect idempotent per quote id, so it fires once per opened quote — not on every render (recomputing mutates state, which re-renders) and not twice under React StrictMode.
Pristine-quote pruning
Clicking a category card immediately calls createQuote, which materializes a real Quote so the
editor has something to edit. To stop “clicking around” from littering the list with empty rows,
navigate (the wrapper behind setView) prunes a freshly-created-but-untouched quote when you leave
its editor.
A quote is pristine (per isPristineQuote) when its customer is entirely blank and its config still
deep-equals the category defaults from defaultConfigFor. On any exit path — back link, nav tab, or
switching to another quote — such a quote is dropped from state. The motivation is the spec’s
“never empty / always credible” list requirement: the quotes list only ever shows quotes someone
actually started.
The role boundary as an invariant
The two roles are enforced as an architectural invariant on the shape of PricingResult, tested as
AC-10. The engine computes a full cost decomposition, but only a subset is ever allowed
in the salesperson’s editor or on the PDF.
| Owner-only (Settings preview only) | Salesperson-safe (editor + PDF) |
|---|---|
costSubtotal (the cost floor) | lineItems[], total |
clBreakdown (full CL cost decomposition) | marginPct, badge (margin badge only) |
derived.installHours | fabPerInch (the displayed per-inch price) |
dl.installHours | pdfMode |
No cost, rate, labor-hour, or raw margin number ever renders in the quote editor or PDF. The only margin information the salesperson sees is the colored badge; the full breakdown lives exclusively on the Settings preview panel. See Testing → AC-10 for the enforced field list.