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 — 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’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">—</p>
<p class="sc-side-note" id="sc-old-parts"></p>
</div>
<div class="sc-arrow" aria-hidden="true">→</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">—</p>
<p class="sc-side-note" id="sc-new-parts"></p>
</div>
</div>
<p class="sc-verdict" id="sc-verdict" aria-live="polite">—</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">—</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">—</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">—</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">—</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’s value</p>
<div class="sc-value-pair">
<span class="sc-value-fig" id="sc-value-before">—</span>
<span class="sc-value-arrow" aria-hidden="true">→</span>
<span class="sc-value-fig sc-value-fig--new" id="sc-value-after">—</span>
</div>
<p class="calc-total-note" id="sc-value-gain"></p>
<p class="calc-hint">Your figure, not ours — 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 — e.g. Florida today</option>
<option value="partial">Partial credit / net billing — 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 — cash or loan</option>
<option value="tpo">A third party — 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
— the residential clean energy credit, <strong>25D</strong> —
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.
§25D(e)(8) treats the expenditure as made when the original
installation is finished — 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 — panels,
labour, permits, shipping</span>
<span class="ts-val" id="ts-cost">—</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">—</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">—</span>
</li>
<li>
<span class="ts-ref">line 15</span>
<span class="ts-what">the lower of lines 13 and 14 — this is
what they actually get</span>
<span class="ts-val ts-val--out" id="ts-claim">—</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">—</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">→</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 — 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 — 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’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
— 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 —
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>