Skip to content

Frontend dashboard — Phase 1 build spec

Status: forks F1, F2, F3 and F5 locked, and AR-84 resolved (Marco, 2026-10-02). F4 and §7.2 are still open. PR 1 (the dashboard shell) is in build.

This page is the build spec for the Phase-1 frontend step: the results dashboard, honest module states, Settings with units, the AR-84 project fields and Marco's parked edits. It was written against origin/main at 18ce717 (#67, the comparison mechanism). It is subordinate to the UI spec — Phase 1. Where this page goes beyond that spec, it says so. Where the two disagree, the disagreement is a fork for Marco, not a silent change.

The calculation engine is done. This is presentation work only. It changes no engine, no schema and no golden.

The implementation session should load the frontend-design skill before it builds. That skill is not in this session's skill list. If it is still missing at build time, the dataviz skill (charts, stat tiles, palettes) and vercel:react-best-practices are the nearest available substitutes.

Locked decisions (Marco, 2026-10-02)

Fork Ruling Consequence
F1 chart approach (b) Hand-built SVG + CSS. No chart library. Matches UI spec §2.4.3 line 377. No spec amendment is needed. PR 2 adds no chart dependency.
F2 module-split form (a) Horizontal bars. No donut. Matches UI spec §2.4.3. The (a) sub-point (scale while the whole-life total is null) is settled in PR 2, not here.
F3 mock gap (b) Mock compute serves the committed compute golden (projects_compute_sample) verbatim. npm run dev:mock shows the dashboard in State A. Correction at build time: F3 affects more than the one test that P-5 and F3(b) named. It touches five mockMode.test.tsx tests, Wizard.test.tsx "lands on the results view", and the shared test/wizard.ts driver, which waited for the stage tablist. Marco chose to migrate them all, one-to-one, in PR 1. The PR body lists each one.
F4 wizard inputs STILL OPEN. It lands in a later PR (PR 4) if Marco picks (b). PR 1–3 do not wire f, ef_grid or the conventional set.
F5 role toggle Deferred to Phase 2. Phase-1 Settings holds units and theme only. API mode is shown read-only. No role control is built in Phase 1. Clear draft (§6) is not in this ruling's Settings scope. PR 3 confirms it with Marco before building it.
AR-84 Resolved (Marco, 2026-10-02). The functional-unit / Gate fields stay as they are in ProjectCreate. Owner, community and notes stay optional, draft-only metadata: kept in the browser draft and never sent, so there is no schema change. Location and photos stay Silent and inert in Phase 1. This is what ProjectStep.tsx already does. The register row is closed. The stale AR-80 line in UI spec §2.1 (latitude, longitude) is fixed. The matching ProjectStep.tsx copy fix stays in PR 3 (§7.1).
§7.2 parked edits STILL OPEN. They land in a later PR once Marco lists them. The register tracks them as AR-100.

Step numbering. This page follows root ROADMAP.md: the frontend dashboard is Phase-1 step 6. "Step 2" in AR-93, AR-95, AR-99 and comparison.md §6–7 is an alias for the same work (P-7).


0 · Pre-flight and premise check

0.1 Pre-flight (2026-10-02)

Check Result
git log --oneline -3 origin/main 18ce717 #67 · 5df5030 #66 · ac08969 #65. HEAD is the post-#67 commit.
gh pr list --state open None. No open PR touches frontend/.
CI on 18ce717 ci and docs push runs both success (runs 37000077311, 37000077326).
Frontend baseline (local) vitest run: 8 files, 95 tests, green. vite build: JS 309.00 kB, 92.92 kB gzip; CSS 26.80 kB, 5.86 kB gzip.

gh run list --branch main -L 12 lists only July runs, so the #67 runs were found with gh run list --commit 18ce717.

0.2 Premises that do not match the code

The brief asked for a stop-and-report wherever a premise is wrong. None of the mismatches below blocks the spec, but each one changes what the implementation session may assume.

# Brief premise What the repo says Consequence
P-1 Chart approach is an open fork (library vs hand-built). Already decided in the governing spec. UI spec §2.4.3, line 377: "Charts are hand-rolled SVG (two bar forms only). No chart library in Phase 1." Fork F1 (§5) is still presented, as asked. Picking the library option means amending UI spec §2.4.3 in the same PR. It is not a free choice.
P-2 The dashboard has a module-split donut. The UI spec draws the module split as horizontal bars (§2.4.3, line 373). A donut is a part-to-whole form, and its parts are the share values. share is null whenever the whole-life total is null (schema.gen.ts ModuleSummary.share). In the primary, mostly-BLOCKED case, a donut has nothing honest to draw. Fork F2 (§5).
P-3 AR-84 is resolved. (Now recorded: resolved by Marco on 2026-10-02. See Locked decisions above.) docs/status/action-register.md:208 showed AR-84 as Open, owner Marco (decision). No ruling is recorded in the register, CHANGELOG.md:63 or ROADMAP.md:348. The resolution the brief describes (functional-unit fields + optional metadata + Silent location/photos) is exactly what ProjectStep.tsx already does. The UI gap is small (§7.1). The governance gap is that the ruling must be recorded, with its date, when the register row is closed.
P-4 "Marco's parked UI edits" exist somewhere. Not found. Searched: every origin/* branch name; docs/, docs/tmp/, scratch/ and the repo root for "parked"; comments and reviews on frontend PRs #44, #56, #58 and #60; GitHub issues (only #53 matches); this project's Claude memory. No TODO file exists. They are not specified here. They are not invented either. §7.2 holds a placeholder for Marco to fill in. PR 3 cannot carry them until he does.
P-5 The dashboard can be built and tested in mock mode against committed goldens. Only partly. In mock mode, mockApi.compute returns CannedStages (the three stage goldens), not a ResultEnvelope (frontend/src/mocks/mockApi.ts:49-57). Wizard.tsx:234-238 then renders the old ResultsView tabs, so npm run dev:mock never shows EnvelopeView, and so never the dashboard. The envelope golden (projects_compute_sample) is used only in tests (EnvelopeView.test.tsx, mockMode.test.tsx:120-131 fake-fetch, client.test.ts). Fork F3 (§5).
P-6 Earth A1–A3 is BLOCKED on #26, so most headlines are BLOCKED. That is true for a real earth set: the earth record is status: blocked in the lci_materials golden. But the only envelope golden's earth variant contains no earth material: it holds cement, steel rebar, sand and glass. So its A1–A3 is complete (749.4 kg CO₂-eq), while B4 and B6 are incomplete and the comparison is pending. The real primary state, where an earth set makes A1–A3 BLOCKED too and every module is null, has no golden. It is exercised only through synthetic test builders (§9). The default-view spec (§4.2) covers both states.
P-7 "Phase-1 step 2 = the frontend build". (Resolved: this page uses ROADMAP's step 6.) Root ROADMAP.md numbers Phase-1 step 2 as A1–A3 fully wired (declared-unit seam) and the UI as step 6. The register (AR-93, AR-95, AR-99) and comparison.md §6–7 use "step 2" for the wizard work, which matches the brief. Naming only. The impl PR should tick ROADMAP step 6 and note the alias.
P-8 Four rendering states: COMPLETE / INCOMPLETE / BLOCKED / PENDING. The API has three different enums. None of them is that set (§2). §2 maps them. The words on screen follow UI spec §1.3.1, not the brief's four labels.
P-9 "Wire ALL modules against their REAL honest states" (presentation only). The wizard cannot send the inputs that move B4, B6 or the comparison off BLOCKED/pending: finishes_fraction/finishes_basis (AR-93), ef_grid (AR-95), a conventional variant and attest_same_building (AR-99). toComputeRequest emits the earth variant only, with attest_same_building: false (wizard/draft.ts:236-251). The API already accepts all of these. With presentation-only scope, a user in live mode can never see B4, B6 or the whole-life total complete. Fork F4 (§5).

1 · What exists today (read pass, cited)

1.1 The results UI

Piece Where What it does Dashboard gap
Two-tier switch wizard/Wizard.tsx:225-247 Step 4 renders EnvelopeView for a ResultEnvelope and ResultsView for CannedStages (isEnvelope, api/api.ts:38-40). Adds the stale notice and dimming (:228-233) and MockComputeNote. Screen 5 (detailed report) does not exist, so there is only one tier today.
EnvelopeView components/results/EnvelopeView.tsx:172-208 Project name and input hash, one full Variant section per variant, the cut-off note (:189-196, prints "pending (AR-81)") and the comparison-pending note (:197-205, comparison.reasons verbatim). Text and tables only. No cards, no charts. It renders the conventional variant as a full peer section, but UI spec §2.4 shows conventional in the comparison elements only.
Headline EnvelopeView.tsx:39-99 Total, per m², per m²·yr when computable. When null: "Not computable yet" + total withheld, the reasons list and three count badges (blocked / provisional / interim geography). The words differ from UI spec §2.4.1, which says pending. There is no stat-card layout.
ModuleTable EnvelopeView.tsx:101-153 One row per module (A1–A3, A4, A5, B4, B6): state badge, total or total withheld, Share column (:110, :133-135: formatShare or —), and the reasons plus B6 secondary kWh in the Note column. A table, not cards. A4/A5 are not folded.
Variant EnvelopeView.tsx:155-170 Headline, module table and the A1–A3 per-material table (A1A3Table). The A1–A3 detail belongs to Screen 5 in the spec. Until Screen 5 exists, it must stay reachable (§3).
ResultsView components/results/ResultsView.tsx:44-137 WAI-ARIA tablist over A1–A3 / A4 / A5 stage goldens (canned mock only). Superseded by the envelope. Its fate depends on F3.
A1A3Table / A4Table / A5Table components/results/*.tsx Expandable per-row provenance: pedigree, citation, verdict chips. Reused as-is. There is no B4 or B6 table (Screen 5 work, out of scope).
AggregatePanel components/results/AggregatePanel.tsx:18-86 The honest "Not computable — n blocked inputs" block, total withheld, reasons list, and "the API gave no incomplete_reason". The pattern for every null card.
DivergenceStatement components/results/primitives.tsx:179-204 For a CORRECTED row: the Δ % against the workbook value, or a plain "the API carries no reference value". Used in FormulaVerdictDetail (:207-235). Row-level, Screen 5 only. Not on the dashboard.
BlockedValue / QuantityValue / FactorValue primitives.tsx:76-102 A null renders as a solid blocked badge, never 0. A value renders in mono with its unit. Reused in every card.

1.2 Palette, tokens and primitives

  • Tokens: frontend/src/index.css:24-56. Background #faf6f0 / dark #1c1917; foreground #2a2320 / #f5f0e8; primary terracotta #b5533a; status computed #5f7a52, provisional #c2891f, blocked #a23b2e. Card, muted and border are color-mix derivations, so they follow the theme for free. The status and primary colours are not redefined for dark mode.
  • Tailwind mapping: index.css:58-76 (bg-status-blocked and so on). Dark variant: index.css:11-20. It follows prefers-color-scheme unless <html data-theme> pins it.
  • Theme: lib/theme.ts:23-41, a system → light → dark cycle stored per browser in try/catch. The only control is the header button, App.tsx:44-46.
  • --series-conventional does not exist yet. UI spec §1.1, line 99, defines it as color-mix(in oklab, var(--foreground) 45%, var(--background)).
  • Badge: components/ui/badge.tsx:16-48. Tones computed / provisional / blocked / neutral; fills tint / solid / outline; always a dot plus a word.
  • Button: components/ui/button.tsx.
  • Tone maps: lib/verdicts.ts. They are exhaustive Records, so a new enum value is a type error.
  • Formatting: lib/format.ts. formatValue: 0 shows as "0.0", and a non-zero value never rounds to 0. Also formatShare, formatSignedPercent and unitLabel. The unit labels have no entry yet for kg_CO2_eq_m2, kg_CO2_eq_m2_yr or kWh (format.ts:53-59), so those fall back to the raw enum.
  • Fonts: Public Sans and IBM Plex Mono (index.css:5-7); .font-mono is tabular (:86-88).
  • Wizard primitives: wizard/fields.tsx (Field, Section, Notice, TagChip).
  • components.json exists (shadcn setup). Only badge and button have been added.

1.3 The governing UI spec vs this page

UI spec section Already specifies This page adds
§1.1, §1.2 Palette, --series-conventional, one number per card, graphs over text A reuse map (§3)
§1.3, §1.3.1 Honest-BLOCKED rules; four non-value words The enum → word mapping (§2); per-element state tables (§4)
§1.4 Units toggle, lib/units.ts, factor table, GWP never converted Scope and placement (§6)
§2.4.1–§2.4.3 Headline, module cards, A4/A5 fold, stat cards, module-split bars, paired bars, the % rule, hand-rolled SVG, table equivalents The mostly-BLOCKED default view (§4.2), the comparison gate checklist (§4.1, E7), forks F1–F4, the PR split
§2.6, §3.3 Empty, mock and stale states —
§2.7 Settings: units, theme, role, clear draft Role scope (F5); API mode shown read-only (§6)
§5 Mock-first; synthetic fixtures for tests only How the mock gap (P-5) is closed (F3)

1.4 Mock layer

  • api/mode.ts:9-15 parses VITE_API_MODE: live (the default, or empty) or mock. Anything else throws. The value is build-time: api/api.ts:52-54 compares the raw env value, so a live build drops the fixtures from the bundle.
  • mocks/mockApi.ts:25-31 serves five GET goldens. compute (:46-57) returns CannedStages for any request body.
  • scripts/sync-mocks.mjs copies backend/tests/golden/*.json verbatim into src/mocks/fixtures/*.ts, typed against the generated client types. npm run mocks:check fails CI on drift (ci.yml:51). projects_compute_sample and projects_compute_request are synced but not served by mockApi.
  • The synthetic-state pattern already exists. EnvelopeView.test.tsx:20,60,74 structuredClones computeSample and mutates it in-test to reach a complete whole-life total, not_provided B6, a supplied cut-off justification and a conventional variant. UI spec §5.3 allows this, under frontend/src/test/, labelled synthetic and never shown in the running app.
  • CI frontend job (.github/workflows/ci.yml:30-59): gen:api drift check, mocks:check, typecheck, lint (oxlint --deny-warnings), build, then test:coverage with the ratchet floor 94 / 87 / 95 / 95 (vite.config.ts:35-40).

1.5 Charting dependency

None. frontend/package.json dependencies are React 19, fontsource, cva, clsx and tailwind-merge. No chart, D3 or animation library is present.


2 · State vocabulary: API enums → screen words

The brief's four labels are not API values. The dashboard renders what the API states, using the words of UI spec §1.3.1.

API source API value Screen word Tone (Badge) Number shown?
ModuleSummary.status complete (the value) computed Yes: total, plus share only if non-null
incomplete BLOCKED (module), with n reasons blocked No. Reasons verbatim. B6 still shows its secondary kWh/yr, because the energy is real and only the carbon is blocked.
not_provided not provided provisional No. A link back to the input field.
not_assessed not assessed neutral No, ever. A4/A5 cut-off (#32).
VariantSummary.total non-null (the value) — Yes, with per m² and per m²·yr, each independently null-safe
null pending (UI spec §2.4.1) blocked border No. incomplete_reason verbatim.
Comparison.status pending pending neutral No. reasons verbatim plus the gate checklist.
complete (the delta) — Unreachable in this build. delta, delta_pct and band are typed null (schema.gen.ts Comparison). The delta PR widens them, and tsc then forces the render path.
MaterialResultStatus (row) computed / provisional / blocked as is as statusTone Counts only on the dashboard (blocked_count, provisional_count)
CutOff.justification null "cut-off justification pending (AR-81)" neutral No

What "INCOMPLETE" means here. A module whose status is incomplete renders as BLOCKED, because every incomplete reason today is a sourcing or input gap. The variant headline renders as pending, because it is derived. This matches §1.3.1 and is the one wording change from today's EnvelopeView ("Not computable yet / total withheld"). If Marco prefers to keep the current words, that is a one-line override. No fork is needed.


3 · Component inventory

Reuse means the existing component is used unchanged. Extend means a small additive change. New means a new file.

Component Kind Built on Purpose
DashboardView New replaces the face of EnvelopeView Screen 4 layout: context strip → headline → stat cards → module cards → module split → comparison → cut-off fold → "Detail" disclosure
ContextStrip New inputs_echo.project Project name, country, climate, programme, GIA, RSP, date, input hash (AR-84 FU fields, §7.1)
HeadlineCard New, from Headline (EnvelopeView.tsx:39-99) QuantityValue, Badge, AggregatePanel pattern Whole-life total, per m², per m²·yr, tag chips
StatCard New font-mono, Badge One mono figure plus a label. Always-available inputs and counts.
ModuleCard New Badge, QuantityValue, MODULE_STATUS map (EnvelopeView.tsx:16-24, move to lib/verdicts.ts) A1–A3, B4, B6: value or state word, share, blocked_count, secondary line
CutOffFold New, from the cut-off note (EnvelopeView.tsx:189-196) ExpandableRow-style disclosure A4 · A5 not assessed muted card
StateBadge Extend Badge usage Badge Maps §2 enums to words. No new tones.
ReasonList New, extracted from Headline / AggregatePanel — Verbatim reasons, wrapped and never truncated. "the API gave no reason" fallback.
ComparisonPanel New, from the pending note (EnvelopeView.tsx:197-205) ReasonList Gate checklist plus reasons when pending. Paired bars when ≥ 2 variants. The % only when complete.
ModuleSplitChart New (PR 2) per F1 / F2 Module split, with a table equivalent
VariantPairChart New (PR 2) per F1 Earth vs conventional paired bars, with a table equivalent
ChartTable + Show as table toggle New (PR 2) <table> Required by UI spec §1.6 and §2.4.3
A1A3Table and ModuleTable Reuse, moved under a Detail disclosure — Keeps per-row provenance reachable until Screen 5 exists. No audit content is removed.
MockBanner, MockComputeNote, stale Notice Reuse — Unchanged
SettingsView, SegmentedControl New (PR 3) Button, useTheme §6
lib/units.ts New (PR 3) — UI spec §1.4
--series-conventional token New (PR 2) index.css UI spec §1.1
format.ts unit labels Extend — Add kg_CO2_eq_m2 → "kg CO₂-eq/m²", kg_CO2_eq_m2_yr → "kg CO₂-eq/m²·yr", kWh

4 · Honest-state design

4.1 Per element

Rules that apply to every element (UI spec §1.3): a null is never 0, never blank and never a zero-length bar; every non-value carries its API reason on the same screen; the browser never sums, divides or normalises.

Element COMPLETE BLOCKED (module incomplete) not provided not assessed pending
E1 Headline (whole-life, per m², per m²·yr) Three mono figures. Any one of them null renders that cell as the state word (they are independently null-safe, see EnvelopeView.test.tsx:53). — — — Large word pending with a blocked left rule, the count "n reasons", then incomplete_reason in full. No ghost number, no placeholder dash.
E2 Tag chips n blocked, n provisional, n interim geography, each hidden only at 0 (UI spec §2.4.1). The current code shows zero counts as neutral; follow the spec. same same same same
E3 Module card A1–A3 / B4 / B6 total in mono; share only when non-null; blocked_count; secondary line. Word BLOCKED (solid blocked badge), "n reasons", the first reason in full, the rest behind a disclosure. B6: the secondary kWh/yr shows as a real value labelled energy — carbon blocked. Word not provided. One line: "No annual energy entered", with a link that jumps to the Materials step, field b6-kwh. Never occurs (A4/A5 only). —
E4 Cut-off fold A4 · A5 — — — Always. One muted card: "A4 Transport · A5 Construction — not assessed (documented cut-off, #32)". Disclosure: cut_off.justification, or "pending (AR-81)". Never a number, although A4/A5 values exist in the engine. —
E5 Stat cards Materials entered · materials blocked (blocked_count) · provisional · RSP · GIA. All are inputs or API counts, so they are always present and anchor the layout in the blocked state. n/a n/a n/a n/a
E6 Module split (chart) Bar per complete module, value label, % only if share non-null. A row with the label and BLOCKED, no bar, no track. A row with not provided, no bar. Not drawn (lives in E4). When no module is complete, the chart is not drawn. It collapses to the module cards; there is no empty axis. See F2 on scale when the whole-life total is null.
E7 Comparison panel Unreachable in this build (§2). — — — Gate checklist: the five comparison.gates booleans as ✓ / ✗ rows with plain labels (conventional set submitted, same building attested, earth total complete, conventional total complete, uncertainty band present), then comparison.reasons verbatim. This makes "pending" legible and polished without a number.
E8 Earth vs conventional bars Drawn only when variants.length === 2, per module plus a whole-life pair, each half following E6. Per-half BLOCKED label, no bar. — — With one variant, not drawn. E7 explains why.
E9 vs-conventional % Only when comparison.status === "complete" and delta_pct non-null. Type-impossible today. — — — pending, inside E7.
E10 Detail disclosure ModuleTable + A1A3Table as today as today as today as today as today

Stale (inputs changed): the existing notice and dimming (Wizard.tsx:228-233) wrap the whole dashboard, unchanged.

4.2 The mostly-BLOCKED default view (the primary design case)

Two realistic states, and both must look finished.

State A: the committed golden. Earth variant with no earth material: A1–A3 complete (749.4), B4 BLOCKED (4 rows: no f, RSL uncited), B6 BLOCKED (no EF_grid; 12,000 kWh/yr supplied), comparison pending (4 reasons, 0 / 5 gates).

State B: a real earth set. A1–A3 BLOCKED (#26), B4 BLOCKED, B6 not provided or BLOCKED, comparison pending. Every carbon number is null. There is no golden, so it is reached through synthetic test builders only.

┌ Sample compute request · SEN · BSh · school · 250 m² · 30 yr · 2026-09-30 · input <hash12> ┐
├──────────────────────────────────────────────────────────────────────────────────────────┤
│ WHOLE-LIFE GWP  (A1–A3 + B4 + B6)                                                         │
│ ▌ pending — 5 reasons                       [0 blocked] [0 provisional] [4 interim geog.] │
│ ▌ 1. B4: Cement: 2 gaps … (full text, wrapped)                                            │
│ ▌ … all reasons, never truncated                                                          │
├──────────────┬──────────────┬──────────────┬──────────────┬──────────────────────────────┤
│ 4 materials  │ 0 blocked    │ 0 provisional│ 250 m² GIA   │ 30 yr RSP                     │
├──────────────┴───┬──────────┴───────┬──────┴──────────────┴──────────────────────────────┤
│ A1–A3 Product    │ B4 Replacement   │ B6 Operational energy                               │
│ 749.4 kg CO₂-eq  │ ■ BLOCKED        │ ■ BLOCKED                                           │
│ share —          │ 4 reasons ▸      │ 12,000 kWh/yr energy — carbon blocked · 1 reason ▸  │
├──────────────────┴──────────────────┴─────────────────────────────────────────────────────┤
│ Module split   A1–A3 ████████████████ 749.4   B4  BLOCKED   B6  BLOCKED   [Show as table] │
├───────────────────────────────────────────────────────────────────────────────────────────┤
│ Earth vs conventional — pending                                                           │
│  ✗ conventional set submitted   ✗ same building attested   ✗ earth total complete         │
│  ✗ conventional total complete  ✗ uncertainty band present                                │
│  Reasons: 1. conventional: no conventional variant submitted (AR-79 …) …                  │
├───────────────────────────────────────────────────────────────────────────────────────────┤
│ A4 Transport · A5 Construction — not assessed (cut-off, #32) ▸ justification pending AR-81│
├───────────────────────────────────────────────────────────────────────────────────────────┤
│ ▸ Detail: module table · A1–A3 per material (provenance)                                  │
└───────────────────────────────────────────────────────────────────────────────────────────┘

In State B, the A1–A3 card also reads BLOCKED, and the module split does not render (E6). The page is then: context strip, pending headline, stat cards (all real), three BLOCKED cards, gate checklist, cut-off fold. It is complete and honest, with no empty chart frame. The visual weight moves to the reasons and the gate checklist, and both are designed elements, not error text.

Polish rules for this case:

  • BLOCKED cards keep the same height and grid as complete cards, so the grid never reads as broken.
  • The state word sits where the number would sit, at the same size and in mono weight.
  • Reasons are body text, not red error text. The blocked colour appears only on the rule and the dot.
  • On a phone (360 px), the cards stack in a single column and the reasons wrap. Only the Detail tables scroll, inside their own container.

5 · Forks for Marco

F1, F2, F3 and F5 were decided on 2026-10-02. F4 is still open. The rulings are in Locked decisions. The options are kept below as the record of what was weighed.

F1 · Chart approach

The governing spec has already decided (b) (P-1). Choosing (a) amends UI spec §2.4.3.

(a) Lightweight chart library (b) Hand-built SVG + CSS
Candidates Recharts: React SVG, and the base of the shadcn chart component (components.json is present). visx: modular SVG primitives, import only @visx/scale + @visx/shape. Chart.js via react-chartjs-2: canvas. Two bar forms (and a donut only if F2 picks it), as <svg> with <rect> and <text>. A linear scale is about 5 lines.
Bundle (baseline 92.9 kB gzip JS) Estimates; the impl PR must measure them with vite build before and after. Recharts adds the most (it pulls in several d3 modules); visx with two packages is the smallest library option; Chart.js sits between them. About 0 kB of dependencies; a few kB of own code.
Theming (warm palette, light/dark) Recharts and visx: SVG fill="var(--primary)" works directly, so dark mode is free. Chart.js: canvas cannot read CSS variables, so colours must be resolved with getComputedStyle and the chart redrawn on theme change. That is weak for the tri-state theme. Native: fill-primary / fill-[var(--series-conventional)] utility classes; light/dark is automatic.
Honest states Libraries tend to draw an axis and a zero-length bar for a null. Each one has to be suppressed and checked by a test. Null → no <rect> by construction.
Tests (jsdom) Recharts' ResponsiveContainer needs a ResizeObserver stub and fixed sizes in jsdom. Canvas (Chart.js) is opaque to Testing Library. Plain DOM: assert <rect> count and labels.
a11y Table equivalent still required (UI spec §1.6). Same.
Effort Low for drawing, medium for suppressing nulls and theming. Medium: about 200–300 LOC plus tests for both forms.
Spec impact Amend UI spec §2.4.3, line 377. None.

F2 · Module-split form: donut (brief) vs bars (spec)

  • (a) Horizontal bars, as specified (UI spec §2.4.3). Absolute values for complete modules, BLOCKED rows without a bar, % only if share is set. Open sub-point for (a): while the whole-life total is null, should the bar scale run from 0 to the largest complete module? A lone complete module would then draw full-width and could read as 100 %. The alternative is to show values as a list with no bars until at least two modules are complete. This page's E6 draws one bar in State A, so this needs Marco's call.
  • (b) Donut, only when the whole-life total is complete. The segments are the API's share values. When the total is null, it falls back to (a)'s rows. The donut is then never visible in Phase 1 unless a synthetic all-f = 0 request is sent with ef_grid, which the wizard cannot send today (P-9). That means amending the spec and building a form no live user will see.
  • A donut drawn from complete modules only is not an option: it implies a whole that the API says does not exist.

F3 · Closing the mock gap (P-5)

  • (a) Keep mock compute as CannedStages. The dashboard is visible only in live mode and in tests. npm run dev:mock keeps showing the stage tabs. No existing test changes.
  • (b) Serve the envelope golden from mock compute. mockApi.compute returns computeSample verbatim. It is still canned and still labelled by MockComputeNote and MockBanner. npm run dev:mock then shows the dashboard in State A. CannedStages and ResultsView become unreachable in the app, and are deleted or kept for tests only. This is frontend-only, and the fixture is unchanged. It does change mockMode.test.tsx:110-113, which asserts the stage tablist in mock mode, so that test is migrated, not weakened (§8). UI spec §5.2 allows serving backend goldens in mock mode.
  • Either way, State B and the COMPLETE states are exercised only by synthetic builders under frontend/src/test/ (UI spec §5.3).

F4 · Input-side wiring (P-9)

These are frontend-only changes to draft.ts / MaterialsStep / ReviewStep. They need no API change.

  • (a) Presentation only, as the brief says. AR-93, AR-95 and AR-99 stay open. In live mode, B4 and B6 are always BLOCKED and the comparison always pending.
  • (b) A fourth PR after the dashboard: per-row f + basis (AR-93), the ef_grid entry with the combustion-only warning (AR-95), and the conventional tab + attestation (AR-99, still gated on AR-79 for real data).
  • (c) Fold (b) into PR 3. This breaks the single-concern rule, so it is not recommended.

F5 · Role flag in Settings

UI spec §2.7 lists a role toggle (LCA user / admin). On the dashboard it unlocks nothing yet: the raw-JSON disclosure and the Admin data screen are Screen 5 / §2.8 work.

  • (a) Build the toggle now as a stored flag with no effect.
  • (b) Defer it to the Screen-5 / Admin PR, so no control is built that does nothing.

6 · Settings and units

Control Scope Notes
Units metric / imperial lib/units.ts (pure, the only place a factor appears, unit-tested both ways, UI spec §1.4 table). It affects input fields (GIA m² ↔ ft²; material kg ↔ lb) and display of per-area figures (kg CO₂-eq/m² → kg CO₂-eq/ft²). The numerator is never converted. GWP, kWh and years are never converted. Inputs convert once on entry to a full-precision canonical value. draft.ts stores text today (draft.ts:9-12 says so), so PR 3 needs the canonical-value-on-entry rule from UI spec §1.4. The per-area display conversion is the browser's only permitted arithmetic (UI spec §0).
Theme system / light / dark Reuse useTheme (lib/theme.ts). Move it from the header button (App.tsx:44-46) to a segmented control. The header keeps a compact theme + units control (UI spec §1.4, line 167).
Role F5: deferred to Phase 2. Not built. —
Clear draft Confirm first. Clears chwr-draft-v1. Outside the F5 ruling (units and theme only). PR 3 confirms with Marco first.
API mode Read-only display, not a toggle. VITE_API_MODE is build-time (api/api.ts:52-54). A runtime toggle would ship the fixtures in the live bundle. Show "Data source: live (API_BASE_URL)" or "mock (committed goldens)".

Storage: localStorage in try/catch, following the theme.ts pattern. The app works with storage unavailable.

Navigation: there is no router (UI spec §0 table). Settings is an App-level view switch ("wizard" | "settings") reached from the header, with the draft untouched. No router dependency is added.


7 · AR-84 fields and Marco's parked edits: where they land

7.1 AR-84

Field group Captured today Gap Lands in
FU / Gate fields: name, country, climate, programme, date, GIA, RSP ProjectStep.tsx:67-180, all Gate fields, sent via toComputeRequest (draft.ts:219-235) None on input Dashboard ContextStrip, read from inputs_echo (the server's echo, not the draft, so it cannot drift from the result)
Optional metadata: owner, community, notes ProjectStep.tsx:193-212, draft-only and never sent (draft.ts:32-36) None on input Not on the dashboard. They are not in inputs_echo, and showing draft values beside a server result could mismatch after an edit. They are shown on Review only.
Location, photos ProjectStep.tsx:213-231, inert <fieldset disabled> Stale copy: "Location becomes an input only if AR-80 decides B6 is tool-computed" (:217-218). AR-80 was resolved on 2026-10-01: fork (a), the tool computing the energy, is Phase 2. Copy fix in PR 3: "…inert in Phase 1; location becomes an input in Phase 2 (AR-80, fork (a))."
Register AR-84 Resolved (Marco, 2026-10-02) None. The ruling is recorded in the register and under Locked decisions. Closed in the spec commit of PR 1. PR 3 still carries the ProjectStep.tsx copy fix above.

7.2 Marco's parked edits

STILL OPEN. They have not been located (P-4). Marco lists them here, and they land in a later PR. The register tracks them as AR-100.

# Edit (Marco to supply) Screen / component PR
— empty — 3

8 · Small-PR sequence

Each PR is single-concern and branches from a fresh origin/main, as CLAUDE.md requires.

PR Branch Delivers Depends on Specific invariants
1 feat/phase1-dashboard-shell DashboardView, ContextStrip, HeadlineCard, StatCard, ModuleCard, CutOffFold, ComparisonPanel (gate checklist + reasons), ReasonList, the §2 word mapping, the Detail disclosure holding ModuleTable + A1A3Table. Conventional moves out of a peer section into the comparison panel only. format.ts unit labels. Synthetic envelope builders under src/test/ for State B, COMPLETE, not_provided and two variants. The F3 outcome. — No chart code. Every honesty assertion in today's EnvelopeView.test.tsx carry over to the new components: null ≠ 0, reasons verbatim, not provided, cut-off text, input hash.
2 feat/phase1-dashboard-charts ModuleSplitChart, VariantPairChart, ChartTable + toggle, --series-conventional token (3:1 contrast against --card, both themes, checked by a test or a recorded measurement), per F1 / F2. PR 1 No <rect> / segment for a null. A test asserts that the bar count equals the count of non-null totals. The bundle delta is stated in the PR body. If F1 = (a), UI spec §2.4.3 is amended in the same PR.
3 feat/phase1-settings-units SettingsView, lib/units.ts + tests, header compact controls, units on GIA / material inputs and per-area display, read-only API mode, AR-84 copy fix and register closure, parked edits once listed. PR 1 (per-area display) units.ts is the only file with a conversion factor. GWP is never converted. A round-trip test proves toggling never drifts a stored value.
4 (if F4 = b) feat/phase1-wizard-module-inputs f + basis per row (AR-93), ef_grid entry + combustion-only warning (AR-95), conventional tab + attestation (AR-99) PR 1 Request shape exactly as ComputeRequest-Input. No default f, no default EF_grid.

9 · Invariants (every PR)

  1. No backend change. git diff --exit-code origin/main -- backend/ is empty.
  2. No schema change. frontend/src/api/schema.gen.ts and docs/api-reference/openapi.json are unchanged. The CI gen:api drift check stays green.
  3. No golden or fixture change. backend/tests/golden/ and frontend/src/mocks/fixtures/ are byte-identical, and npm run mocks:check is green. New synthetic builders go under frontend/src/test/, are named synthetic*, and are imported only by tests (UI spec §5.3). (Confirmed 2026-10-02: synthetic test builders under frontend/src/test/ are allowed. The ban covers committed goldens and fixtures only.)
  4. Existing frontend tests stay green. Where a test asserts markup of a component this work replaces (EnvelopeView.test.tsx; mockMode.test.tsx:110-113 under F3 (b)), it is migrated in the same PR to the same assertion against the new component, never weakened or deleted without a replacement. The PR body lists every migrated assertion.
  5. The coverage floor is not lowered (vite.config.ts:35-40). Raise it if measured coverage rises (the ratchet).
  6. No browser arithmetic on results. The only exceptions are unit display conversion (PR 3) and counting rows for stat cards.
  7. No hard-coded colours. Tokens only. Light and dark are checked for every new element.
  8. No invented numbers in mock mode. The running app shows only API or golden values.
  9. AGPL header on every new .ts / .tsx file. typecheck, lint --deny-warnings and build are green. CI is green on origin before the PR is called done.

10 · Governance owed at implementation time

  • action-register.md: AR-84 was closed with Marco's ruling (2026-10-02, PR 1 spec commit). The unlocated parked edits are AR-100. If F4 = (a), annotate AR-93, AR-95 and AR-99 with "dashboard renders their BLOCKED state; input not wired".
  • ROADMAP.md: tick within step 6 (P-7), plus a Session Log entry for each PR.
  • docs/mkdocs.yml: this page is in the Design nav beside ui-spec-phase1.md (done in PR 1).
  • UI spec: amend §2.4.3 only if F1 = (a) or F2 = (b). Amend §2.4.1 wording only if Marco keeps "Not computable" over "pending" (§2).