Developer DocsArchitecture

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:

TypeLives inWho edits it
Table ASignConfigQuote.config, one per jobThe salesperson
Table BSettings.constants (plus ledProducts and dimGrid)One global Settings objectThe 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): PricingResult

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

ChoiceWhy (spec §8)
Vite + React 18 + TypeScriptFastest scaffold; the pricing engine stays a pure TS module.
Tailwind CSSPlain utility classes — no design-system dependency.
@react-pdf/rendererProduces a real downloadable PDF file (more credible in a demo than print-to-PDF) and previews in a modal.
VitestPins computePricing to the taxonomy’s worked examples — see Testing.
localStoragePersistence 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:

MethodWhat 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) / navigateChanges 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 validates meta.schemaVersion === 1 with 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 via buildSeedState() (seeded with two example quotes and nextQuoteNumber: 1003) and writes it.
  • saveState(state) writes the whole state back, wrapped in a try/catch so 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.installHoursfabPerInch (the displayed per-inch price)
dl.installHourspdfMode

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.