Build Your Own Savings Calculator

Make this calculator yours — your rates, your pricing, your logo.

A working one, before you build yours

Two questions. Everything else is an assumption you can open up and correct — because the assumptions are where these calculators go wrong, not the arithmetic.

your bill today

—

with solar, all in

—

—

what you spend either way, adding up

kept over 25 years

—

after paying for the system

ahead from

—

when the lines cross

system size

—

installed cost

—

the whole thing, as one file
show the code — or just hit copy
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Solar savings estimate</title>
<style>
/* atlasautomates.com — site token artifact
   Derived from .claude/design/design-systems/atlasautomates/ (schema v3, bible §2).
   Deltas from house artifact: hue channels swapped to Miami Vice pair
   (blue 229.5 = #41B6E6, pink 356.2 = #F04E98, converted to OKLCH);
   bg/border set re-tinted to the view hue at whisper chroma; glow token added. */
:root {
  /* ---------- Hue channels ---------- */
  --view-hue: 229.5;            /* vice blue — identity hue, all views */
  --role-hue: 356.2;            /* vice pink — role: direct line out to Adrian */

  /* ---------- Backgrounds (persistent world, whisper view-hue tint) ---------- */
  --bg-root: oklch(0.125 0.008 var(--view-hue));
  --bg-surface: oklch(0.155 0.010 var(--view-hue));
  --bg-elevated: oklch(0.185 0.012 var(--view-hue));
  --bg-overlay: oklch(0.088 0.010 var(--view-hue) / 0.85);

  /* ---------- Text ---------- */
  --text-primary: oklch(0.957 0.005 var(--view-hue));
  --text-secondary: oklch(0.815 0.015 var(--view-hue));
  --text-muted: oklch(0.560 0.020 var(--view-hue));
  --text-ghost: oklch(0.360 0.018 var(--view-hue));

  /* ---------- Borders ---------- */
  --border-subtle: oklch(0.200 0.012 var(--view-hue));
  --border-default: oklch(0.260 0.018 var(--view-hue));
  --border-hover: oklch(0.330 0.035 var(--view-hue));

  /* ---------- Accent family (identity — interaction states only) ---------- */
  --accent: oklch(0.731 0.123 var(--view-hue));          /* vice blue solid */
  --accent-strong: oklch(0.816 0.130 var(--view-hue));   /* hover emphasis */
  --accent-bg: oklch(0.731 0.123 var(--view-hue) / 0.07);
  --accent-border: oklch(0.731 0.123 var(--view-hue) / 0.30);

  /* ---------- Role accent (demoted, persistent meaning) ---------- */
  --accent-role: oklch(0.667 0.170 var(--role-hue));     /* vice pink, demoted C */
  --accent-role-strong: oklch(0.720 0.208 var(--role-hue));

  /* ---------- Glow — the ONE special moment per view (bible §5) ---------- */
  --glow-accent: 0 0 20px oklch(0.731 0.123 var(--view-hue) / 0.45);

  /* ---------- Chart ramp (sequential, view hue) ----------
     Bible §2 allows ONE accent hue per view, so revenue segments are a
     sequential ramp in the identity hue rather than a categorical palette:
     lightness encodes magnitude, and identity is carried by direct labels on
     every segment (never by colour alone).

     Validated against the dark chart surface with the dataviz validator:
     monotonic lightness 0.86 > 0.74 > 0.63 > 0.545, contrast 12.9 / 8.7 / 5.8 /
     4.1 : 1 (all >= 3:1), worst adjacent CVD dE 8.4 deutan — above the floor and
     paired with direct labels + 2px gaps as secondary encoding. */
  --chart-1: oklch(0.860 0.105 var(--view-hue));
  --chart-2: oklch(0.740 0.125 var(--view-hue));
  --chart-3: oklch(0.630 0.120 var(--view-hue));
  --chart-4: oklch(0.545 0.105 var(--view-hue));

  /* ---------- Typography ---------- */
  --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  --font-sans: 'Inter', 'DM Sans', ui-sans-serif, system-ui, -apple-system, sans-serif;

  --text-xs: 0.6875rem;
  --text-sm: 0.8125rem;
  --text-base: 1rem;
  --text-lg: 1.25rem;
  --text-xl: 1.5rem;
  --text-hero: clamp(2.25rem, 7vw, 3rem);

  --leading-tight: 1.25;
  --leading-normal: 1.5;
  --leading-relaxed: 1.6;

  --weight-normal: 400;
  --weight-medium: 500;
  --weight-semibold: 600;

  /* ---------- Spacing (8px grid) ---------- */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --space-6: 2rem;
  --space-8: 2.5rem;
  --space-10: 4rem;
  --space-12: 6rem;

  /* ---------- Layout ---------- */
  --column-max: 36rem;
  --column-max-doc: 48rem;      /* long-form prose measure (/made/solar/) */
  --column-max-wide: 62rem;     /* prompt-block breakout (/made/solar/) */
  --column-padding: var(--space-5);
  --tap-min: 44px;              /* minimum touch-target edge (RS02) */

  /* ---------- Radius ---------- */
  --radius-sm: 6px;
  --radius-md: 8px;
  --radius-lg: 12px;

  /* ---------- Depth ---------- */
  --z-base: 0;
  --z-modal: 1000;
  --shadow-modal: 0 12px 40px oklch(0 0 0 / 0.3);

  /* ---------- Motion ---------- */
  --duration-fast: 150ms;
  --duration-normal: 200ms;
  --duration-medium: 300ms;
  --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);

  /* ---------- Breakpoints (reference) ---------- */
  --bp-phone: 480px;
}

/* Pre-OKLCH engines (Safari <15.4, Chrome <111, FF <113): without this the
   accent/bg declarations go invalid-at-computed-value and the page inverts to
   the UA's white canvas. Hex values below are the exact OKLCH conversions —
   fallback definitions live in the token file, so build code stays literal-free. */
@supports not (color: oklch(0 0 0)) {
  :root {
    --bg-root: #040709;
    --bg-surface: #080D10;
    --bg-elevated: #0D1417;
    --bg-overlay: rgba(1, 3, 4, 0.85);
    --text-primary: #EDF1F3;
    --text-secondary: #B9C5CA;
    --text-muted: #69777E;
    --text-ghost: #343F45;
    --border-subtle: #11171A;
    --border-default: #1B262B;
    --border-hover: #223944;
    --accent: #41B6E6;
    --accent-strong: #58D2FF;
    --accent-bg: rgba(65, 182, 230, 0.07);
    --accent-border: rgba(65, 182, 230, 0.30);
    --accent-role: #E26097;
    --accent-role-strong: #FF60A8;
    --glow-accent: 0 0 20px rgba(65, 182, 230, 0.45);
    --chart-1: #83DEFF;
    --chart-2: #42B9EA;
    --chart-3: #1796C3;
    --chart-4: #0D7BA1;
    --shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.3);
  }
}


/* minimal host styles — drop these if your own stylesheet already sets them */
*, *::before, *::after { box-sizing: border-box; }
body {
  margin: 0;
  background: var(--bg-root);
  color: var(--text-primary);
  font-family: var(--font-sans);
  line-height: var(--leading-relaxed);
}
.wrap { max-width: 48rem; margin: 0 auto; padding: var(--space-8) var(--space-5); }
h2 { margin-top: 0; }
p { color: var(--text-secondary); }
strong { color: var(--text-primary); }
hr { border: 0; border-top: 1px solid var(--border-subtle); margin: var(--space-8) 0; }
.visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}

/* Calculator A — per-lead database value page (/for/<slug>/).
   Extends solar.css. Same tokens, same hues; no colour literals.
   Chart colours come from the validated sequential ramp in tokens.css. */

.calc-eyebrow {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-ghost);
  margin: 0 0 var(--space-2);
}

.calc-h {
  font-size: var(--text-base);
  font-weight: var(--weight-semibold);
  color: var(--text-muted);
  letter-spacing: 0.02em;
  margin: 0 0 var(--space-3);
  padding: 0;
  border: 0;
}

/* ---------- Inputs ---------- */

.calc-input {
  margin: var(--space-8) 0;
  padding: var(--space-5);
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
}

.field-row { margin-top: var(--space-4); }

.field-row--split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-4);
}

.calc-field {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  position: relative;
}

.calc-label {
  font-size: var(--text-sm);
  color: var(--text-secondary);
}

.calc-num {
  font-family: var(--font-mono);
  font-size: var(--text-lg);
  color: var(--text-primary);
  background: var(--bg-root);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  padding: var(--space-3);
  min-height: var(--tap-min);
  width: 100%;
  transition: border-color var(--duration-fast) var(--ease-smooth);
}

.calc-num:hover { border-color: var(--border-hover); }

.calc-num:focus-visible {
  outline: none;
  border-color: var(--accent-border);
  box-shadow: var(--glow-accent);
}

.calc-suffix {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  color: var(--text-ghost);
  margin-left: var(--space-1);
}

/* ---------- Output ---------- */

.calc-out { margin: var(--space-8) 0; }

/* The one display-type moment on this page (bible §4). */
.calc-total {
  font-size: var(--text-hero);
  font-weight: var(--weight-semibold);
  line-height: var(--leading-tight);
  letter-spacing: -0.03em;
  color: var(--text-primary);
  margin: 0;
  font-variant-numeric: tabular-nums;
}

.calc-total-note {
  font-size: var(--text-sm);
  color: var(--text-ghost);
  margin: var(--space-2) 0 var(--space-5);
}

/* ---------- Stacked bar ----------
   Part-to-whole rides on a stacked bar, not a pie: the four category names are
   long, the segments must be clickable, and bars carry magnitude as well as
   proportion. 2px surface gaps between fills per the dataviz mark spec. */
.calc-bar {
  display: flex;
  gap: 2px;
  height: 14px;
  margin: 0 0 var(--space-5);
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--bg-surface);
}

.calc-seg {
  flex-basis: 0;
  min-width: 2px;
  transition: flex-grow var(--duration-medium) var(--ease-smooth);
}

@media (prefers-reduced-motion: reduce) {
  .calc-seg { transition: none; }
}

/* ---------- Line rows ---------- */

.calc-lines {
  list-style: none;
  margin: 0 0 var(--space-6);
  padding: 0;
}

.calc-lines li { margin: 0; }

.calc-line {
  display: grid;
  grid-template-columns: auto 1fr auto auto;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) 0;
  min-height: var(--tap-min);
  border-bottom: 1px solid var(--border-subtle);
  text-decoration: none;
  position: relative;
  /* Not the prose wipe: this is a block row, same treatment as .play. */
  background-image: none;
}

.calc-line::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -1px;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--duration-medium) var(--ease-smooth);
}

.calc-line:hover::after,
.calc-line:focus-visible::after { transform: scaleX(1); }

.calc-swatch {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  flex: none;
}

.calc-line-name {
  font-size: var(--text-sm);
  color: var(--text-primary);
}

.calc-line-val {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

.calc-line-pct {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--text-ghost);
  font-variant-numeric: tabular-nums;
  min-width: 3ch;
  text-align: right;
}

.calc-line-go {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--text-ghost);
  white-space: nowrap;
  transition: color var(--duration-fast) var(--ease-smooth);
}

.calc-line:hover .calc-line-go,
.calc-line:focus-visible .calc-line-go { color: var(--accent); }

/* ---------- Presets ---------- */

.calc-presets {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.calc-preset-label {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-ghost);
  margin-right: var(--space-2);
}

.preset,
.calc-action {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: 0.06em;
  color: var(--text-muted);
  background: none;
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  padding: 0 var(--space-3);
  min-height: var(--tap-min);
  cursor: pointer;
  transition: color var(--duration-fast) var(--ease-smooth),
              border-color var(--duration-fast) var(--ease-smooth);
}

.preset:hover, .preset:focus-visible,
.calc-action:hover, .calc-action:focus-visible {
  color: var(--accent);
  border-color: var(--accent-border);
}

.preset[aria-pressed="true"] {
  color: var(--accent);
  border-color: var(--accent-border);
  background: var(--accent-bg);
}

/* ---------- Assumptions ---------- */

.calc-assume {
  margin: var(--space-6) 0;
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-subtle);
}

.calc-assume summary {
  font-size: var(--text-sm);
  color: var(--text-secondary);
  cursor: pointer;
  min-height: var(--tap-min);
  display: flex;
  align-items: center;
}

.calc-assume summary:hover { color: var(--accent); }

.calc-mini {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  color: var(--text-primary);
  background: var(--bg-root);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
  padding: var(--space-1) var(--space-2);
  width: 6.5ch;
}

.calc-mini:focus-visible {
  outline: none;
  border-color: var(--accent-border);
}

.calc-actions {
  display: flex;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin: var(--space-6) 0;
}

/* ---------- Sources ---------- */

.calc-sources {
  margin-top: var(--space-10);
  padding-top: var(--space-6);
  border-top: 1px solid var(--border-subtle);
}

.calc-sources ol {
  padding-left: var(--space-5);
  font-size: var(--text-sm);
}

.calc-sources li { color: var(--text-ghost); margin-bottom: var(--space-2); }

.calc-sources li a { color: var(--text-muted); }

.calc-sources li:target a { color: var(--accent); }

.calc-disclaimer {
  font-size: var(--text-sm);
  color: var(--text-ghost);
  margin-top: var(--space-5);
}

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* ---------- Responsive ---------- */

@media (max-width: 480px) {
  .field-row--split { grid-template-columns: 1fr; }

  .calc-line {
    grid-template-columns: auto 1fr auto;
    row-gap: var(--space-1);
  }
  .calc-line-pct { display: none; }
  .calc-line-go { grid-column: 2 / -1; }
}

/* ---------- Print / PDF ----------
   The page is a deliverable they keep, so printing must produce the numbers,
   not a dark rectangle. Controls drop; content stays. */
@media print {
  .calc-actions,
  .calc-presets,
  .crumb { display: none; }

  .calc-assume { display: block; }
  .calc-assume summary { display: none; }

  .calc-input,
  .calc-bar { background: none; border: 1px solid currentColor; }
}

/* ---------- Partner table (per-lead rev-share calculators) ----------
   Named-partner rows are the personalization, so the table is the primary
   content rather than a supporting detail. Scrolls inside its own container so
   the page body never scrolls sideways at 320px. */

.partners { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }

.partners th {
  text-align: left;
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--border-default);
  color: var(--text-muted);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  white-space: nowrap;
}

.partners td {
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--border-subtle);
  vertical-align: middle;
}

.p-name { color: var(--text-primary); font-weight: var(--weight-medium); white-space: nowrap; }
.p-trade { color: var(--text-ghost); font-size: var(--text-xs); white-space: nowrap; }

.p-val {
  font-family: var(--font-mono);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  text-align: right;
  white-space: nowrap;
}

/* Inbound is the column the page exists to make visible — one returned install
   outweighs many outbound commissions, and that asymmetry IS the argument. */
.p-val--strong { color: var(--accent); font-weight: var(--weight-medium); }

.partners .calc-mini { width: 7ch; }

/* Two totals, deliberately unequal in weight: inbound is the finding, outbound
   is the thing everyone already assumes. */
.split {
  display: grid;
  grid-template-columns: 1.6fr 1fr;
  gap: var(--space-5);
  margin: var(--space-6) 0;
  align-items: start;
}

.split-label {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-ghost);
  margin: 0 0 var(--space-2);
}

.split-primary .calc-total { color: var(--text-primary); }

.split-secondary .calc-total {
  font-size: var(--text-xl);
  color: var(--text-secondary);
}

@media (max-width: 480px) {
  .split { grid-template-columns: 1fr; gap: var(--space-5); }
  .p-trade { display: none; }
}

/* ---------- Wide scrollable table ----------
   Seven columns do not fit the 48rem prose measure, and a plain `overflow-x:
   auto` container gives NO signal that content continues — it just looks like a
   cut-off table. Two fixes, both needed:

   1. Break out well past the prose column, up to 84rem, so most viewports show
      every column without scrolling at all.
   2. Where it still overflows, show a real scroll shadow. The layered-gradient
      technique does this with no JS: the `local` layers scroll WITH the content
      and mask the shadow when you reach an edge, while the `scroll` layers stay
      pinned — so a shadow appears on exactly the side that has more content and
      disappears when there is none. Self-correcting, unlike a static fade. */
.table-wrap--wide {
  --break: calc((min(84rem, 100vw - (2 * var(--column-padding))) - var(--column-max-doc)) / 2);
  width: calc(100% + (2 * max(0px, var(--break))));
  margin-left: calc(-1 * max(0px, var(--break)));
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-x: contain;

  background-image:
    linear-gradient(to right, var(--bg-root), transparent),
    linear-gradient(to left, var(--bg-root), transparent),
    linear-gradient(to right, var(--border-hover), transparent),
    linear-gradient(to left, var(--border-hover), transparent);
  background-position: 0 0, 100% 0, 0 0, 100% 0;
  background-repeat: no-repeat;
  background-size: 44px 100%, 44px 100%, 12px 100%, 12px 100%;
  background-attachment: local, local, scroll, scroll;
}

/* Sticky first column: the partner name is the row's identity, so it must stay
   readable while the numbers scroll under it. */
.partners tbody .p-name,
.partners thead th:first-child {
  position: sticky;
  left: 0;
  background: var(--bg-root);
  z-index: 1;
}

.scroll-hint {
  display: none;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--text-ghost);
  margin: 0 0 var(--space-2);
}

/* Shown only where the table cannot fit — an instruction that is wrong most of
   the time trains people to ignore it. */
@media (max-width: 60rem) {
  .scroll-hint { display: block; }
}

/* ---------- Number inputs in the partner table ----------
   `width: 7ch` looked sufficient for "18000" and was not: a native number input
   reserves ~18px for spinner arrows INSIDE the content box, so a 7ch field
   rendered about three characters and silently truncated every value on screen.
   The stored number was always correct — it just displayed as "18" — which is
   the worst kind of wrong on a page whose job is showing someone their numbers.

   Spinners are removed rather than accommodated: stepping to 18000 in 100s is
   not a real interaction, and reclaiming the width is what makes the value
   readable. Keyboard up/down still works. */
.partners .calc-mini { width: 9ch; text-align: right; }

/* No spinners on ANY number input here. They eat width, they invite an
   accidental step, and stepping to 18000 in increments was never a real
   interaction. Arrow-key stepping is separately blocked in JS, so the control
   and the keyboard now agree. */
.calc-num,
.calc-mini {
  -moz-appearance: textfield;
  appearance: textfield;
}

.calc-num::-webkit-outer-spin-button,
.calc-num::-webkit-inner-spin-button,
.calc-mini::-webkit-outer-spin-button,
.calc-mini::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

/* Every editable figure gets the same accent-glow response on hover and focus.
   Bible §6: a control that gives no feedback reads as broken — and on a page
   whose whole invitation is "change these numbers", the fields need to look
   changeable before they are touched. */
.calc-mini {
  transition: border-color var(--duration-fast) var(--ease-smooth),
              box-shadow var(--duration-normal) var(--ease-smooth),
              color var(--duration-fast) var(--ease-smooth);
}

.calc-num:hover,
.calc-mini:hover {
  border-color: var(--accent-border);
  box-shadow: var(--glow-accent);
}

.calc-num:focus-visible,
.calc-mini:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: var(--glow-accent);
}

@media (prefers-reduced-motion: reduce) {
  .calc-mini { transition: none; }
}

/* ---------- Calculator B (homeowner savings) ---------- */

.widget {
  margin: var(--space-8) 0 var(--space-10);
  padding: var(--space-6);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
}

/* The live tool and the prompt that teaches you to build one are two different
   kinds of thing on the same page. One box, one border, one job — so the reader
   never has to work out where the demo stops and the instructions start.
   Bible §3: separation by whitespace first, then a border. Both are earned here
   because the two sections are genuinely different in kind, not just in order. */
.widget > :first-child { margin-top: 0; }
.widget > :last-child { margin-bottom: 0; }

/* Inner surfaces step DOWN to the page background instead of stacking another
   card on top of this one — boxes inside boxes read as clutter, not hierarchy. */
.widget .calc-input {
  background: var(--bg-root);
  border-color: var(--border-subtle);
}

.widget .sc-hero {
  background: var(--bg-elevated);
  border-color: var(--border-default);
}

/* The export block belongs to the widget, so it sits inside the box and loses
   its own breakout. */
.widget .prompt {
  width: 100%;
  margin-left: 0;
  background: var(--bg-root);
}

.sc-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--space-5);
  margin: var(--space-6) 0;
}

.sc-cell { min-width: 0; }

.sc-fig {
  font-family: var(--font-mono);
  font-size: var(--text-lg);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
  margin: 0 0 var(--space-1);
}

/* ---------------------------------------------------------------------------
   TIER 1 — the hero.

   One comparison, and from a cold screenshot it has to be the thing a stranger
   names first (bible §1). It gets the page's only display type, the highest
   surface, and the page's ONLY glow — §5 allows exactly one emitting element,
   and this is it.

   The figure the reader actually came for is the RIGHT-hand one: what they would
   pay all in. The old bill is deliberately quieter, because it is the reference
   point rather than the news. */
.sc-hero {
  margin: var(--space-6) 0;
  padding: var(--space-6) var(--space-5);
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
}

.sc-hero-pair {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: start;
  gap: var(--space-4);
}

.sc-side { min-width: 0; }

.sc-big {
  font-family: var(--font-mono);
  font-size: var(--text-hero);
  line-height: var(--leading-tight);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
  margin: var(--space-2) 0 var(--space-2);
}

.sc-big--old { color: var(--text-secondary); }

.sc-big--new {
  color: var(--accent);
  text-shadow: var(--glow-accent);
}

.sc-side-note {
  font-size: var(--text-xs);
  color: var(--text-muted);
  line-height: var(--leading-normal);
  margin: 0;
}

.sc-arrow {
  font-size: var(--text-xl);
  color: var(--text-ghost);
  align-self: center;
  padding-top: var(--space-4);
}

/* The verdict states the uncomfortable case in the same voice as the
   comfortable one — see verdict() in savings-calc.js. It is the sentence that
   decides whether the page is honest, so it is not shrunk into a footnote. */
.sc-verdict {
  margin: var(--space-5) 0 0;
  padding-top: var(--space-4);
  border-top: 1px solid var(--border-subtle);
  font-size: var(--text-base);
  color: var(--text-secondary);
  line-height: var(--leading-relaxed);
}

.sc-hero--up .sc-big--new { color: var(--accent-strong); }

/* ---------------------------------------------------------------------------
   TIER 2 — the crossover chart.

   Two bars can only describe one year. The cumulative pair plus the year they
   cross is the object that survives follow-up questions, so it sits directly
   under the hero and nothing else on the page is a filled area. */
.sc-figure {
  margin: var(--space-6) 0;
  padding: var(--space-5);
  background: var(--bg-root);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
}

.sc-figcap {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
  margin-bottom: var(--space-4);
}

.sc-cross-note {
  font-size: var(--text-xs);
  font-family: var(--font-mono);
  color: var(--accent);
}

.sc-chart svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}

/* The gap after the crossing is the reader's money — the only fill on the page,
   and the reason the chart is here at all. */
.sc-gap {
  fill: var(--accent);
  opacity: 0.16;
}

.sc-line {
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* One hue, two lightness steps: the cost that recedes and the one that matters
   (bible §2 — lightness carries hierarchy, hue never moves). The ramp's own
   endpoints are not far enough apart at a 2px stroke — both lines read as the
   same blue — so the receding one also drops opacity. Hierarchy has to survive
   being squinted at. */
.sc-line--nothing { stroke: var(--chart-4); opacity: 0.5; }
.sc-line--solar { stroke: var(--chart-1); }

.sc-cross {
  stroke: var(--border-default);
  stroke-width: 1;
  stroke-dasharray: 3 4;
}

.sc-dot {
  fill: var(--chart-1);
  stroke: var(--bg-root);
  stroke-width: 2;
}

/* Direct labels, so identity never rests on a legend the eye has to travel to. */
.sc-plabel {
  font-family: var(--font-sans);
  font-size: 13px;
  fill: var(--text-muted);
}

.sc-pval {
  font-family: var(--font-mono);
  font-size: 13px;
  font-variant-numeric: tabular-nums;
}

.sc-plabel--solar { fill: var(--text-secondary); }
.sc-pval--nothing { fill: var(--text-muted); }
.sc-pval--solar { fill: var(--chart-1); }

.sc-axis {
  font-family: var(--font-sans);
  font-size: 12px;
  fill: var(--text-ghost);
}

.sc-axis--end { text-anchor: end; }

/* ---------------------------------------------------------------------------
   Home value — hidden until an installer supplies both numbers. */
.sc-value {
  margin: var(--space-6) 0;
  padding: var(--space-5);
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
}

.sc-value-pair {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-3);
  margin: var(--space-2) 0;
}

.sc-value-fig {
  font-family: var(--font-mono);
  font-size: var(--text-xl);
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}

.sc-value-fig--new { color: var(--accent); }
.sc-value-arrow { color: var(--text-ghost); }

/* ---------------------------------------------------------------------------
   Rep mode — the installer's half of the file. */
.sc-rep {
  margin-top: var(--space-6);
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-subtle);
}

select.calc-num {
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
  /* Room for the chevron below, so a long option label stops short of it
     instead of running underneath and looking truncated by accident. */
  padding-right: var(--space-8);
  text-overflow: ellipsis;
}

/* `appearance: none` strips the native arrow, which leaves a control that reads
   as a text box until you click it — silent affordance is the same defect as
   silent interactivity (bible §6). Put it back on the label, in tokens. */
.calc-field:has(select) { position: relative; }

.calc-field:has(select)::after {
  content: '';
  position: absolute;
  right: var(--space-4);
  bottom: 1.15rem;
  width: 0.5rem;
  height: 0.5rem;
  border-right: 2px solid var(--text-muted);
  border-bottom: 2px solid var(--text-muted);
  transform: rotate(45deg);
  pointer-events: none;
  transition: border-color var(--duration-fast) var(--ease-smooth);
}

.calc-field:has(select:hover)::after,
.calc-field:has(select:focus-visible)::after {
  border-right-color: var(--accent);
  border-bottom-color: var(--accent);
}

.calc-label em {
  font-style: normal;
  color: var(--text-ghost);
}

/* Collapsed code block: the export is copyable without spending a screen on it. */
.prompt details { border: 0; margin: 0; }

.prompt details summary {
  padding: var(--space-3) var(--space-4);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--text-ghost);
  cursor: pointer;
}

.prompt details summary:hover { color: var(--accent); }

@media (max-width: 60rem) {
  .sc-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 480px) {
  .sc-grid { grid-template-columns: 1fr; gap: var(--space-4); }

  /* Stack the hero rather than squeezing three columns: at this width the
     side-by-side pair shrinks the display type below the tier-3 figures and
     inverts the hierarchy it exists to establish. */
  .sc-hero-pair { grid-template-columns: 1fr; gap: var(--space-2); }

  .sc-arrow {
    align-self: start;
    padding-top: 0;
    transform: rotate(90deg);
    transform-origin: left center;
    margin-left: var(--space-2);
  }

  .sc-side--new { margin-top: var(--space-3); }

  /* A 25-year projection needs room to be legible; on a phone the right-hand
     label column is a bigger share of the box, so give it back. */
  .sc-figure { padding: var(--space-4) var(--space-3); }
}

/* `.doc h2` carries a border-top + padding-top as a section divider. Inside the
   boxed widget that renders as a stray rule across the card. `.calc-h` already
   sets border:0 but loses on specificity (0,1,0 vs 0,1,1), so scope it properly
   rather than reaching for !important. */
.doc .calc-h,
.doc .widget h2 {
  border-top: 0;
  padding-top: 0;
}

/* ---------- Scope fixes: .doc descendant rules were winning ----------
   `.doc p` / `.doc h2` are (0,1,1); `.calc-total` / `.calc-h` are (0,1,0), so the
   document defaults silently beat the component. That is why the headline figure
   rendered at body size instead of display size, and why a section divider drew a
   rule across the card. Same root cause both times — scope the component rather
   than escalating with !important. */
.doc .calc-total {
  font-size: var(--text-hero);
  font-weight: var(--weight-semibold);
  line-height: var(--leading-tight);
  letter-spacing: -0.03em;
  color: var(--text-primary);
  margin: 0;
}

.doc .sc-fig { font-size: var(--text-lg); margin: 0 0 var(--space-1); }
.doc .split-label,
.doc .calc-total-note,
.doc .calc-disclaimer { margin-top: 0; }

/* Same trap, the hero's turn. Every one of these is a <p> inside .doc, so each
   needs the extra class to outrank the prose defaults — losing here collapses
   the display figure to body size and inverts the whole hierarchy. */
.doc .sc-big {
  font-size: var(--text-hero);
  line-height: var(--leading-tight);
  letter-spacing: -0.02em;
  margin: var(--space-2) 0;
}

.doc .sc-big--old { color: var(--text-secondary); }
.doc .sc-big--new { color: var(--accent); }
.doc .sc-hero--up .sc-big--new { color: var(--accent-strong); }

.doc .sc-side-note {
  font-size: var(--text-xs);
  line-height: var(--leading-normal);
  color: var(--text-muted);
  margin: 0;
}

.doc .sc-verdict {
  font-size: var(--text-base);
  color: var(--text-secondary);
  margin: var(--space-5) 0 0;
}

/* ---------- Boxed sections ---------- */

.card {
  margin: var(--space-6) 0;
  padding: var(--space-6);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
}

.card > :first-child { margin-top: 0; }
.card > :last-child { margin-bottom: 0; }
.card .calc-input { background: var(--bg-root); border-color: var(--border-subtle); }

/* The result is the page's one primary. It gets its own card, the accent hue and
   the display size — everything else on the page is supporting (bible §1). */
.card--result {
  background: var(--bg-elevated);
  border-color: var(--accent-border);
}

.card--result .calc-total { color: var(--accent); }

.result-primary { margin-bottom: var(--space-6); }

.result-secondary {
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-subtle);
}

.doc .result-secondary .calc-total {
  font-size: var(--text-xl);
  color: var(--text-secondary);
}

/* ---------- Live formula ----------
   Shows the arithmetic with THEIR numbers substituted, updating as they type. A
   static diagram would explain the method; this proves the number in front of
   them, which is what actually answers "where did that come from". */
.formula {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  margin: var(--space-3) 0 var(--space-5);
}

.f-term {
  display: inline-flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--space-2) var(--space-3);
  background: var(--bg-root);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
}

.f-val { color: var(--text-primary); font-variant-numeric: tabular-nums; }

.f-key {
  font-size: var(--text-xs);
  color: var(--text-ghost);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  white-space: nowrap;
}

.f-op { color: var(--text-ghost); padding: 0 var(--space-1); }

.f-term--out {
  background: var(--accent-bg);
  border-color: var(--accent-border);
}

.f-term--out .f-val { color: var(--accent); }

@media (max-width: 480px) {
  .card { padding: var(--space-5); }
  .formula { font-size: var(--text-xs); }
}

/* ---------- Two-direction column groups ----------
   The table carries two different kinds of money in one grid: a fee you collect,
   and a customer you gain. Grouping the columns under a spanning header, with a
   divider between the groups, is what makes the two-way street structural rather
   than something the reader has to infer from column names. */
.dir-row th { border-bottom: 0; padding-bottom: var(--space-1); }

.dir {
  font-size: var(--text-xs);
  letter-spacing: 0.04em;
  text-transform: none;
  font-weight: var(--weight-medium);
  white-space: nowrap;
}

.dir--out { color: var(--text-muted); }
.dir--in { color: var(--accent); }

/* One divider, on the boundary between the directions. */
.partners th.g-in:first-of-type,
.partners td.g-in:first-of-type,
.partners .dir--in { border-left: 1px solid var(--border-default); }

.partners .g-in { background: var(--accent-bg); }

.partners td.g-out,
.partners th.g-out { color: var(--text-secondary); }

/* ---------- Assumption groups ----------
   The assumptions panel grew from four fields to three distinct subjects, and an
   undifferentiated stack of eight inputs reads as a settings dump. Grouping is by
   whitespace and a label, not another box (bible §3): the panel is already inside
   the widget card, and a third nested border would be depth spent on nothing. */

.sc-group + .sc-group {
  margin-top: var(--space-8);
  padding-top: var(--space-6);
  border-top: 1px solid var(--border-subtle);
}

/* ---------- Checkbox ----------
   A native control, restyled only where it must be: the accent colour, and a tap
   target that clears 44px on a phone. The label is the target too — a checkbox
   you have to hit precisely is a checkbox people get wrong. */

.calc-check {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  min-height: var(--tap-min);
  padding: var(--space-2) 0;
  font-size: var(--text-sm);
  color: var(--text-secondary);
  cursor: pointer;
}

.calc-check input[type="checkbox"] {
  flex: none;
  width: 1.15rem;
  height: 1.15rem;
  margin: 0;
  accent-color: var(--accent);
  cursor: pointer;
}

.calc-check:hover { color: var(--text-primary); }

.calc-check input[type="checkbox"]:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.calc-check strong { color: var(--text-primary); }

/* Sub-label for a control whose wording carries a trap — here, "completed"
   rather than "paid". Ambient layer: present when looked for, quiet otherwise. */
.calc-hint {
  font-size: var(--text-sm);
  color: var(--text-ghost);
  margin: var(--space-2) 0 0;
}

.calc-hint em { font-style: normal; color: var(--text-muted); }

/* The live consequence of the two controls above it. It changes on every input,
   so it sits at reading weight rather than ghost — it is the answer, not a
   footnote. */
.sc-note {
  font-size: var(--text-sm);
  color: var(--text-secondary);
  margin: var(--space-4) 0 0;
  padding-left: var(--space-4);
  border-left: 2px solid var(--accent-border);
}

/* ---------- Tax form walkthrough ----------
   Steps down to bg-root: it is a reference artifact quoted inside the panel, not
   a peer of the inputs. Recessed rather than elevated, because it is where you
   look AFTER the number convinces you (bible §5 — elevation is priority). */

.taxforms {
  margin-top: var(--space-6);
  padding: var(--space-5);
  background: var(--bg-root);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
}

.taxsteps {
  list-style: none;
  counter-reset: step;
  margin: var(--space-4) 0 0;
  padding: 0;
}

.taxsteps li {
  display: grid;
  grid-template-columns: 12rem 1fr auto;
  align-items: baseline;
  gap: var(--space-3);
  padding: var(--space-3) 0;
  margin: 0;
  border-bottom: 1px solid var(--border-subtle);
}

.taxsteps li:last-child { border-bottom: 0; padding-bottom: 0; }

.ts-ref {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.ts-what { font-size: var(--text-sm); color: var(--text-secondary); }

.ts-val {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
  text-align: right;
  white-space: nowrap;
}

/* Line 15 is the only figure on this list the taxpayer actually receives. It is
   the one thing here that earns the identity hue. */
.ts-val--out { color: var(--accent); }

@media (max-width: 480px) {
  .taxsteps li {
    grid-template-columns: 1fr auto;
    row-gap: var(--space-1);
  }
  .ts-ref { grid-column: 1 / -1; }
  .taxforms { padding: var(--space-4); }
}

/* ---------- Scope fixes, second round ----------
   Same root cause as the block above, and the reason it is worth restating: the
   widget ships TWICE — once inside `.doc` on our page, once as a standalone
   export with no `.doc` at all. A component that loses to `.doc p` therefore
   renders one way here and another way on the installer's site, which is exactly
   the drift generating the export from live files was meant to make impossible.
   Anything typographic added to this widget needs its `.doc` twin.

   `.split-label` is included even though it predates this change: it was already
   losing font-size, colour and margin to `.doc p`, and the tax-credit block adds
   two more instances of it. */

.doc .split-label {
  font-size: var(--text-xs);
  line-height: var(--leading-normal);
  color: var(--text-ghost);
  margin: 0 0 var(--space-2);
}

.doc .calc-hint {
  font-size: var(--text-sm);
  line-height: var(--leading-normal);
  color: var(--text-ghost);
  margin: var(--space-2) 0 0;
}

.doc .sc-note {
  font-size: var(--text-sm);
  color: var(--text-secondary);
  margin: var(--space-4) 0 0;
}

.doc .taxsteps {
  margin: var(--space-4) 0 0;
  padding-left: 0;
}

.doc .taxsteps li {
  font-size: var(--text-sm);
  margin: 0;
}

.doc .ts-what { color: var(--text-secondary); }
.doc .ts-val { color: var(--text-primary); }
.doc .ts-val--out { color: var(--accent); }

/* A select cannot ellipsize its closed-state text in every engine, so at phone
   width it simply clipped the longer option. Dropping to body size fits both
   ownership labels at 320px without touching the number fields, which are the
   one place the larger mono size is actually doing work. */
@media (max-width: 480px) {
  select.calc-num { font-size: var(--text-base); }
}

/* ---------- `hidden` must actually hide ----------
   The UA sheet's `[hidden] { display: none }` is a bare attribute selector, so
   ANY class that sets `display` outranks it — `.field-row--split { display: grid }`
   did exactly that, leaving the credit-rate and tax fields on screen for a
   lease/PPA even though the DOM said hidden. The element was gone from the
   accessibility tree and still visible, which is the worst of both.

   Scoped to the widget and winning on specificity rather than `!important`, so
   any control this calculator hides in future is hidden for real. */
.widget [hidden] { display: none; }

/* ---------- Wide card: the CARD breaks out, not its contents ----------
   The table was breaking out past its own card, so the card's left and right
   borders drew straight through the rows. A container cannot contain something
   wider than itself — so the card takes the breakout and the table sits inside
   it at 100%. */
.card--wide {
  --break: calc((min(84rem, 100vw - (2 * var(--column-padding))) - var(--column-max-doc)) / 2);
  width: calc(100% + (2 * max(0px, var(--break))));
  margin-left: calc(-1 * max(0px, var(--break)));
}

.card .table-wrap--wide {
  width: 100%;
  margin-left: 0;
  background-image: none;   /* the card edge is the boundary now */
}

/* Sticky column sits on the card surface, not the page. */
.card .partners tbody .p-name,
.card .partners thead th:first-child { background: var(--bg-surface); }

/* ---------- Two directions, equal weight, different tint ----------
   These are the same KIND of money arriving by different routes, so ranking one
   above the other by size misrepresents the model. Identical type scale and
   spacing; the only difference is the surface tint, which carries "different
   version of the same thing" without implying "less important".
   Bible §2: one identity hue — the tint is a lightness/chroma step of it, not a
   second hue. */
.dirs {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-4);
  margin: var(--space-5) 0 0;
}

.dir-card {
  padding: var(--space-5);
  border-radius: var(--radius-md);
  border: 1px solid var(--border-subtle);
}

.dir-card--out { background: var(--bg-elevated); }

.dir-card--in {
  background: var(--accent-bg);
  border-color: var(--accent-border);
}

/* Same size for both — the tint does the distinguishing. */
.doc .dir-card .calc-total {
  font-size: var(--text-xl);
  color: var(--text-primary);
}

.dir-card .split-label { color: var(--text-muted); }
.dir-card--in .split-label { color: var(--accent); }

/* Table groups reuse exactly the same two tints, so the header and the columns
   below it are recognisably the same pair. */
.partners .g-out { background: var(--bg-elevated); }
.partners .g-in { background: var(--accent-bg); }
.dir--out { color: var(--text-muted); }

@media (max-width: 720px) {
  .dirs { grid-template-columns: 1fr; }
}

/* The bridge column is context, not a result — dimmer than the figure it explains. */
.p-val--dim { color: var(--text-muted); font-size: var(--text-xs); }

</style>
</head>
<body>
<div class="wrap">
    <section class="calc widget" aria-labelledby="sc-h">
      <h2 id="sc-h" class="calc-h">A working one, before you build yours</h2>
      <p>Two questions. Everything else is an assumption you can open up and
      correct &mdash; because the assumptions are where these calculators go wrong,
      not the arithmetic.</p>

      <div class="calc-input">
        <div class="field-row field-row--split">
          <label class="calc-field">
            <span class="calc-label">Your monthly electricity bill</span>
            <input class="calc-num" autocomplete="off" id="sc-bill" type="number" min="0" max="20000"
                   step="10" value="220" inputmode="decimal">
          </label>
          <label class="calc-field">
            <span class="calc-label">How you&rsquo;d pay for it</span>
            <select class="calc-num" autocomplete="off" id="sc-payment">
              <option value="loan">Finance it</option>
              <option value="cash">Pay cash</option>
            </select>
          </label>
        </div>
      </div>

      <!-- TIER 1 — the hero. One comparison, biggest thing on the page. -->
      <div class="sc-hero" id="sc-hero">
        <div class="sc-hero-pair">
          <div class="sc-side">
            <p class="split-label">your bill today</p>
            <p class="sc-big sc-big--old" id="sc-old" aria-live="polite">&mdash;</p>
            <p class="sc-side-note" id="sc-old-parts"></p>
          </div>
          <div class="sc-arrow" aria-hidden="true">&rarr;</div>
          <div class="sc-side sc-side--new">
            <p class="split-label">with solar, all in</p>
            <p class="sc-big sc-big--new" id="sc-new" aria-live="polite">&mdash;</p>
            <p class="sc-side-note" id="sc-new-parts"></p>
          </div>
        </div>
        <p class="sc-verdict" id="sc-verdict" aria-live="polite">&mdash;</p>
      </div>

      <!-- TIER 2 — the proof. Cumulative either way; the gap after the crossing
           is the reader's money, and it is the only filled area on the page. -->
      <figure class="sc-figure">
        <figcaption class="sc-figcap">
          <span class="split-label">what you spend either way, adding up</span>
          <span class="sc-cross-note" id="sc-cross-note"></span>
        </figcaption>
        <div class="sc-chart" id="sc-chart"></div>
      </figure>

      <!-- TIER 3 — supporting, visibly smaller. -->
      <div class="sc-grid" role="group" aria-label="The rest of it">
        <div class="sc-cell">
          <p class="split-label">kept over 25 years</p>
          <p class="sc-fig" id="sc-lifetime" aria-live="polite">&mdash;</p>
          <p class="calc-total-note">after paying for the system</p>
        </div>
        <div class="sc-cell">
          <p class="split-label">ahead from</p>
          <p class="sc-fig" id="sc-payback" aria-live="polite">&mdash;</p>
          <p class="calc-total-note">when the lines cross</p>
        </div>
        <div class="sc-cell">
          <p class="split-label">system size</p>
          <p class="sc-fig" id="sc-size" aria-live="polite">&mdash;</p>
          <p class="calc-total-note" id="sc-usage"></p>
        </div>
        <div class="sc-cell">
          <p class="split-label">installed cost</p>
          <p class="sc-fig" id="sc-cost" aria-live="polite">&mdash;</p>
          <p class="calc-total-note" id="sc-cost-note"></p>
        </div>
      </div>

      <!-- Home value: hidden until an installer supplies BOTH numbers, and never
           shown on a lease. See the note at the top of savings-calc.js. -->
      <div class="sc-value" id="sc-value" hidden>
        <p class="split-label">what it does to the home&rsquo;s value</p>
        <div class="sc-value-pair">
          <span class="sc-value-fig" id="sc-value-before">&mdash;</span>
          <span class="sc-value-arrow" aria-hidden="true">&rarr;</span>
          <span class="sc-value-fig sc-value-fig--new" id="sc-value-after">&mdash;</span>
        </div>
        <p class="calc-total-note" id="sc-value-gain"></p>
        <p class="calc-hint">Your figure, not ours &mdash; an owned system only.
        A buyer inheriting a lease inherits an obligation, so this stays hidden
        on a lease or PPA.</p>
      </div>

      <div class="calc-actions">
        <button class="calc-action" id="sc-rep-toggle" type="button"
                aria-expanded="false" aria-controls="sc-rep">open the assumptions</button>
      </div>

      <!-- REP MODE. The homeowner answers two questions; everything below is the
           installer's job. ?rep=1 opens straight into it. -->
      <div class="sc-rep" id="sc-rep" hidden>

        <div class="sc-group">
          <p class="split-label">the bill and the system</p>
          <div class="field-row field-row--split">
            <label class="calc-field">
              <span class="calc-label">Rate, $ per kWh</span>
              <input class="calc-num" autocomplete="off" id="sc-rate" type="number" min="0.01" max="2"
                     step="0.01" value="0.17" inputmode="decimal">
            </label>
            <label class="calc-field">
              <span class="calc-label">Fixed utility charges, $ per month</span>
              <input class="calc-num" autocomplete="off" id="sc-fixed" type="number" min="0" max="200"
                     step="1" value="12" inputmode="decimal">
            </label>
          </div>
          <p class="calc-hint">The fixed charge is what the utility bills for being
          connected. It survives any amount of generation, and leaving it out is
          the quiet way a calculator manufactures a $0 bill.</p>

          <div class="field-row field-row--split">
            <label class="calc-field">
              <span class="calc-label">Annual kWh per installed kW <em>(orientation + shade)</em></span>
              <input class="calc-num" autocomplete="off" id="sc-perkw" type="number" min="200" max="3000"
                     step="25" value="1400" inputmode="numeric">
            </label>
            <label class="calc-field">
              <span class="calc-label">Installed cost, $ per watt</span>
              <input class="calc-num" autocomplete="off" id="sc-cpw" type="number" min="0.5" max="15"
                     step="0.05" value="3.00" inputmode="decimal">
            </label>
          </div>
          <div class="field-row field-row--split">
            <label class="calc-field">
              <span class="calc-label">State, utility and local incentives, $</span>
              <input class="calc-num" autocomplete="off" id="sc-incentives" type="number" min="0" max="200000"
                     step="100" value="0" inputmode="numeric">
            </label>
            <label class="calc-field">
              <span class="calc-label">Annual utility rate rise, %</span>
              <input class="calc-num" autocomplete="off" id="sc-esc" type="number" min="0" max="20"
                     step="0.5" value="3" inputmode="decimal">
            </label>
          </div>
          <div class="field-row">
            <label class="calc-field">
              <span class="calc-label">What happens to power they export</span>
              <select class="calc-num" autocomplete="off" id="sc-export">
                <option value="full">Full retail credit &mdash; e.g. Florida today</option>
                <option value="partial">Partial credit / net billing &mdash; e.g. California today</option>
                <option value="none">No export credit</option>
              </select>
            </label>
          </div>
        </div>

        <div class="sc-group">
          <p class="split-label">how it gets paid for</p>
          <div class="field-row">
            <label class="calc-field">
              <span class="calc-label">Who owns the system</span>
              <select class="calc-num" autocomplete="off" id="sc-own">
                <option value="buy">The customer &mdash; cash or loan</option>
                <option value="tpo">A third party &mdash; lease or PPA</option>
              </select>
            </label>
          </div>

          <div class="field-row field-row--split" id="sc-loan-fields" hidden>
            <label class="calc-field">
              <span class="calc-label">Loan APR, %</span>
              <input class="calc-num" autocomplete="off" id="sc-apr" type="number" min="0" max="40"
                     step="0.01" value="6.99" inputmode="decimal">
            </label>
            <label class="calc-field">
              <span class="calc-label">Loan term, years</span>
              <input class="calc-num" autocomplete="off" id="sc-term" type="number" min="1" max="30"
                     step="1" value="15" inputmode="numeric">
            </label>
          </div>

          <div class="field-row" id="sc-lease-fields" hidden>
            <label class="calc-field">
              <span class="calc-label">Lease or PPA payment, $ per month</span>
              <input class="calc-num" autocomplete="off" id="sc-lease" type="number" min="0" max="2000"
                     step="5" value="0" inputmode="decimal">
            </label>
            <p class="calc-hint">Without this the comparison is your bill with a
            piece missing, so it starts at zero and says so rather than quietly
            flattering the lease.</p>
          </div>
        </div>

        <div class="sc-group">
          <p class="split-label">the federal tax credit</p>
          <p>Most calculators still on the web quietly bake in 30%. That credit
          &mdash; the residential clean energy credit, <strong>25D</strong> &mdash;
          is gone for systems whose installation was <em>completed</em> after
          31 December 2025, which is how a homeowner ends up with a number that
          collapses at contract. So it is off here until you say it applies.</p>

          <div class="field-row" id="sc-own-buy">
            <label class="calc-check">
              <input type="checkbox" autocomplete="off" id="sc-pre2026">
              <span>Installation was <strong>completed</strong> on or before
              31 December 2025</span>
            </label>
            <p class="calc-hint">Not when they signed, and not when they paid.
            &sect;25D(e)(8) treats the expenditure as made when the original
            installation is finished &mdash; a deposit in December against a
            January commissioning does not qualify.</p>
          </div>

          <div class="field-row field-row--split" id="sc-credit-fields" hidden>
            <label class="calc-field">
              <span class="calc-label">Credit rate, %</span>
              <input class="calc-num" autocomplete="off" id="sc-creditpct" type="number" min="0" max="100"
                     step="1" value="30" inputmode="decimal">
            </label>
            <label class="calc-field">
              <span class="calc-label">Their federal tax that year
              <em>(Form 1040, line 18)</em></span>
              <input class="calc-num" autocomplete="off" id="sc-liability" type="number" min="0" max="1000000"
                     step="100" value="0" inputmode="numeric">
            </label>
          </div>

          <p class="sc-note" id="sc-credit-note"></p>

          <div class="taxforms" id="sc-forms" hidden>
            <p class="split-label">how they actually claim it</p>
            <ol class="taxsteps">
              <li>
                <span class="ts-ref">Form 5695, line 1</span>
                <span class="ts-what">everything the project cost &mdash; panels,
                labour, permits, shipping</span>
                <span class="ts-val" id="ts-cost">&mdash;</span>
              </li>
              <li>
                <span class="ts-ref">line 6</span>
                <span class="ts-what">multiply by <span id="ts-rate">0.30</span></span>
                <span class="ts-val" id="ts-credit">&mdash;</span>
              </li>
              <li>
                <span class="ts-ref">limit worksheet, line 1</span>
                <span class="ts-what">their tax, carried from Form 1040 line 18</span>
                <span class="ts-val" id="ts-liab">&mdash;</span>
              </li>
              <li>
                <span class="ts-ref">line 15</span>
                <span class="ts-what">the lower of lines 13 and 14 &mdash; this is
                what they actually get</span>
                <span class="ts-val ts-val--out" id="ts-claim">&mdash;</span>
              </li>
              <li>
                <span class="ts-ref">line 16</span>
                <span class="ts-what">what is left over, carried to a later
                return</span>
                <span class="ts-val" id="ts-carry">&mdash;</span>
              </li>
              <li>
                <span class="ts-ref">Schedule 3, line 5a</span>
                <span class="ts-what">where line 15 lands on the 1040; attach
                both forms</span>
                <span class="ts-val">&rarr;</span>
              </li>
            </ol>
            <p class="calc-hint">Checked against the current Form 5695 and
            Schedule 3. A guide that sends you to <em>line 53</em> is quoting a
            Form 1040 that has not existed since 2017 &mdash; which tells you
            roughly how old the rest of that page is too.</p>
          </div>
        </div>

        <div class="sc-group">
          <p class="split-label">home value &mdash; yours to fill in, or leave empty</p>
          <div class="field-row field-row--split">
            <label class="calc-field">
              <span class="calc-label">Their home&rsquo;s value today, $</span>
              <input class="calc-num" autocomplete="off" id="sc-homevalue" type="number" min="0" max="100000000"
                     step="5000" value="0" inputmode="numeric">
            </label>
            <label class="calc-field">
              <span class="calc-label">Uplift you can defend, %</span>
              <input class="calc-num" autocomplete="off" id="sc-uplift" type="number" min="0" max="100"
                     step="0.1" value="0" inputmode="decimal">
            </label>
          </div>
          <p class="calc-hint">Both start at zero and the block stays hidden until
          you fill them in. We will not put a market-wide percentage in your mouth
          &mdash; use a figure you can source for your own market, and know that it
          applies to owned systems only.</p>
        </div>

        <p class="calc-disclaimer">Panel output is degraded 0.5% a year. Under a
        partial or no-export regime, self-consumed power is also discounted &mdash;
        production arriving when nobody is home is exported whether you meant it or
        not. Only the credit that can actually be used against tax is taken off the
        cost; any carryforward is shown but not counted, because it is worth
        nothing until they owe federal tax again. An estimate for orientation, not
        a quote, and not tax advice.</p>

        <div class="calc-actions">
          <button class="calc-action" id="sc-reset" type="button">reset to defaults</button>
        </div>
      </div>

</div>
<script>
/* Calculator B — homeowner savings estimate.
   Lives on /made/solar/savings-calculator/, above the prompt that teaches an
   installer to build their own. It is a REFERENCE IMPLEMENTATION: the point is
   that a visitor can drive a working one before reading how to make it.

   WHY THIS IS THE CAC TOOL. Residential solar acquisition cost is rising largely
   because the savings maths got harder to explain once the federal residential
   credit expired. A calculator the customer drives themselves does that
   explaining without a sales call.

   THE HERO IS THE MONTHLY DELTA, AND IT INCLUDES THE PAYMENT.

   Every generic calculator on the web shows a new electricity bill near zero and
   omits the loan payment that replaced it. It demos beautifully and dies at the
   kitchen table, because the customer reconstructs the real figure during the
   contract conversation and everything else on the page becomes suspect.

   So `newMonthly` here is the sum of three things — the remaining utility bill,
   the fixed charges that survive any amount of generation, and the financing
   payment. On a financed system that total is frequently HIGHER than today's
   bill in year one. The calculator says so, out loud, rather than hiding it,
   because the honest version has an answer and the dishonest one only has a
   delay: rates escalate, the payment does not, and the lines cross.

   Which is why the proof object is a CROSSOVER, not a before/after pair of bars.
   Two bars can only describe one year, so they flatter or sink you depending
   which year is picked. Cumulative cost either way, the year they cross, and the
   gap after it shaded in — that survives follow-up questions.

   TWO THINGS IT STILL REFUSES TO DO, both deliberate:

   1. NO HARDCODED FEDERAL CREDIT. The 25D residential credit expired for
      expenditures after 2025-12-31. Every generic solar calculator still on the
      web bakes in 30%, which is exactly how a homeowner gets a number that
      collapses at contract. Incentives here default to ZERO and are an input the
      installer fills with what actually applies in their territory.

      The credit is GATED rather than removed, because two live cohorts still
      touch it and a flat "no credit" would be its own kind of wrong:

        a. Installations COMPLETED on or before 2025-12-31. 25D(e)(8) treats an
           expenditure as made when the original installation is completed — not
           when it was paid or contracted — so a December deposit against a
           January commissioning does NOT qualify. That distinction is the one
           most sales pages got wrong, so the checkbox says "completed".
        b. Carryforward. 25D is NONREFUNDABLE: the credit is capped at the year's
           tax liability and the remainder rolls to a later return. The cap is
           why a liability input exists at all — a household with little federal
           tax gets far less of the credit than the sticker 30%, and only the
           usable portion is subtracted from system cost here. The carryforward
           is displayed but NOT counted, because it is worth nothing until they
           owe federal tax again. Overstating is worse than understating.

      Lease/PPA is a third path: the credit belongs to the company that owns the
      system (48E), reaching the homeowner as a lower rate rather than a line on
      their return. Those projects must be placed in service by 31 December 2027.

   2. NO ASSUMED NET METERING. This is the single largest swing factor and it is
      state-specific: Florida keeps full retail credit (FPSC 25-6.065), while
      California moved to net billing where exports earn far less. So export
      treatment is an explicit choice, not a buried constant — and picking it is
      itself the education.

   AND ONE IT REFUSES TO ASSERT: home value. The premium is real in the
   literature and it is NOT ours to state — it depends on the market, and it
   applies to OWNED systems only, since a buyer inheriting a lease is taking on
   an obligation rather than an asset. So both the current value and the uplift
   percentage are inputs that start at zero, the block stays hidden until an
   installer fills them in, and it is suppressed entirely on a lease or PPA. */
(function () {
  'use strict';

  var STORE = 'aa-savings-calc-v2';
  var YEARS = 25;

  // Export credit as a share of the retail rate. The labels name the regime
  // rather than a state, because the regime is what actually drives the number.
  var EXPORT = {
    full:    { label: 'Full retail credit', factor: 1.00 },
    partial: { label: 'Partial / net billing', factor: 0.70 },
    none:    { label: 'No export credit', factor: 0.55 }
  };

  var OWNERSHIP = { buy: 1, tpo: 1 };
  var PAYMENT = { cash: 1, loan: 1 };

  var DEFAULTS = {
    bill: 220,          // TOTAL monthly electricity bill, $ (energy + fixed)
    rate: 0.17,         // $/kWh
    fixedCharge: 12,    // $/mo the utility bills regardless of generation
    perKw: 1400,        // annual kWh per installed kW; shading/orientation live here
    costPerWatt: 3.00,  // installed $/W before incentives
    incentives: 0,      // state/utility/local only — federal is gated separately
    escalation: 3.0,    // % annual utility rate rise
    exportMode: 'full',
    ownership: 'buy',   // 'buy' | 'tpo' — see note 1 above
    payment: 'loan',    // 'cash' | 'loan' — only meaningful when buying
    loanApr: 6.99,      // % APR — an assumption, not a quote
    loanTerm: 15,       // years
    leasePayment: 0,    // $/mo on a lease or PPA; 0 until the installer sets it
    pre2026: false,     // installation COMPLETED on or before 2025-12-31
    creditPct: 30,      // only reachable once pre2026 is true
    liability: 0,       // federal tax for that year; caps a nonrefundable credit
    homeValue: 0,       // current home value — 0 means the block stays hidden
    upliftPct: 0        // value uplift %, installer-supplied; never ours to assert
  };

  // Which state keys are which kind, so a stale localStorage blob cannot put a
  // string through the arithmetic or a number through the select.
  var BOOL_KEYS = { pre2026: 1 };
  var ENUM_KEYS = { exportMode: EXPORT, ownership: OWNERSHIP, payment: PAYMENT };

  var state = Object.assign({}, DEFAULTS);
  var $ = function (id) { return document.getElementById(id); };

  function money(n) {
    if (!isFinite(n)) return '$0';
    var neg = n < 0;
    n = Math.abs(n);
    var s = n >= 999500 ? '$' + (n / 1e6).toFixed(1) + 'm'
          : n >= 1000   ? '$' + Math.round(n / 1000) + 'k'
          : '$' + Math.round(n);
    return (neg ? '-' : '') + s;
  }

  /* Monthly figures are the hero and are never abbreviated — "$0.2k" is absurd
     on the one number the reader is going to compare against their own bill. */
  function dollars(n) {
    if (!isFinite(n)) return '$0';
    return (n < 0 ? '-$' : '$') + Math.round(Math.abs(n)).toLocaleString('en-US');
  }

  /* Tax figures are quoted to the dollar too, for the same reason: rounding is
     what makes a calculator look like it is guessing precisely where it needs to
     look exact — a number destined for a line of Form 5695 is not a projection. */
  var exact = dollars;

  /* Level payment on an amortising loan. A zero or negative rate degrades to
     straight-line rather than dividing by zero. */
  function amortise(principal, aprPct, years) {
    var n = Math.max(Math.round(years * 12), 1);
    if (principal <= 0) return 0;
    var i = Math.max(aprPct, 0) / 100 / 12;
    if (i <= 0) return principal / n;
    return principal * i / (1 - Math.pow(1 + i, -n));
  }

  function compute() {
    // Size on the ENERGY portion of the bill. Fixed charges buy no kWh, so
    // including them would inflate the system and every figure downstream.
    var fixedYear = Math.max(state.fixedCharge, 0) * 12;
    var monthlyEnergy = Math.max(state.bill - Math.max(state.fixedCharge, 0), 0);
    var annualKwh = (monthlyEnergy * 12) / Math.max(state.rate, 0.01);
    var kw = annualKwh / Math.max(state.perKw, 1);
    var gross = kw * 1000 * state.costPerWatt;

    // The federal credit, gated on ownership AND completion date. A lease/PPA
    // gives the homeowner nothing to claim: the credit sits with the owner.
    var creditEligible = state.ownership === 'buy' && state.pre2026 === true;
    var creditFull = creditEligible
      ? gross * (Math.min(Math.max(state.creditPct, 0), 100) / 100)
      : 0;
    // Nonrefundable: capped at the year's tax, remainder carried forward. Only
    // the usable slice comes off the system cost — see note 1(b).
    var creditUsed = Math.min(creditFull, Math.max(state.liability, 0));
    var creditCarry = creditFull - creditUsed;

    var net = Math.max(gross - state.incentives - creditUsed, 0);

    // How the system gets paid for decides the hero number.
    var buying = state.ownership === 'buy';
    var onLoan = buying && state.payment === 'loan';
    var upfront = buying && state.payment === 'cash' ? net : 0;
    var loanMonthly = onLoan ? amortise(net, state.loanApr, state.loanTerm) : 0;
    var leaseMonthly = !buying ? Math.max(state.leasePayment, 0) : 0;
    var solarMonthly = loanMonthly + leaseMonthly;

    var factor = (EXPORT[state.exportMode] || EXPORT.full).factor;

    // Year by year, because escalation and panel degradation pull in opposite
    // directions and a flat multiplier would overstate the total.
    var esc = state.escalation / 100, deg = 0.005;
    var doNothingSeries = [], withSolarSeries = [];
    var cumNothing = 0, cumSolar = upfront;
    var cumNothingSeries = [], cumSolarSeries = [];
    var saved = 0, doNothing = 0, year1Saved = 0, year1Remaining = 0;
    var crossover = null;

    for (var y = 0; y < YEARS; y++) {
      var rate = state.rate * Math.pow(1 + esc, y);
      var production = kw * state.perKw * Math.pow(1 - deg, y);
      var energyNoSolar = annualKwh * rate;
      // Production covering own usage saves the full rate; anything beyond usage
      // is exported and earns the regime's factor.
      var selfUsed = Math.min(production, annualKwh);
      var exported = Math.max(production - annualKwh, 0);
      var yearSaved = (selfUsed * rate) + (exported * rate * factor);
      if (exported === 0 && factor < 1) {
        // Under a poor export regime, production arriving when nobody is home is
        // still effectively exported. Haircut the self-used share too rather than
        // pretending every kWh lands on a running air conditioner.
        yearSaved = selfUsed * rate * (0.65 + 0.35 * factor);
      }

      // The fixed charge survives on BOTH paths — it is what the utility bills
      // for being connected, and leaving it out of the solar path is the quiet
      // way calculators manufacture a $0 bill.
      var billNoSolar = energyNoSolar + fixedYear;
      var remaining = Math.max(energyNoSolar - yearSaved, 0) + fixedYear;
      var payments = (onLoan && y < state.loanTerm ? loanMonthly * 12 : 0)
                   + leaseMonthly * 12;
      var withSolar = remaining + payments;

      if (y === 0) { year1Saved = yearSaved; year1Remaining = remaining; }
      saved += yearSaved;
      doNothing += billNoSolar;
      cumNothing += billNoSolar;
      cumSolar += withSolar;

      doNothingSeries.push(billNoSolar);
      withSolarSeries.push(withSolar);
      cumNothingSeries.push(cumNothing);
      cumSolarSeries.push(cumSolar);
      if (crossover === null && cumSolar < cumNothing) crossover = y + 1;
    }

    // Home value: owned systems only, and only once an installer supplies both
    // numbers. A buyer inheriting a lease inherits an obligation.
    var valueEligible = buying && state.homeValue > 0 && state.upliftPct > 0;
    var valueGain = valueEligible
      ? state.homeValue * (Math.min(state.upliftPct, 100) / 100)
      : 0;

    return {
      annualKwh: annualKwh, kw: kw, gross: gross, net: net,
      oldMonthly: Math.max(state.bill, 0),
      newMonthly: year1Remaining / 12 + solarMonthly,
      remainingMonthly: year1Remaining / 12,
      solarMonthly: solarMonthly,
      loanMonthly: loanMonthly, leaseMonthly: leaseMonthly, upfront: upfront,
      onLoan: onLoan, buying: buying,
      year1: year1Saved, saved: saved, doNothing: doNothing,
      lifetimeSaved: cumNothing - cumSolar,
      totalWithSolar: cumSolar,
      crossover: crossover,
      cumNothing: cumNothingSeries, cumSolar: cumSolarSeries,
      creditEligible: creditEligible, creditFull: creditFull,
      creditUsed: creditUsed, creditCarry: creditCarry,
      valueEligible: valueEligible, valueGain: valueGain,
      newHomeValue: state.homeValue + valueGain
    };
  }

  function show(id, on) {
    var el = $(id);
    if (el) el.hidden = !on;
  }

  /* Tolerant write. This file is meant to be copied and edited, and the tax-form
     walkthrough is the block an installer is most likely to delete outright —
     plenty of them will not want to look like they are giving tax advice. A hard
     $(id).textContent would then throw on every keystroke and take the whole
     calculator down with it. */
  function put(id, text) {
    var el = $(id);
    if (el) el.textContent = text;
  }

  /* ---------------------------------------------------------------------------
     The crossover chart.

     Drawn as inline SVG with no library, because the file has to survive being
     opened from a USB stick in a driveway with no network. A viewBox plus
     preserveAspectRatio does the responsive work that a chart library would
     otherwise charge 90KB for.

     Colour carries hierarchy and nothing else: ONE hue, three lightness steps.
     The do-nothing line recedes, the with-solar line is the bright one, and the
     region between them after the crossover — the reader's money — is the only
     filled area on the page. */
  var VB_W = 720, VB_H = 280, PAD_L = 8, PAD_R = 96, PAD_T = 18, PAD_B = 30;

  function pointsFor(series, maxV) {
    var w = VB_W - PAD_L - PAD_R, h = VB_H - PAD_T - PAD_B;
    return series.map(function (v, i) {
      var x = PAD_L + (i / (series.length - 1)) * w;
      var y = PAD_T + h - (v / maxV) * h;
      return [x, y];
    });
  }

  function path(pts) {
    return pts.map(function (p) { return p[0].toFixed(1) + ',' + p[1].toFixed(1); })
              .join(' ');
  }

  function renderChart(r) {
    var host = $('sc-chart');
    if (!host) return;

    var maxV = Math.max(
      r.cumNothing[r.cumNothing.length - 1],
      r.cumSolar[r.cumSolar.length - 1]
    ) * 1.06;
    if (!isFinite(maxV) || maxV <= 0) { host.innerHTML = ''; return; }

    var nothingPts = pointsFor(r.cumNothing, maxV);
    var solarPts = pointsFor(r.cumSolar, maxV);
    var baseY = PAD_T + (VB_H - PAD_T - PAD_B);

    // The shaded gap starts at the crossover; with no crossover inside the
    // projection there is no gap to shade and the chart simply shows two lines.
    var gap = '';
    if (r.crossover) {
      var i0 = r.crossover - 1;
      var top = nothingPts.slice(i0);
      var bottom = solarPts.slice(i0).reverse();
      gap = '<polygon class="sc-gap" points="' + path(top.concat(bottom)) + '"/>';
    }

    var marker = '';
    if (r.crossover) {
      var cx = solarPts[r.crossover - 1][0];
      marker =
        '<line class="sc-cross" x1="' + cx.toFixed(1) + '" y1="' + PAD_T +
        '" x2="' + cx.toFixed(1) + '" y2="' + baseY + '"/>' +
        '<circle class="sc-dot" cx="' + cx.toFixed(1) + '" cy="' +
        solarPts[r.crossover - 1][1].toFixed(1) + '" r="4.5"/>';
    }

    var endN = nothingPts[nothingPts.length - 1];
    var endS = solarPts[solarPts.length - 1];

    host.innerHTML =
      '<svg viewBox="0 0 ' + VB_W + ' ' + VB_H + '" role="img" ' +
      'preserveAspectRatio="xMidYMid meet" aria-label="Cumulative cost of doing ' +
      'nothing versus going solar over ' + YEARS + ' years">' +
      gap +
      '<polyline class="sc-line sc-line--nothing" points="' + path(nothingPts) + '"/>' +
      '<polyline class="sc-line sc-line--solar" points="' + path(solarPts) + '"/>' +
      marker +
      '<text class="sc-plabel sc-plabel--nothing" x="' + (endN[0] + 8).toFixed(1) +
        '" y="' + endN[1].toFixed(1) + '" dy="0.32em">do nothing</text>' +
      '<text class="sc-pval sc-pval--nothing" x="' + (endN[0] + 8).toFixed(1) +
        '" y="' + (endN[1] + 15).toFixed(1) + '" dy="0.32em">' +
        money(r.cumNothing[r.cumNothing.length - 1]) + '</text>' +
      '<text class="sc-plabel sc-plabel--solar" x="' + (endS[0] + 8).toFixed(1) +
        '" y="' + endS[1].toFixed(1) + '" dy="0.32em">go solar</text>' +
      '<text class="sc-pval sc-pval--solar" x="' + (endS[0] + 8).toFixed(1) +
        '" y="' + (endS[1] + 15).toFixed(1) + '" dy="0.32em">' +
        money(r.cumSolar[r.cumSolar.length - 1]) + '</text>' +
      '<text class="sc-axis" x="' + PAD_L + '" y="' + (baseY + 20) + '">today</text>' +
      '<text class="sc-axis sc-axis--end" x="' + (PAD_L + VB_W - PAD_L - PAD_R) +
        '" y="' + (baseY + 20) + '">year ' + YEARS + '</text>' +
      '</svg>';
  }

  /* The credit block explains itself in the user's own situation rather than in
     general. Each branch is a different REASON the number is what it is, and the
     reason is the part that survives into the conversation with an installer. */
  function creditNote(r) {
    if (state.ownership === 'tpo') {
      return 'On a lease or PPA the 30% sits with the company that owns the ' +
             'system, under 48E — you see it as a lower rate, not a line on ' +
             'your return, and those projects have to be in service by ' +
             '31 December 2027.';
    }
    if (!state.pre2026) {
      return 'Nothing federal is applied. 25D is gone for installations ' +
             'completed after 31 December 2025. Whatever your state, utility ' +
             'or city still offers goes in the incentives field below — those ' +
             'did not expire with it.';
    }
    if (r.creditFull <= 0) {
      return 'Set a credit rate to see what it is worth.';
    }
    if (state.liability <= 0) {
      return 'With no federal tax to offset, the credit is worth nothing this ' +
             'year: all ' + exact(r.creditFull) + ' carries forward, and only ' +
             'pays off if you owe federal tax in a later year. It is a credit, ' +
             'not a refund — so none of it comes off the cost above yet.';
    }
    if (r.creditCarry > 0) {
      return exact(r.creditUsed) + ' of the credit lands this year; ' +
             exact(r.creditCarry) + ' is more than you owe and carries ' +
             'forward. Only the part you can actually use is taken off the ' +
             'cost above.';
    }
    return exact(r.creditUsed) + ' comes straight off your federal tax for ' +
           'that year, and off the system cost above.';
  }

  /* The hero verdict. This is the sentence that decides whether the page is
     honest, so it states the uncomfortable case FIRST and in the same type size
     as the comfortable one. */
  function verdict(r) {
    var delta = r.oldMonthly - r.newMonthly;
    if (!r.buying && r.leaseMonthly <= 0) {
      return 'Enter the lease or PPA payment below — without it this comparison ' +
             'is not a comparison, it is just your bill with a piece missing.';
    }
    if (delta >= 0) {
      return dollars(delta) + ' a month lower from the first bill' +
             (r.crossover ? ', and ahead overall from year ' + r.crossover + '.'
                          : '.');
    }
    if (r.crossover) {
      return dollars(-delta) + ' a month MORE to begin with. Your bill keeps ' +
             'climbing and the payment does not, so you are ahead overall from ' +
             'year ' + r.crossover + ' — everything shaded below is yours.';
    }
    return dollars(-delta) + ' a month more, and at these inputs it does not ' +
           'catch up inside ' + YEARS + ' years. Change the assumptions below ' +
           'until it does, or walk away — that is what the tool is for.';
  }

  function render() {
    var r = compute();

    // Tier 1 — the hero.
    put('sc-old', dollars(r.oldMonthly));
    put('sc-new', dollars(r.newMonthly));
    put('sc-verdict', verdict(r));

    var parts = [dollars(r.remainingMonthly) + ' utility'];
    if (r.loanMonthly > 0) parts.push(dollars(r.loanMonthly) + ' loan payment');
    if (r.leaseMonthly > 0) parts.push(dollars(r.leaseMonthly) + ' lease');
    put('sc-new-parts', parts.join('  +  '));
    put('sc-old-parts', 'everything you pay the utility today');

    var host = $('sc-hero');
    if (host) {
      host.classList.toggle('sc-hero--up', r.newMonthly > r.oldMonthly);
    }

    // Tier 2 — the proof.
    renderChart(r);
    put('sc-cross-note', r.crossover
      ? 'You are ahead from year ' + r.crossover + '.'
      : 'These two lines do not cross inside ' + YEARS + ' years.');

    // Tier 3 — supporting.
    put('sc-lifetime', money(r.lifetimeSaved));
    put('sc-payback', r.crossover ? 'year ' + r.crossover : 'not within ' + YEARS);
    put('sc-size', r.kw.toFixed(1) + ' kW');
    put('sc-cost', money(r.net));
    put('sc-usage', Math.round(r.annualKwh).toLocaleString() + ' kWh/yr');

    var offsets = [];
    if (r.creditUsed > 0) offsets.push(money(r.creditUsed) + ' federal credit');
    if (state.incentives > 0) offsets.push(money(state.incentives) + ' in incentives');
    put('sc-cost-note', offsets.length
      ? money(r.gross) + ' before ' + offsets.join(' and ')
      : r.upfront > 0 ? 'paid upfront' : 'financed, not paid upfront');

    show('sc-value', r.valueEligible);
    if (r.valueEligible) {
      put('sc-value-before', money(state.homeValue));
      put('sc-value-after', money(r.newHomeValue));
      put('sc-value-gain', '+' + money(r.valueGain) + ' at the ' +
          state.upliftPct + '% you entered');
    }

    // Progressive disclosure: each control only appears once the answer above it
    // makes it meaningful. A credit-rate field on a lease is a question with no
    // right answer, and loan terms on a cash purchase are noise.
    show('sc-own-buy', state.ownership === 'buy');
    show('sc-loan-fields', r.onLoan);
    show('sc-lease-fields', !r.buying);
    show('sc-credit-fields', r.creditEligible);
    show('sc-forms', r.creditEligible && r.creditFull > 0);
    put('sc-credit-note', creditNote(r));

    if (r.creditEligible) {
      put('ts-cost', exact(r.gross));
      put('ts-rate', (Math.min(Math.max(state.creditPct, 0), 100) / 100).toFixed(2));
      put('ts-credit', exact(r.creditFull));
      put('ts-liab', exact(state.liability));
      put('ts-claim', exact(r.creditUsed));
      put('ts-carry', exact(r.creditCarry));
    }
    save();
  }

  function save() {
    try { localStorage.setItem(STORE, JSON.stringify(state)); } catch (e) {}
  }

  function load() {
    try {
      var s = JSON.parse(localStorage.getItem(STORE) || 'null');
      if (!s || typeof s !== 'object') return;
      // Coerce field by field, BY KIND. A stale blob must not put NaN through
      // the model and render a page of dashes — nor a number into the ownership
      // select, which would silently gate the credit on a value no branch
      // matches.
      Object.keys(DEFAULTS).forEach(function (k) {
        if (ENUM_KEYS[k]) {
          if (ENUM_KEYS[k][s[k]]) state[k] = s[k];
        } else if (BOOL_KEYS[k]) {
          if (typeof s[k] === 'boolean') state[k] = s[k];
        } else if (isFinite(Number(s[k])) && Number(s[k]) >= 0) {
          state[k] = Number(s[k]);
        }
      });
    } catch (e) { /* defaults are fine */ }
  }

  function noArrowStep(e) {
    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') e.preventDefault();
  }

  /* Build rows once, update in place, and NEVER replace a node that owns focus —
     re-rendering an input mid-edit is what presents to a user as "backspace
     kicks my cursor out of the box". Everything above writes textContent only. */
  function bind(id, key, min, max) {
    var el = $(id);
    if (!el) return;
    el.value = state[key];
    el.addEventListener('keydown', noArrowStep);
    el.addEventListener('input', function () {
      var raw = el.value.trim();
      // An empty field is a transient zero, not an invalid value to ignore —
      // otherwise every total freezes while the reader is part-way through
      // typing a new one.
      var v = raw === '' ? 0 : parseFloat(raw);
      if (!isFinite(v) || v < min || (max !== undefined && v > max)) return;
      state[key] = v;
      render();
    });
  }

  function bindSelect(id, key, allowed) {
    var el = $(id);
    if (!el) return;
    el.value = state[key];
    el.addEventListener('change', function () {
      if (allowed[el.value]) { state[key] = el.value; render(); }
    });
  }

  /* Rep mode. The homeowner answers two questions; everything else is the
     installer's job and lives behind this toggle. ?rep=1 opens straight into it
     so a rep can bookmark the unlocked version. */
  function initRepMode() {
    var panel = $('sc-rep');
    var toggle = $('sc-rep-toggle');
    if (!panel || !toggle) return;
    var open = /(?:^|[?&])rep=1(?:&|$)/.test(window.location.search);
    var apply = function () {
      panel.hidden = !open;
      toggle.setAttribute('aria-expanded', String(open));
      toggle.textContent = open
        ? 'hide the assumptions'
        : 'open the assumptions — every number here is editable';
    };
    toggle.addEventListener('click', function () { open = !open; apply(); });
    apply();
  }

  function init() {
    load();
    bind('sc-bill', 'bill', 0);
    bind('sc-rate', 'rate', 0.01, 2);
    bind('sc-fixed', 'fixedCharge', 0, 200);
    bind('sc-perkw', 'perKw', 200, 3000);
    bind('sc-cpw', 'costPerWatt', 0.5, 15);
    bind('sc-incentives', 'incentives', 0);
    bind('sc-esc', 'escalation', 0, 20);
    bind('sc-apr', 'loanApr', 0, 40);
    bind('sc-term', 'loanTerm', 1, 30);
    bind('sc-lease', 'leasePayment', 0);
    bind('sc-creditpct', 'creditPct', 0, 100);
    bind('sc-liability', 'liability', 0);
    bind('sc-homevalue', 'homeValue', 0);
    bind('sc-uplift', 'upliftPct', 0, 100);

    bindSelect('sc-export', 'exportMode', EXPORT);
    bindSelect('sc-own', 'ownership', OWNERSHIP);
    bindSelect('sc-payment', 'payment', PAYMENT);

    var pre = $('sc-pre2026');
    if (pre) {
      pre.checked = state.pre2026;
      pre.addEventListener('change', function () {
        state.pre2026 = pre.checked;
        render();
      });
    }

    var reset = $('sc-reset');
    if (reset) {
      reset.addEventListener('click', function () {
        try { localStorage.removeItem(STORE); } catch (e) {}
        window.location.reload();
      });
    }
    initRepMode();
    render();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();

</script>
</body>
</html>

Self-contained: styles and script are inline, so it works pasted into any page with no build step and no dependencies. Swap the colour tokens at the top for your own brand.

The calculator above uses general assumptions. You know your utility territory, your install costs, and your equipment better than any generic tool does. This prompt builds you your own version as a single file you can host on your site, send in an email, or hand to a rep on a tablet.

Why this matters more than it sounds. Customer acquisition cost in residential solar is forecast to rise roughly 40% in 2026, and a large part of that is education time — the savings math got harder to explain after the federal credit expired. A calculator the customer drives themselves does that explaining without a truck roll.


The one decision that makes or breaks it

Most solar calculators show the homeowner a new electricity bill of roughly zero and never mention the loan payment. It demos beautifully and it dies at the kitchen table, because the customer works out the real number about four minutes into the contract conversation — and now everything else you told them is suspect too.

So this prompt builds the honest version, where the new monthly figure includes the financing payment. That has a consequence you should decide you can live with before you build it:

On a financed system, the new monthly is often HIGHER than the old bill in year one. It wins later, as utility rates climb and your payment doesn't.

Which is why the centrepiece here is not a before-and-after bar chart. Two bars can only show one year, so they either flatter you or sink you depending on which year you pick. The centrepiece is the crossover: their bill climbing year after year, your payment flat, the year the two lines cross, and everything after it shaded in as theirs. A rep can put a finger on the crossing year. That is a stronger moment than a fake zero, and it survives the follow-up questions.


Stage 1 — Build it

prompt 1
You are a front-end engineer building a solar savings calculator for a
residential solar installer to use with their own customers and prospects.

### DELIVERABLE
A single self-contained HTML file. All CSS and JavaScript inline. No external
libraries, no CDN, no build step. It must work opened from a USB stick with no
internet connection — reps lose signal in driveways and garages constantly.

### TWO AUDIENCES, ONE FILE
This file is used two ways and must behave differently in each:

  CUSTOMER MODE (the default, and what lives on my website)
  The homeowner fills in TWO things and nothing else:
    1. their current monthly electricity bill
    2. their address or ZIP
  Everything else is derived or assumed. No question a homeowner cannot answer
  on the spot. They must never be asked their rate per kWh, their net metering
  regime, their roof orientation, or their tax liability — those are my job.

  REP MODE (opened with ?rep=1 in the URL)
  Unlocks every assumption as an editable control, so a rep sitting at the table
  can correct anything live: rate, system size, price per watt, financing terms,
  incentives, escalation, tax credit inputs, export treatment, degradation.
  Changes must be visible to the customer instantly — this is a conversation
  tool, not a back office.

Same maths in both modes. Rep mode reveals controls; it never changes a formula.

### OUTPUT — AND ITS HIERARCHY
Hierarchy is a requirement, not a style note. From a cold screenshot, a stranger
should identify the single most important number in under two seconds. Build it
in exactly these three tiers:

  TIER 1 — THE HERO. One number, biggest thing on the page:
    Their monthly cost now vs their monthly cost with solar.
    The "with solar" figure MUST be the sum of:
      - the remaining utility bill after the system's production, AND
      - the loan or lease payment, if the system is financed, AND
      - any fixed utility charges that stay whatever they do
    If that sum is higher than their bill today, SAY SO plainly and immediately
    point at the crossover year. Never suppress it, never round it away, and
    never show a "new bill" that silently excludes the payment.

  TIER 2 — THE PROOF. A single chart, directly under the hero:
    Cumulative cost of doing nothing (their bill, escalating) versus cumulative
    cost with solar (payment + remaining bill, then just the remaining bill once
    the loan ends). Mark the crossover year. Shade the area after it — that
    shaded region is their money, and it is the most persuasive object on the
    page. Label both lines directly on the chart, not only in a legend.

  TIER 3 — SUPPORTING, VISIBLY SMALLER:
    - total saved over the system's life
    - simple payback
    - estimated home value change, IF I have supplied a figure (see below)
    - system size and installed cost

  EVERYTHING ELSE — assumptions, tax mechanics, formulas — goes in collapsed
  panels below. Available on demand, never competing with tier 1.

Do not render tiers 1, 2 and 3 at the same type size. Do not give me five equal
tiles. A flat wall of figures is the exact failure this brief exists to prevent.

### INPUTS THE CUSTOMER FILLS IN
  - monthly electricity bill
  - address or ZIP

### HOW TO HANDLE THE ADDRESS
The address is for deriving their utility rate and expected production per kW.
Requirements:
  - If I have supplied a rate and production figure for my service territory,
    use those and treat the address as confirmation only. Do NOT make a network
    call when you already have the answer.
  - If you do call out for rate or irradiance data, the file MUST still work
    with the network unavailable: fall back to my supplied defaults, show
    plainly which figures are estimated rather than looked up, and never block
    the result on a request that has not returned.
  - Never require an API key I have not given you, and never invent one.
  - Do not put the address in a URL query string, and do not send it anywhere I
    have not explicitly named.

### MY BUSINESS
Company: [name]
Service area and utility territory: [e.g. FPL, South Florida]
Typical system size sold: [kW]
My installed price: [$ per watt, or typical total]
Battery products and prices: [if offered]
Financing I offer, with REAL terms: [cash / loan APR and term / lease / PPA]
Monthly fixed utility charges in my territory: [$ — the part of the bill that
  stays no matter how much they generate; if you leave this blank the
  calculator will overstate savings]

### MY RATE ENVIRONMENT
Current utility rate structure: [e.g. tiered, TOU, etc.]
Approximate rate per kWh, and peak/off-peak if applicable: [$]
Net metering regime for NEW customers in my territory: [e.g. full retail / net
  billing / none]
Expected annual utility rate escalation I want to model: [%]
Annual production per installed kW in my territory: [kWh]

### SEASONALITY
A single monthly bill hides the swing that customers actually feel. If I have
given you a summer and a winter bill, size the system on the annual average but
show the customer BOTH months in the hero comparison — their worst month is the
one they remember. If I have given you only one figure, use it flat and say so
in the assumptions rather than inventing a seasonal curve.

### HOME VALUE — OFF UNLESS I FILL IT IN
Do not display any home value effect unless I supply a percentage or dollar
figure myself. When I have:
  - Apply it ONLY when the customer is BUYING the system. On a lease or PPA the
    system belongs to someone else and commonly complicates a sale instead,
    because a buyer has to assume the agreement. Showing a value gain on a
    leased system is a claim that detonates at closing.
  - Label it as an estimate attributed to the figure I gave you, never as a
    market fact, and never cite a study I have not named.
  - If my territory exempts residential renewable energy equipment from property
    tax assessment, and I have told you so, say that alongside it — value up,
    assessment unchanged is a strong and checkable point. Do not assert it on
    your own.

### ROOF AREA IS A GUARDRAIL, NOT A HEADLINE
Size the system from consumption, not from roof area — on most homes the roof is
not the binding constraint and a roof estimate derived from house square footage
compounds error without changing the answer. Compute the area the system needs,
compare it to any roof figure I supply, and surface it ONLY as a warning when
the system would not plausibly fit.

### NON-NEGOTIABLE RULES

- EVERY assumption must be visible and editable by ME in one clearly marked
  block at the top of the file. Rates, escalation, degradation, price per watt,
  loan terms, fixed charges, incentives, all of it. I need to change these
  without touching the logic.
- The federal residential clean energy credit (25D) EXPIRED for expenditures
  after 2025-12-31. Do NOT hardcode a 30% federal credit. Gate it instead, on
  three questions, and apply nothing until they are answered:
    1. Is the customer BUYING the system, or is it a lease/PPA? On a lease or
       PPA the credit belongs to the third party that owns the system (48E) and
       reaches the customer as a lower rate, never as a line on their return.
    2. Was the installation COMPLETED on or before 2025-12-31? Not signed, not
       paid — 25D(e)(8) treats the expenditure as made when the original
       installation is completed, so a December deposit against a January
       commissioning does not qualify.
    3. What is their federal tax for that year? 25D is NONREFUNDABLE: it is
       capped at their tax liability and the rest carries to a later return.
       Subtract only the usable part from system cost, and show the
       carryforward separately rather than folding it in — it is worth nothing
       until they owe federal tax again.
  Keep state, utility and local incentives as a SEPARATE field I fill in. Those
  did not expire with the federal credit and must not be conflated with it.
- If you show the customer how to claim it, get the form references right and
  say which tax year they are for: Form 5695 line 1 (cost), line 6 (× the
  rate), line 14 (the liability limit from the worksheet), line 15 (the lower of
  13 and 14 — what they actually receive), line 16 (carryforward), then
  Schedule 3 (Form 1040) line 5a. Anything citing "line 53" is quoting a Form
  1040 retired after 2017. Put this walkthrough in a collapsed panel — it is
  reference material and it must never sit in the customer's main view.
- Do not invent utility rates, incentive amounts, escalation figures, loan APRs
  or home value percentages. Use the values I supplied. Where I have not
  supplied one, mark it clearly as needing my input rather than picking a
  plausible number.
- Show assumptions to the CUSTOMER too, in a collapsible panel. A calculator
  that hides its maths gets distrusted the moment a number looks surprising.
- Include a plain-language disclaimer that this is an estimate, not a quote.
- Mobile first. Reps will use this on a phone in someone's driveway.
- Print cleanly to PDF, and stamp the assumptions actually used onto the
  printout, so what the customer keeps is something you can both defend later.
- Accessible: real labels, keyboard navigable, readable contrast.

### ALSO GIVE ME
A short plain-English explanation of every formula you used, so I can check your
maths against my own numbers before I put this in front of a customer.

Stage 2 — Check it before you use it

prompt 2
Review this solar savings calculator for errors before I put it in front of
customers. Be adversarial — assume it is wrong and find out where.

Check specifically:
  - Does the "new monthly cost" include the financing payment and any fixed
    utility charges? Reconstruct it from the parts and tell me what it omits.
    A new-bill figure that excludes the loan payment is the single most common
    lie in this category and I would rather find it here than at a kitchen
    table.
  - Where the financed monthly cost EXCEEDS the current bill in year one, does
    the calculator say so plainly, or does the design bury it?
  - Is the crossover year correct? Recompute it by hand from the cumulative
    series and check it against what the chart draws.
  - Does the maths actually work? Recompute three scenarios by hand: a small
    cash system, a large financed system with battery, and a household with very
    high usage.
  - Does it degrade sensibly at the edges — zero bill, enormous bill, full shade,
    a loan term longer than the projection, an address that resolves to nothing?
  - If it fetches rate or irradiance data, does it still produce a correct
    result with the network switched off, and does it say which numbers were
    estimated rather than looked up?
  - Does any hardcoded assumption survive that should be a variable?
  - Does it apply the expired 25D federal credit anywhere without first
    establishing that the customer is buying (not leasing) AND that installation
    completed on or before 2025-12-31?
  - Does it treat 25D as refundable — crediting more than the customer's tax
    liability, or folding a carryforward into today's cost as if it were cash?
  - Does it show a home value gain on a lease or PPA, or cite a study or
    percentage I never supplied?
  - Are any IRS form or line references stale? Check them against the current
    forms, not against another calculator.
  - Does it overstate savings in any scenario? Overstating is far worse than
    understating; a customer who feels misled tells everyone.
  - Take a screenshot in your head: is there ONE obvious most-important number,
    or a flat wall of equally-sized tiles? If a stranger cannot name the primary
    figure in two seconds, the hierarchy has failed.
  - Would a customer reading the assumptions panel find anything that looked
    like a thumb on the scale?

Give me a specific list of what to fix, ordered by how badly it would embarrass
me in front of a customer.

### THE FILE
[paste the HTML here]

Stage 3 — Read the bill for them (do this last, if at all)

Typing one bill figure loses the thing that actually sizes a system well: the twelve-month usage history most utilities print as a bar chart on the statement. In Florida the summer-to-winter spread is enormous, and a system sized on the average quietly under-serves the month the customer complains about.

Only build this once Stages 1 and 2 are solid. It is a delight, not a foundation.

prompt 3
Add an optional bill upload to this calculator. A customer or rep can attach a
photo or PDF of a utility bill, and you extract ONLY:
  - the twelve-month kWh usage history, if the bill shows one
  - the rate per kWh, if it is stated
  - the utility name
  - the billing period

Requirements:
  - It is strictly optional. Every figure it extracts is a field the user can
    already type, and the calculator must work identically with it untouched.
  - Show what you extracted and let the user correct it BEFORE it changes any
    number on screen. Never silently overwrite something they entered.
  - If extraction is uncertain, say the figure is uncertain rather than
    guessing. A wrong number the customer did not type is worse than no number.
  - Tell me plainly where the file goes and what happens to it afterwards. If it
    leaves the browser, that is a decision I need to make deliberately, not
    discover — a utility bill carries their name, address and account number.
  - With a twelve-month history present, size on the annual total and show the
    highest and lowest months in the hero comparison.

### THE FILE
[paste the HTML here]

What to do with it

Put it on your site as its own page, not inside a form. Send it to the dead_quote_aged cohort from 01 — Database X-Ray and let people rediscover their own numbers — a lost bid reopens far more readily when the first move is a figure they generated themselves rather than a call they have to take.

Then send your reps the ?rep=1 link and let them run it live at the table. The moment worth rehearsing is small: put in the bill, put in the address, then turn the screen around and let the customer watch the crossover year change as you correct the assumptions in front of them. Nobody argues with a number they watched you build.

Want this run for you instead of by you? That is the job.
adrian@atlasautomates.com · atlas automates