# Acorn Finance Open Calculator (`<af-calculator-open-component>`)

<p align="left">
  <!-- Bundle size badge (placeholder). Replace URL with a real service like bundlephobia or size-limit action output -->
  <img alt="Bundle Size" src="https://img.shields.io/badge/bundle-%3C30KB%20gzip-green" />
  <!-- Lighthouse score badge (manual / placeholder) -->
  <img alt="Lighthouse Performance" src="https://img.shields.io/badge/perf-95%2B-brightgreen" />
  <!-- Node version support -->
  <img alt="Node Version" src="https://img.shields.io/badge/node-18%2B%20|%2020%2B-blue" />
  <!-- License (internal / proprietary placeholder) -->
  <img alt="License" src="https://img.shields.io/badge/license-internal-orange" />
</p>

A Lit-based Web Component for embedding a financing pre‑qualification calculator with an integrated disclosures modal. Recent updates focus on improving Largest Contentful Paint (LCP), reducing early main‑thread work, and making WordPress embedding friction‑free.

---

## 1. Features

- Base project cost (single field in `standard` layout) + optional add‑on funding (exposed only when using the `additional` layout toggle)
- Auto thousand separators
- Enforced total loan range with state-aware limits: $1,000 – $100,000 (default), or $6,700 – $100,000 for Massachusetts
- Remaining optional allowance hint
- Monthly payment: displays placeholder `--` until an amortized value can be computed from rates; remains `--` if rates unavailable or amount invalid
- Deferred (idle + in‑viewport) rates fetch for faster initial paint
- Cached dynamic “Check offers” link (`offersHref`) built from a whitelist of safe parameters
- Access Offers link (`accessOffersHref`): centered text link shown under disclosures on both layouts; env‑aware `{env}.your.acornfinance.com/access-offers` and includes `email` when provided.
- Reactive validity state (`validity-change` event)
- Additional events: `parameters-loaded`, `rates-loaded`, `check-offers-clicked`
- Adaptive loading skeleton with short baseline (≈400ms) to prevent flash; override via attribute/query/global flag; respects `prefers-reduced-motion`
- Lifecycle phase events (`calc-phase`) + performance marks for instrumentation
- Disclosure modal (lazy-rendered only when opened)
- Query param & attribute seeding (URL > attribute precedence)
- Environment shim (supports `process.env`, `import.meta.env`, or `window.__ACORN_ENV`)
- State-aware validation with appropriate error messaging (e.g., Massachusetts-specific loan ranges)
- Forced mobile preview attribute (`force-mobile`) for demos (CSS-only; simulates narrow layout without changing actual viewport width)

**Monthly payment logic (Updated):**

1. Initial: Placeholder `--` while loading state active and/or rates not yet ready.
2. After rates load and total amount valid: Uses fetched tier (APR + term) and amortization formula to compute and display the monthly payment.
3. Failure / No matching band / Below minimum: Placeholder `--` persists (no approximation shown).
4. Loading minimum duration ensures the placeholder remains visible briefly so UI/tests consistently reflect the loading state.

---

## 2. Quick Start

```html
<script type="module" src="./src/af-calculator-open-component.js"></script>
<af-calculator-open-component></af-calculator-open-component>
```

Adjust path for your build / hosting.

### 2.1 Local Development / Preview

Start the demo + dev web server (with automatic rebuild of the bundled `dist` output when you edit `src/**/*.js`):

```bash
npm run start
```

Behind the scenes `web-dev-server` serves the component from source. A custom watcher in `web-dev-server.config.js` triggers `npm run build` (Rollup) whenever a JavaScript file under `src/` changes (debounced ~250ms) so the minified `dist/af-calculator-open-component.min.js` stays current for any external pages referencing the built artifact.

Notes:

- You can also use `npm run dev` to run Rollup in watch mode alone if you don’t need the demo page.
- If build output churn becomes noisy, adjust debounce or remove the plugin in `web-dev-server.config.js`.
- The dev server opens `demo/index.html` automatically.

### 2.2 Hosted Demo Pages (Internal)

The following hosted demo pages are available for quick validation and sharing. Parameters (like `d` or `loanAmount`) can be adjusted directly in the query string.

Full Page Demo:

```
https://dev.widgets-cdn.acornfinance.com/calculator-open/demo/?d=QRS-QUOTE
https://dev.widgets-cdn.acornfinance.com/calculator-open/demo/?d=QRS-QUOTE&loanAmount=98000
```

Speed Test Demo (minimal surface for performance / LCP inspection):

```
https://dev.widgets-cdn.acornfinance.com/calculator-open/demo/test.html?d=QRS-QUOTE
https://dev.widgets-cdn.acornfinance.com/calculator-open/demo/test.html?d=QRS-QUOTE&loanAmount=99000
```

Layouts Comparison Demo (desktop vs forced mobile, standard vs additional layouts):

```
https://dev.widgets-cdn.acornfinance.com/calculator-open/demo/layouts.html
```

Usage Notes:

- These URLs reference the development CDN environment; ensure environment variables / rate JSON availability match expectations.
- For production validation, replace the host with the production CDN domain once deployed.
- `loanAmount` param in query can trigger automatic selection of the `additional` layout when layout attribute is omitted (legacy deeplink behavior).

---

## 3. Query Parameters & Attributes

URL params populate component state; missing params fall back to same‑named attributes. URL takes precedence.

| Query                  | Attribute(s)             | Purpose                                                                                                                                            |
| ---------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| loanAmount             | loanAmount / loanamount  | Base cost (raw digits)                                                                                                                             |
| d                      | d                        | Partner / dealer id                                                                                                                                |
| cv                     | cv                       | Campaign / variant code                                                                                                                            |
| subPurpose             | subPurpose               | Sub category                                                                                                                                       |
| cname                  | cname                    | Contractor / brand display name                                                                                                                    |
| utm_content            | utm_content / utmContent | UTM content                                                                                                                                        |
| utm_medium             | utm_medium / utmMedium   | UTM medium                                                                                                                                         |
| utm_source             | utm_source / utmSource   | UTM source                                                                                                                                         |
| docSource              | docSource                | Document source                                                                                                                                    |
| docUser                | docUser                  | User context                                                                                                                                       |
| cc                     | cc                       | Custom/geo code                                                                                                                                    |
| cs                     | cs                       | Customer state code                                                                                                                                |
| cz                     | cz                       | Zip fragment                                                                                                                                       |
| returnUrl              | returnUrl                | Post-flow redirect                                                                                                                                 |
| firstname              | firstname                | First name                                                                                                                                         |
| lastname               | lastname                 | Last name                                                                                                                                          |
| email                  | email                    | Email                                                                                                                                              |
| streetaddress          | streetaddress            | Address line 1                                                                                                                                     |
| apartment              | apartment                | Address line 2                                                                                                                                     |
| city                   | city                     | City                                                                                                                                               |
| state                  | state                    | State                                                                                                                                              |
| zipcode                | zipcode                  | Zip                                                                                                                                                |
| phonenumber            | phonenumber              | Phone digits                                                                                                                                       |
| yrs                    | yrs                      | Years (domain)                                                                                                                                     |
| pmt                    | pmt                      | External payment reference                                                                                                                         |
| pcid                   | pcid                     | Partner customer id                                                                                                                                |
| processEnv             | processEnv               | Environment flag (QA/Prod)                                                                                                                         |
| (outbound only) skipLa | –                        | Added automatically to outbound offers link (`skipLa=1`) so the loan app skips its initial loan amount question (calculator already collected it). |

Defaults: `loanAmount` → `10000` if absent; others undefined.

### 3.1 State-Specific Loan Ranges

Loan amount ranges are validated based on the borrower's state, which can be provided via the `state` query parameter, attribute, or collected from user input:

| State                      | Minimum | Maximum  |
| -------------------------- | ------- | -------- |
| Massachusetts (MA)         | $6,700  | $100,000 |
| All other supported states | $1,000  | $100,000 |

When a borrower's state is Massachusetts, the component automatically enforces the higher minimum ($6,700) and displays an appropriate error message if the entered amount falls outside the valid range.

Example with Massachusetts state:

```html
<af-calculator-open-component
  state="MA"
  loanAmount="5000"
></af-calculator-open-component>
<!-- Component will show error: "In Massachusetts, the total loan amount must be between $6,700 and $100,000." -->
```

### 3.2 Component Version (cv) Derivation Rules (Updated)

The `cv` (component/version or campaign variant) is resolved using the following precedence order:

1. Explicit inbound `cv` query parameter (e.g. `?cv=cv-1234`) – highest priority.
2. Component `cv` attribute (e.g. `<af-calculator-open-component cv="cv-1234">`).
3. Host + pathname based auto‑derivation (only if neither query nor attribute provided):
   - Borrower Platform domains (`*.your.acornfinance.com` including `qa.` / `uat.` / production):
     - Path starts with `/financing` → `cv-0006`
     - Path starts with `/calculator` → `cv-0006` (updated mapping)
   - Marketing/Staging sites (`staging.acornfinance.dev`, `www.acornfinance.com`):
     - Path starts with `/pre-qualify` or `/pre-qualify-2` → `pq-0002`
   - Fallback (any other host/path) → `cv-0006`

Implementation detail: Auto‑derivation occurs once in the constructor if `cv` is blank after reading query + attribute. The static helper `AfCalculatorOpenComponent._deriveCvForContext(host, pathname, existingCv)` encapsulates this logic and is unit tested.

Rationale:

- Ensures consistent tagging across platform pages without needing every embed to specify `cv`.
- Distinguishes financing flow (`cv-0006`) from pre‑qualification marketing pages (`pq-0002`).
- Provides safe, upgradable fallback aligned with current default variant (`cv-0006`).

To override derivation simply provide a `cv` attribute or query param; the auto rules will not run.

Example overrides:

```html
<!-- Explicit override wins; auto rules skipped -->
<af-calculator-open-component cv="cv-custom-42"></af-calculator-open-component>

<!-- Query param takes precedence over attribute -->
<iframe
  src="https://qa.your.acornfinance.com/calculator?cv=cv-deeplink-01"
></iframe>
```

### 3.3 Layout Selection (Updated in 1.2.9)

The `layout` attribute controls which UI arrangement is shown.

Values:

- `standard` (default): Single "Loan amount" field only (no "Need more funds?" toggle or optional input).
- `additional`: Two-stage view – primary amount plus a "Need more funds?" toggle that reveals an optional "Additional needs" input and live Total Loan Amount summary.

Auto Selection Logic:
If you omit the `layout` attribute, the component inspects the page URL on first connection:

- If a `loanAmount` query parameter exists → `layout` is set to `additional` (preserves legacy deeplink expectations that included optional add-on capability).
- If no `loanAmount` param present → `layout` defaults to `standard` (simplified single-field design).

Override Example:

```html
<af-calculator-open-component
  layout="additional"
></af-calculator-open-component>
```

Programmatic switch:

```js
el.layout = "standard"; // triggers re-render
```

Validation & monthly payment logic are identical for both layouts; the `additional` layout solely adds the toggle-controlled optional amount and remaining allowance hint. The toggle state does not exist in `standard`.

### 3.4 Preview Helper: `force-mobile` Attribute (New)

Add the boolean host attribute `force-mobile` to simulate the mobile stacked layout in constrained preview cards or comparison pages without resizing the browser window:

```html
<af-calculator-open-component
  layout="standard"
  force-mobile
></af-calculator-open-component>
```

Details:

- Purely CSS-driven (`:host([force-mobile])`) – no JS logic changes.
- Constrains width and applies mobile alignment (monthly payment left-aligned, button full width inside container).
- Intended for demos, docs, or component galleries; omit in production embeds where natural responsive media queries already apply.
- Current forced mobile gap between payment label and amount is `10px` (desktop is `40px`). Override with custom CSS if you require `0`.

If you need a zero gap explicitly:

```css
af-calculator-open-component[force-mobile] .monthly-payment.available-payment {
  gap: 0;
}
```

---

## 4. Core Reactive Properties (subset)

| Property                           | Description                     |
| ---------------------------------- | ------------------------------- |
| requiredAmount                     | Formatted base amount (commas)  |
| optionalAmount                     | Formatted optional add‑on       |
| totalAmount                        | Sum (0 if invalid)              |
| monthlyPayment                     | Calculated illustrative payment |
| isValid                            | True when range satisfied       |
| offersHref                         | Cached dynamic offers link      |
| showModal                          | Disclosures modal state         |
| d / cv / subPurpose / cname        | Partner & campaign metadata     |
| utmContent / utmMedium / utmSource | Tracking fields                 |
| pcid / processEnv                  | Partner & env flags             |

---

## 5. Events

| Event                | Detail                                       | When                                                                                                               |
| -------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| parameters-loaded    | `getParameters()` snapshot                   | After initial parse                                                                                                |
| validity-change      | `{ isValid, totalAmount }`                   | After amounts change                                                                                               |
| rates-loaded         | Raw rates JSON                               | When deferred fetch resolves                                                                                       |
| check-offers-clicked | `getParameters()` snapshot                   | CTA click                                                                                                          |
| disclosure-open      | `getParameters()` snapshot                   | Modal open                                                                                                         |
| loading-change       | `{ loading }`                                | Any time loading state toggles                                                                                     |
| payment-ready        | `{ monthlyPayment, totalAmount, timestamp }` | First time a valid monthly payment is computed and loading has ended                                               |
| calc-phase           | `{ phase, timestamp }`                       | Lifecycle instrumentation phases: `init`, `rates-requested`, `rates-loaded`, `payment-computed`, `loading-cleared` |

Example:

```js
el.addEventListener("rates-loaded", (e) => console.log("Rates:", e.detail));
```

---

## 6. Access Snapshot

```js
const p = el.getParameters();
console.log(p.totalAmount, p.loanAmount, p.d);
```

---

## 7. Example Attribute Configuration

```html
<af-calculator-open-component
  loanAmount="27500"
  d="CRT-525-TX"
  subPurpose="Painting"
  cname="CertaPro Painters®"
  utm_source="Contractor"
  utm_medium="CRT-861400"
  utm_content="CRT-945761aaaf8b182efdee013b"
  docSource="Proposal"
  docUser="Customer"
  firstname="Nancy"
  lastname="Birkhead"
  email="demo@acornfinance.com"
  streetaddress="2600 Capitol Ave"
  city="Sacramento"
  state="CA"
  zipcode="95816"
  phonenumber="5303201000"
  pcid="2899"
  processEnv="QA"
>
</af-calculator-open-component>
```

---

## 8. Disclosures Modal

Open: click link. Close: `×` or backdrop.

```js
el.showModal = true; // programmatic
```

Rendered lazily (HTML only inserted when needed) to reduce initial DOM.

---

## 9. “Check offers” Link (Optimized)

Built once post‑paint → stored in `offersHref` → microtask update after amount changes. Whitelist mapping ensures only approved params included; `loanAmount` always appended last.

**Note:** Outbound links no longer use `dealerid`. Only the `d` parameter is used for dealer attribution.

### 9.1 Outbound `skipLa` Optimization (New in 1.2.3)

The component now always appends `skipLa=1` to the outbound offers link. This informs the destination loan application that the user has already selected / confirmed their desired loan amount within the calculator, allowing the app to skip its first loan amount input step. This reduces friction and shortens the time to the personalized offer view.

Notes:

- `skipLa` is outbound-only; it is never read inbound and does not alter component UI.
- If a future flow requires disabling this behavior, a toggle attribute could be introduced (not currently implemented).
- Placed near the end of the query string together with the final enforced `loanAmount` value.

```js
const href = el.offersHref; // current cached link
```

---

## 10. Programmatic Example

```html
<af-calculator-open-component id="loanCalc"></af-calculator-open-component>
<script type="module">
  const c = document.getElementById("loanCalc");
  c.addEventListener("validity-change", (e) => console.log(e.detail));
  c.requiredAmount = "25,000";
  c.optionalAmount = "5,000";
  c.requestUpdate();
</script>
```

---

## 11. Environment & Deployment

### 11.1 Resolution Order

1. `process.env.*`
2. `import.meta.env.*`
3. `window.__ACORN_ENV`

### 11.2 WordPress Injection

```html
<script>
  window.__ACORN_ENV = {
    WIDGET_ENV: "Prod",
    WIDGET_URL: "https://fs.acornfinance.com",
    LOAN_APP_PROD_URL: "https://app.acornfinance.com",
    LOAN_APP_QA_URL: "https://qa-app.acornfinance.com",
    LOAN_APP_UAT_URL: "https://uat-app.acornfinance.com",
  };
</script>
```

### 11.3 Build / Serve

CDN Lit import works without build. For bundling (Rollup/Vite) alias lit to local dependency and emit minified ESM.

---

## 12. Performance & LCP Optimization Summary

| Optimization                                       | Why                                                              | Status               |
| -------------------------------------------------- | ---------------------------------------------------------------- | -------------------- |
| Deferred rates fetch (idle + IntersectionObserver) | Avoids blocking early paint                                      | Implemented          |
| Cached offers href                                 | Eliminates repeat URL building                                   | Implemented          |
| Lazy modal render                                  | Shrinks initial DOM                                              | Implemented          |
| Placeholder `--` (no pre-rate approximation)       | Avoids showing speculative payment                               | Implemented          |
| Loading placeholder minimum duration               | Avoids flicker & stabilizes layout                               | Implemented          |
| Adaptive minimum (overrideable)                    | Avoids unnecessary 2.5s delay while guaranteeing stable skeleton | Implemented          |
| Microtask batching (queueMicrotask)                | Keeps typing responsive                                          | Implemented          |
| Env shim                                           | Prevents `process` runtime error                                 | Implemented          |
| Potential SSR skeleton                             | Further perceived speed                                          | Planned              |
| Reduce reflected attrs                             | Lower attribute churn                                            | Planned              |
| Hero image `fetchpriority` & preconnect            | Better LCP                                                       | External integration |
| Fast-fade spinner skip                             | Avoid spinner flash on ultra-fast loads                          | Implemented          |
| Reserved heights via CSS vars (consolidated)       | Prevent CLS & reduce duplication                                 | Implemented          |
| Inline critical CSS (hero/calculator)              | Improve FCP & early LCP candidate paint                          | Implemented          |
| content-visibility + contain (below-fold)          | Skip rendering offscreen sections initially                      | Implemented          |
| Diagnostics module (metrics panels)                | Developer insight into FCP/LCP/CLS & height shifts               | Implemented          |
| Optional overflow debugger                         | Rapid detection of horizontal scroll regressions                 | Implemented          |

**Suggested WordPress snippet:**

```html
<link rel="preconnect" href="https://fs.acornfinance.com" crossorigin />
<link rel="preload" as="image" href="/path/to/hero.webp" fetchpriority="high" />
<script>
  window.__ACORN_ENV = {
    WIDGET_ENV: "Prod",
    WIDGET_URL: "https://fs.acornfinance.com",
    LOAN_APP_PROD_URL: "https://app.acornfinance.com",
  };
</script>
<script
  type="module"
  src="/calculator-open/dist/af-calculator-open-component.min.js"
  defer
></script>
<af-calculator-open-component
  id="calculator"
  d="P063M"
  loanAmount="15000"
></af-calculator-open-component>
<script>
  window.addEventListener("DOMContentLoaded", () => {
    const c = document.getElementById("calculator");
    if (c && !c.d) c.d = "P063M";
  });
</script>
```

### WordPress Reserved Height / CLS Setup (New)

Reserve vertical space early to eliminate layout shift as the component upgrades and rates/payment hydrate. Add this CSS to your theme (style.css) or Appearance > Customize > Additional CSS:

```css
:root {
  --calc-standard-desktop: 306px;
  --calc-standard-mobile: 398px;
  --calc-additional-desktop: 319px;
  --calc-additional-mobile: 414px;
  --calc-fallback: 398px; /* majority: mobile standard */
}
af-calculator-open-component,
af-calculator-open-component[loading] {
  display: block;
  min-height: var(--calc-fallback);
}
af-calculator-open-component[layout="standard"],
af-calculator-open-component[layout="standard"][loading] {
  min-height: var(--calc-standard-desktop);
}
af-calculator-open-component[layout="additional"],
af-calculator-open-component[layout="additional"][loading] {
  min-height: var(--calc-additional-desktop);
}
@media (max-width: 640px) {
  af-calculator-open-component[layout="standard"],
  af-calculator-open-component[layout="standard"][loading] {
    min-height: var(--calc-standard-mobile);
  }
  af-calculator-open-component[layout="additional"],
  af-calculator-open-component[layout="additional"][loading] {
    min-height: var(--calc-additional-mobile);
  }
}
```

Optional early layout attribute script (place before the component tag so the correct reserved height applies immediately):

```html
<script>
  (function () {
    const hasLoanAmount = new URLSearchParams(location.search).has(
      "loanAmount",
    );
    function setLayout() {
      const el = document.querySelector("af-calculator-open-component");
      if (!el) {
        requestAnimationFrame(setLayout);
        return;
      }
      if (!el.hasAttribute("layout")) {
        el.setAttribute("layout", hasLoanAmount ? "additional" : "standard");
      }
    }
    setLayout();
  })();
</script>
```

Why this works:

- Fallback height covers most mobile visits (common initial viewport) reducing unexpected growth.
- Media query swaps in mobile heights below 640px (matches component internal breakpoint).
- `[loading]` selector ensures spinner vs hydrated UI doesn't change the box height.
- Early script forces layout selection before custom element upgrade so the correct `min-height` is chosen.
- Safe for multiple instances; each instance reads its own `layout`.

Adjusting values: Only increase when new content pushes actual rendered height higher. Never reduce below real content or CLS may return.

Multiple platforms (Quasar / plain HTML) can reuse the same CSS block unchanged.

### WordPress Spinner Overlay (Optional)

### Demo Version Footer Automation

All demo pages display the current widget version dynamically via a generated `version.js` file (global `AF_CALC_OPEN_VERSION`). This file is auto-created on `npm version` through the `postversion` script.

Generation pipeline:

1. Bump version: `npm version patch` (or minor/major).
2. `postversion` runs `npm run generate-version`.
3. `scripts/write-version.js` writes `demo/version.js` with:

```js
window.AF_CALC_OPEN_VERSION = "<version>";
```

4. Demo HTML pages include:

```html
<script src="version.js"></script>
<script>
  document.querySelectorAll(".demo-version").forEach((el) => {
    el.textContent =
      "Open Calculator Version: " + (window.AF_CALC_OPEN_VERSION || "unknown");
  });
</script>
```

If you add new demo pages, copy the snippet above to show the current version automatically. For external platforms without file system access, you can expose the version by serving `version.js` alongside the bundle.

To emulate the performance harness spinner shown in `demo/test.html`, wrap the component with a container and add a lightweight spinner that appears only if the initial load exceeds a short threshold.

Layout Standard

- min-height: 306px; for desktop
- min-height: 398px; for mobile

Additional Layout

- min-height: 436px; for desktop
- min-height: 577px; for mobile

````html
<style>
  .af-calc-wrapper {
    position: relative;
  }
  .af-calc-wrapper.spinner-active::before {
    content: "";
    position: absolute;
    inset: 0;
    background: rgba(255, 255, 255, 0.85);
    backdrop-filter: saturate(180%) blur(2px);
    border-radius: 8px;
    pointer-events: none;
    z-index: 5;
  }
  .af-loading-spinner-wrapper {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -60%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    font-family: system-ui, sans-serif;
    font-size: 0.75rem;
    color: #333;
    opacity: 0;
    transition:
      opacity 0.25s ease,
      transform 0.25s ease;
    z-index: 6;
    pointer-events: none;
  }
  .af-calc-wrapper.spinner-active .af-loading-spinner-wrapper {
    opacity: 1;
    transform: translate(-50%, -50%);
  }
  .af-loading-spinner {
    width: 44px;
    height: 44px;
    border-radius: 50%;
    box-sizing: border-box;
    border: 4px solid #d1d5db;
    border-top-color: #016de1;
    animation: spin 1.1s linear infinite;
  }
  @keyframes spin {
    0% {
      transform: rotate(0deg);
    }
    100% {
      transform: rotate(360deg);
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .af-loading-spinner {
      animation: none;
    }
    .af-loading-spinner-wrapper {
      transition: none;
    }
  }
</style>

<div class="af-calc-wrapper" id="afCalcWrapper">
  <af-calculator-open-component
    id="calculator"
    loanAmount="15000"
    d="P063M"
  ></af-calculator-open-component>
  <div
    class="af-loading-spinner-wrapper"
    aria-live="polite"
    aria-busy="true"
    role="status"
  >
    <div class="af-loading-spinner" aria-hidden="true"></div>
    <div>Loading…</div>
  </div>
</div>

<script>
  (function () {
    const calc = document.getElementById("calculator");
    const wrapper = document.getElementById("afCalcWrapper");
    let paymentReadyEarly = false;
    let ratesLoadedEarly = false;
    calc.addEventListener(
      "payment-ready",
      () => {
        paymentReadyEarly = true;
      },
      { once: true },
    );
    calc.addEventListener(
      "rates-loaded",
      () => {
        ratesLoadedEarly = true;
      },
      { once: true },
    );

    // Delay before showing spinner (matches demo harness intent)
    const SPINNER_DELAY = 250; // ms
    const spinnerTimer = setTimeout(() => {
      if (paymentReadyEarly || ratesLoadedEarly) return; // fast load -> skip spinner
      if (calc.hasAttribute("loading")) wrapper.classList.add("spinner-active");
    }, SPINNER_DELAY);

    // Safety activation (in case events slow) at 400ms
    setTimeout(() => {
      if (
        !wrapper.classList.contains("spinner-active") &&
        !paymentReadyEarly &&
        !ratesLoadedEarly &&
        calc.hasAttribute("loading")
      ) {
        wrapper.classList.add("spinner-active");
      }
    }, 400);

    function removeSpinner() {
      if (wrapper.classList.contains("spinner-active"))
        wrapper.classList.remove("spinner-active");
    }

    calc.addEventListener("payment-ready", removeSpinner, { once: true });
    calc.addEventListener("rates-loaded", removeSpinner); // fallback if payment-ready not first

    // Ultimate timeout fallback (in case of network issue)
    setTimeout(removeSpinner, 8000);
  })();
</script>

Notes: - Spinner appears only if the component remains in its loading state past
~250ms. - The component internally sets `loading` and emits `payment-ready` /
`rates-loaded`; no polling required. - Keep CSS scoped (prefix classes) to avoid
collisions with theme styles. - Adjust `SPINNER_DELAY` higher (e.g. 400ms) to
show spinner less often on fast hosts. --- ## 12.1 Adaptive Loading & Lifecycle
Events (New) The component previously enforced a fixed 2500ms minimum skeleton
time. This has been replaced with an adaptive strategy that prioritizes fast
user-perceived responsiveness while preventing a distracting “flash” on very
fast loads. ### 12.1.1 Adaptive Algorithm 1. Record `performance.now()` at
construction (`_loadingStart`). 2. Set a baseline minimum hold (`_minLoadingMs`,
default `400ms`). 3. When a valid payment is computed, determine elapsed = now -
start. 4. If `elapsed >= _minLoadingMs` → clear loading immediately, else
schedule a timeout for the remaining delta. 5. If `prefers-reduced-motion:
reduce` is true, minimum becomes `0` unless explicitly overridden. 6. Safety
cap: a separate 4500ms timeout still clears the skeleton if something stalls.
### 12.1.2 Override Mechanisms You can force a specific minimum loading duration
(useful for automated visual/accessibility tests) by any of the following (first
found wins): | Mechanism | Example | Scope | |-----------|---------|-------| |
Attribute | `<af-calculator-open-component
  data-force-min-loading="1200"
></af-calculator-open-component
>` | Per instance | | Query Param | `?forceMinLoading=1500` | Page-wide effect
unless attribute overrides | | Global Flag | `window.__ACORN_FLAGS = {
forceMinLoading: 2000 }` | Page (set before element upgrades) | Providing a
value of `0` removes the artificial delay entirely. ### 12.1.3 New Events &
Marks Instrumentation events (`calc-phase`) and performance marks are emitted
for deeper diagnostics and synthetic monitoring correlation. | Phase |
Description | Performance Marks | |-------|-------------|-------------------| |
init | Constructor/connected | `calc-init` | | rates-requested | Fetch started |
`calc-rates-requested` | | rates-loaded | Fetch resolved | `calc-rates-loaded`,
measure `calc-rates-fetch` | | payment-computed | Monthly payment calculated |
`calc-payment-computed`, measure `calc-time-to-payment` | | loading-cleared |
Skeleton removed | `calc-loading-cleared`, measure
`calc-time-to-loading-cleared` | ### 12.1.4 Consuming Lifecycle Events ```js
const calc = document.querySelector('af-calculator-open-component');
calc.addEventListener('calc-phase', e => { const { phase, timestamp } =
e.detail; console.log('[calc-phase]', phase, timestamp.toFixed(1)); });
calc.addEventListener('payment-ready', e => { console.log('Payment ready:',
e.detail.monthlyPayment); });
````

### 12.1.5 Waiting for Payment Programmatically

You can either listen for `payment-ready` or use the helper utility described below (Section 12.1.7).

### 12.1.6 Testing Guidance

Prefer event-driven assertions:

```js
await page.waitForFunction(() => {
  const el = document.querySelector("af-calculator-open-component");
  return el && !el.loading && el.monthlyPayment > 0;
});
```

If you require a stable skeleton for screenshot baselines, set `data-force-min-loading="1500"` on the test instance.

### 12.1.7 Helper Utility: awaitPaymentReady

An optional utility has been added at `src/utils/awaitPaymentReady.js`:

```js
import { awaitPaymentReady } from "./src/utils/awaitPaymentReady.js";

const el = document.querySelector("af-calculator-open-component");
await awaitPaymentReady(el, { timeout: 5000 });
console.log("Ready with payment:", el.monthlyPayment);
```

---

---

## 13. Extensibility Ideas

| Enhancement             | Notes                                |
| ----------------------- | ------------------------------------ |
| Expose `part` names     | Easier theming for partners          |
| Custom payment strategy | Provide callback / strategy prop     |
| Min/max overrides       | Attribute-configurable constraints   |
| i18n formatting         | `Intl.NumberFormat` with locale prop |
| Rate caching            | session/localStorage TTL             |

---

## 14. Troubleshooting

| Issue                      | Cause                     | Resolution                                 |
| -------------------------- | ------------------------- | ------------------------------------------ |
| `process is not defined`   | Bundler didn’t inject env | Use shim (`window.__ACORN_ENV`)            |
| Link always shows 10000    | Invalid total             | Ensure total within range                  |
| Modal stays mounted always | Previous build            | New build lazy loads modal                 |
| No `rates-loaded` event    | Not scrolled into view    | IntersectionObserver threshold not met yet |

---

## 15. Build & Continuous Integration

### 15.1 Build Commands

| Purpose                                      | Command         |
| -------------------------------------------- | --------------- |
| One-off production bundle                    | `npm run build` |
| Dev server (serves demo + auto rebuild dist) | `npm run start` |
| Rollup watch only (no dev server)            | `npm run dev`   |

Artifacts (after build):

- `dist/af-calculator-open-component.min.js` (minified ESM bundle)
- Source maps (if configured in Rollup output)

### 15.2 Auto-Build Plugin

`web-dev-server.config.js` contains a small plugin that watches `src/**/*.js` and triggers `npm run build` with a ~250ms debounce. This keeps the `dist` bundle fresh for any page loading the minified output while you develop primarily against source modules.

Adjustments:

- Change debounce: edit the `250` ms value in the plugin.
- Disable: remove the plugin from `plugins:` array.
- Extend to CSS: add an additional regex test for `\.css$`.

### 15.3 Suggested CI Workflow (GitHub Actions example)

```yaml
name: build
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint --if-present
      - run: npm run build
      # Optional: size report
      - run: ls -lh dist
```

### 15.4 Size / Budget Checks (Optional)

- Add a tool like `size-limit` or a Rollup plugin to enforce max bundle size (e.g., < 30KB gzip).
- Fail CI if bundle exceeds threshold to avoid regressions.

### 15.5 Performance Regression Ideas

- Run Lighthouse CI against `demo/test.html` (fast + throttled modes).
- Track LCP element paint time; store historical JSON for comparison.

### 15.6 Troubleshooting Auto-Build

| Symptom                            | Cause                               | Fix                                                                                                                                   |
| ---------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Build runs repeatedly in a loop    | Dist output also watched            | Exclude `dist/` or serve only `src/` paths; plugin already only watches `src` but ensure no other watcher triggers rebuild on `dist`. |
| CPU high during idle               | Rapid file change events on some OS | Increase debounce (e.g., 800ms) or switch to Rollup watch mode (`npm run dev`).                                                       |
| Dev server doesn’t reflect changes | Browser caching or build error      | Check terminal for Rollup errors; hard refresh / disable cache.                                                                       |
| Need faster incremental feedback   | Full rebuild cost                   | Consider swapping to Rollup `--watch` only and load from `src/` during dev.                                                           |

---

## 16. Measurement Plan (Core Web Vitals)

| Tool             | Focus              | Notes                                      |
| ---------------- | ------------------ | ------------------------------------------ |
| Lighthouse       | LCP / CLS / TBT    | Mobile, 3× CPU slowdown                    |
| WebPageTest      | Filmstrip & LCP    | Include Web Vitals capture                 |
| Search Console   | Field LCP status   | Monitor post deploy                        |
| Chrome UX Report | Field distribution | Needs traffic                              |
| Performance API  | Dev debugging      | Inspect `largest-contentful-paint` entries |

Post‑deploy checklist:

1. Hero image (or H1) confirmed LCP, compressed & dimensioned.
2. First calculator paint < ~1s (lab cable‑fast) with deferred rates.
3. No blocking third‑party scripts ahead of component.
4. No console errors (env shim working).
5. `rates-loaded` fires only after viewport intersection.

---

## 17. License / Usage

Internal demo component for Acorn Finance partner embedding. Adjust disclosures text per legal guidance before production.

---

## 18. TODO / Roadmap (Updated)

Legend: ✅ done | 📝 planned | 💤 archived

| #   | Item                                          | Status | Notes                             |
| --- | --------------------------------------------- | ------ | --------------------------------- |
| 1   | Dynamic offers link whitelist & caching       | ✅     | Uses `offersHref`                 |
| 2   | Deferred rates fetch (idle + IO)              | ✅     | Implemented                       |
| 3   | Environment variable shim                     | ✅     | Multi-source                      |
| 4   | Lazy modal rendering                          | ✅     | Insert only when opened           |
| 5   | Microtask batching for href recompute         | ✅     | queueMicrotask strategy           |
| 6   | Remove unnecessary reflected attributes       | 📝     | Audit needed                      |
| 7   | SSR skeleton placeholder                      | 📝     | Perceived speed                   |
| 8   | Rate fetch caching (session/localStorage TTL) | 📝     | Reduce repeat network             |
| 9   | Unit tests (parsing / payment / validation)   | 📝     | Web Test Runner / Jest            |
| 10  | Lighthouse perf budget in CI                  | 📝     | Prevent regressions               |
| 11  | Accessibility: focus trap + aria-modal        | 📝     | Modal enhancement                 |
| 12  | i18n number / currency formatting             | 📝     | Locale prop                       |
| 13  | Duplicate param cleanup (`subPurpose`)        | ✅     | Removed duplicate key             |
| 14  | WordPress embed guide                         | ✅     | Added section & snippet           |
| 15  | Measurement plan docs                         | ✅     | Section 15                        |
| 16  | Hero image optimization guidance              | ✅     | Preconnect + preload snippet      |
| 17  | Placeholder `--` until rates resolve          | ✅     | Avoids speculative pre-rate value |
| 18  | Offers link build on demand only              | ✅     | Cached + lazy updates             |
| 19  | Refine reflection (only needed attrs)         | 📝     | Perf improvement                  |
| 20  | Forced mobile preview attribute               | ✅     | CSS-only simulation for demos     |

### (Legacy) Original Roadmap (Archived – condensed)

Items 5 & 8 (demos, initial link rewrite) now superseded by current optimizations.

---

## 19. Future Events (Potential)

- `rates-error` (on fetch failure)
- `payment-recalculated` (after successful amortization recompute)

---

Maintained by: Acorn Finance Front-End / Partner Integrations Team  
Questions: tech-support@acornfinance.com

---

## Appendix A: Quasar / Vue 3 Quick Embed (CLS-Safe)

Add to `public/index.html` (Quasar CLI) or top-level HTML template:

```html
<link rel="preconnect" href="https://fs.acornfinance.com" crossorigin />
<script
  type="module"
  src="https://qa.your.acornfinance.com/widgets/calculator-open/src/af-calculator-open-component.js"
></script>
<script>
  (function () {
    const hasLoanAmount = new URLSearchParams(location.search).has(
      "loanAmount",
    );
    function setLayout() {
      const el = document.querySelector("af-calculator-open-component");
      if (!el) {
        requestAnimationFrame(setLayout);
        return;
      }
      if (!el.hasAttribute("layout")) {
        el.setAttribute("layout", hasLoanAmount ? "additional" : "standard");
      }
    }
    setLayout();
  })();
</script>
<style>
  :root {
    --calc-standard-desktop: 306px;
    --calc-standard-mobile: 398px;
    --calc-additional-desktop: 319px;
    --calc-additional-mobile: 414px;
    --calc-fallback: 398px;
  }
  af-calculator-open-component,
  af-calculator-open-component[loading] {
    display: block;
    min-height: var(--calc-fallback);
  }
  af-calculator-open-component[layout="standard"],
  af-calculator-open-component[layout="standard"][loading] {
    min-height: var(--calc-standard-desktop);
  }
  af-calculator-open-component[layout="additional"],
  af-calculator-open-component[layout="additional"][loading] {
    min-height: var(--calc-additional-desktop);
  }
  @media (max-width: 640px) {
    af-calculator-open-component[layout="standard"],
    af-calculator-open-component[layout="standard"][loading] {
      min-height: var(--calc-standard-mobile);
    }
    af-calculator-open-component[layout="additional"],
    af-calculator-open-component[layout="additional"][loading] {
      min-height: var(--calc-additional-mobile);
    }
  }
</style>
```

Vue template usage:

```vue
<template>
  <q-page padding>
    <h2>Financing Calculator</h2>
    <af-calculator-open-component
      loanAmount="18000"
      d="QSR-001"
      subPurpose="Roofing"
      cname="Quasar Demo Contractor"
      pcid="5555"
      processEnv="QA"
    >
    </af-calculator-open-component>
  </q-page>
</template>
```

Tell Vue compiler to treat it as custom element (Quasar Vite config):

```js
// quasar.config.js
build: {
  vueOptions: {
    compilerOptions: {
      isCustomElement: (tag) => tag === "af-calculator-open-component";
    }
  }
}
```

Event example:

```js
onMounted(() => {
  const el = document.querySelector("af-calculator-open-component");
  el.addEventListener("validity-change", (e) =>
    console.log("Valid?", e.detail.isValid),
  );
});
```

---

## Appendix B: Condensed Copy/Paste Block (Universal Embed)

Use this minimal block (HTML + CSS + early layout) for any platform (WordPress, Vue, plain HTML). Adjust `loanAmount`, `d`, and environment host as needed.

```html
<!-- Acorn Finance Open Calculator (CLS-safe embed) -->
<link rel="preconnect" href="https://fs.acornfinance.com" crossorigin />
<script
  type="module"
  src="https://qa.your.acornfinance.com/widgets/calculator-open/src/af-calculator-open-component.js"
></script>
<style>
  :root {
    --calc-standard-desktop: 306px;
    --calc-standard-mobile: 398px;
    --calc-additional-desktop: 319px;
    --calc-additional-mobile: 414px;
    --calc-fallback: 398px;
  }
  af-calculator-open-component,
  af-calculator-open-component[loading] {
    display: block;
    min-height: var(--calc-fallback);
  }
  af-calculator-open-component[layout="standard"],
  af-calculator-open-component[layout="standard"][loading] {
    min-height: var(--calc-standard-desktop);
  }
  af-calculator-open-component[layout="additional"],
  af-calculator-open-component[layout="additional"][loading] {
    min-height: var(--calc-additional-desktop);
  }
  @media (max-width: 640px) {
    af-calculator-open-component[layout="standard"],
    af-calculator-open-component[layout="standard"][loading] {
      min-height: var(--calc-standard-mobile);
    }
    af-calculator-open-component[layout="additional"],
    af-calculator-open-component[layout="additional"][loading] {
      min-height: var(--calc-additional-mobile);
    }
  }
</style>
<script>
  (function () {
    const hasLoanAmount = new URLSearchParams(location.search).has(
      "loanAmount",
    );
    function setLayout() {
      const el = document.querySelector("af-calculator-open-component");
      if (!el) {
        requestAnimationFrame(setLayout);
        return;
      }
      if (!el.hasAttribute("layout")) {
        el.setAttribute("layout", hasLoanAmount ? "additional" : "standard");
      }
    }
    setLayout();
  })();
</script>
<af-calculator-open-component
  loanAmount="15000"
  d="EMBED-001"
  processEnv="QA"
></af-calculator-open-component>
```

Notes:

- Early script optional if you always set `layout` explicitly.
- Remove or adjust `processEnv` for production.
- Add tracking params (`utm_source`, etc.) as attributes or rely on query params.
