Skip to content

UI specification — Phase 1 (Option A)

Status: build-ready spec, docs only

This page is the Phase-1 UI specification, for roadmap step 6: a thin input shell plus two-tier results. It is written against main at cc5086b, after decisions #31–#33 and the amended #10 and #29 (all pending review in #53).

It supersedes §6.4 (wizard IA) of the Option B/C spec for Phase 1. That page proposed an 8-page geometry wizard. Option C is deferred (#33), so there is no geometry step. Units (§6.1), roles (§6.2) and the honest-BLOCKED rules (§6.3) still apply. This page restates them where the UI depends on them.

It specifies what the UI needs from the API (§6 below). It does not design the backend.


0 · Scope and ground truth

The tool (amended #10, #31, #33). A user enters one project's metadata and one or two material-quantity sets (earth; optionally conventional/BTC) plus the B6 inputs. The API computes GWP for A1–A3, B4 and B6. The UI renders a sleek dashboard and, on desktop, a detailed report.

The UI is a thin client. The browser never adds, multiplies, divides or normalises a result. Totals, shares, per-m² figures and deltas all come from the API. The browser's only arithmetic is converting display units to and from the canonical SI unit (§1.4).

What exists on main today (recon, Session 34):

Area On main Missing for this UI
Result models A1A3Result, A4Result, A5Result. Each has total: Quantity \| null, incomplete_reason: string[] \| null and per-row status, formula_verdict, provenance_verdict, geographic_provenance, pending_african_data, weidema_axis4_score, source_citation and blocked_reason. A1–A3 rows also carry source_epd_id, provenance_cascade_step, pedigree: PedigreeScore (five axes), reference_value and divergence_pct (AR-71). A5 carries total_energy_demand and scope_note. No B4 or B6 result model (lca/b4.py and lca/b6.py are TODO stubs). No envelope across modules, no per-variant summary, no comparison model. No representation of "module not assessed" for the A4/A5 cut-off (#32). A4 and A5 rows carry axis 4 only, not the full pedigree.
Routes GET /lca/{a1-a3,a4,a5}/keur-songho, GET /quantities/keur-songho, POST /quantities/explode (501 for anything but Keur Songho). No project-intake route and no generic compute route (AR-72). No route lists the materials a user may pick from.
Project schema ProjectCreate (identity, functional unit, system boundary, methodology) in schemas/project.py. No route exposes it. A route that accepts it.
Material list See §0.1. A list endpoint.
Frontend ResultsView (stage tabs), AggregatePanel (honest "Not computable" block), stage tables with expandable provenance rows, chips in primitives.tsx, the exhaustive tone maps in lib/verdicts.ts, the honest number formatting in lib/format.ts, the tri-state theme in lib/theme.ts, and the palette tokens in index.css. Mock layer (#50): VITE_API_MODE=mock serves backend goldens through Api.get. No router, no wizard state, no units library, no charts, no Api.post, no settings, no role flag.

0.1 Interim material list (what the Materials picker offers today)

Until Chiwara's LCI structure arrives (AR-78), the pickable list is the 18-row A1–A3 factor table in backend/app/domains/lca/a1_a3.py, keyed by MaterialCategory.

  • compute_a1_a3 accepts only these 18 categories. It raises KeyError for the generic MaterialCategory members (aggregate, binder, metal, timber, …), so the picker must not offer them.
  • The input unit is kg for every row, because the engine takes weights_kg.
  • Four rows resolve from gated seed EPDs in backend/app/data/seed_epds/: cement, steel rebar, rock (crushed stone) and sand (CATEGORY_SEED_FILES). Four more seed EPDs are vendored but not wired. Three of them (ceramic tile, glass, PE film) are declared per m², and steel profile is held with them. Wiring them is the declared-unit seam (AR-46, AR-59, AR-63; Phase-1 step 2).

Statuses as computed on main for the Keur Songho masses:

material_id (category) Label Status Formula Provenance Geography Seed EPD
rock_stone Rock computed CORRECTED SOURCED ÖKOBAUDAT-interim crushed_rock
aggregate_coarse Gravel computed VALIDATED BLOCKED ÖKOBAUDAT-interim —
aggregate_fine Sand computed CORRECTED SOURCED ÖKOBAUDAT-interim sand
earth Earth blocked BLOCKED BLOCKED local data required (#26) —
water Water computed (0) VALIDATED SOURCED n/a —
cement Cement computed CORRECTED SOURCED ÖKOBAUDAT-interim cement (VDZ)
steel_rebar Steel rebars computed CORRECTED SOURCED ÖKOBAUDAT-interim steel_rebar
steel_sheet Steel plates computed VALIDATED BLOCKED ÖKOBAUDAT-interim —
steel_profile Steel profiles computed VALIDATED BLOCKED ÖKOBAUDAT-interim — (vendored, not wired)
plastic_film Polyethylene film computed VALIDATED BLOCKED ÖKOBAUDAT-interim — (vendored, per m²)
bitumen Bitumen computed VALIDATED BLOCKED ÖKOBAUDAT-interim —
petroleum_distillate Crude oil blocked BLOCKED BLOCKED blocked —
waste_oil Waste oil computed VALIDATED BLOCKED ÖKOBAUDAT-interim —
glass Glass computed VALIDATED BLOCKED ÖKOBAUDAT-interim — (vendored, per m²; AR-59)
pesticide Anti-termite blocked BLOCKED BLOCKED blocked —
paint_wall Wall paint computed VALIDATED BLOCKED ÖKOBAUDAT-interim —
paint_antirust Antirust paint provisional CORRECTED BLOCKED blocked —
ceramic_tile Floor tiles blocked BLOCKED BLOCKED ÖKOBAUDAT-interim — (vendored, per m²)

Totals: 13 computed, 1 provisional, 4 blocked. The A1–A3 total is therefore null for any set that contains earth, which every earth variant does. The picker's status column maps directly from this table: computed → usable, provisional → provisional, blocked → blocked.


1 · Design language

1.1 Palette, type and theme

The palette is the warm theme from #43, as defined in frontend/src/index.css. Reuse the tokens; add no hard-coded colours.

Token Light Use
--background #faf6f0 (dark #1c1917) Page
--foreground #2a2320 (dark #f5f0e8) Text
--primary #b5533a terracotta Actions, focus, active step, the earth series in charts
--status-computed #5f7a52 COMPUTED, SOURCED, VALIDATED/CORRECTED
--status-provisional #c2891f PROVISIONAL, mock banner, interim-geography tag
--status-blocked #a23b2e BLOCKED
--card, --muted, --border color-mix derivations Surfaces
  • Conventional series colour (the only new token): --series-conventional, a neutral warm grey derived with color-mix(in oklab, var(--foreground) 45%, var(--background)). It must stay distinguishable from --primary and from the three status colours in both themes. Any series colour must pass a 3:1 contrast against --card.
  • Light/dark: reuse useTheme(). It follows the system, can be pinned light or dark, and is stored per browser.
  • Type: Public Sans for text and IBM Plex Mono for every number. .font-mono already sets tabular-nums. Every figure, in a table, a card or a chart label, uses tabular mono figures so columns and stacked cards line up.

1.2 Minimalism: graphs over text

  • One headline number per card. Explanation lives behind a disclosure (the existing ExpandableRow pattern) or in the detailed report, not on the card face.
  • Charts carry the comparison. Prose is for reasons (why a value is blocked), and reasons are never truncated. Wrap them; do not ellipsise them.
  • No decorative imagery, no gradients, no icons that carry meaning alone. Status is always colour plus a word (computed / provisional / blocked), as the existing Badge does.

1.3 The honest-BLOCKED rule (binding on every screen)

  1. A missing value is never rendered as 0, as a blank, or as a zero-length bar. It renders as the word BLOCKED (or pending, or not provided, §1.3.1) in the blocked style, with its reason on the same screen. Reuse BlockedValue and AggregatePanel.
  2. Always a reason. Every non-value shows the API's blocked_reason or incomplete_reason verbatim. If the API gave none, the UI says "the API gave no reason", as AggregatePanel already does. That is a bug report, not a state to design around.
  3. No partial totals. If the API's total is null, the UI shows no total. It does not sum the computable rows itself. Summing is calculation logic (§0).
  4. A genuine 0 is a value. formatValue(0) is "0.0", which is visibly a number (water in A1–A3). The existing lib/format.ts rules stand.
  5. Tags propagate. A headline carries a chip whenever any contributing row is PROVISIONAL, pending_african_data or user-entered. The API supplies the counts (§6.3).

1.3.1 The four non-value words

Word Meaning Who can fix it
BLOCKED No defensible parameter or factor exists (decision #23 Axis 2). Sourcing, not the user.
not provided An optional user input was left empty (for example, B6 kWh/yr). The user: the chip links back to the field.
not assessed A module is a documented cut-off (A4/A5, #32). Nobody. It is a methodological scope statement.
pending A derived figure whose preconditions are not met (a comparison %, #18). Depends on the reasons listed with it.

1.4 Units: metric ↔ imperial display toggle; the engine stays SI

  • The API is SI only (design §6.1). Every quantity in a request or response is a Quantity(value, unit) in its canonical unit. The API never converts.
  • One conversion module in the frontend: frontend/src/lib/units.ts. It is the only place a conversion factor appears, it is pure, and it is unit-tested both ways.
Canonical Imperial display Factor (exact where defined)
kg lb 1 lb = 0.45359237 kg
t short ton 1 short ton = 907.18474 kg
m ft 1 ft = 0.3048 m
m2 ft² 0.09290304 m²
m3 ft³ 0.028316846592 m³
L US gal 3.785411784 L
km mi 1.609344 km
kWh, kW, h, years unchanged —
kg_CO2_eq, kg_CO2_eq_kg never converted GWP is reported in kg CO₂-eq under EN 15978 in both modes
  • Input: the user types in the display unit. The UI converts once, at the moment of entry, to a full-precision canonical value and stores that value. Re-displaying converts back. The stored canonical value is never re-derived from a rounded display string, so toggling units never drifts a value.
  • Per-area figures (kg CO₂-eq/m²) show as kg CO₂-eq/ft² in imperial mode. The numerator is never converted.
  • The toggle lives in Settings (§2.7) and is mirrored as a compact control in the header.

1.5 Roles (Phase-1 role flag, no authentication)

Role Sees
lca_user (default) Screens 1–5 and Settings
admin Everything above, plus Admin data (§2.8) and a raw-JSON disclosure on the detailed report

The role is a UI mode flag in Settings, stored per browser. It is not a security boundary (design §6.2). No screen offers a write action that the API would accept from an anonymous caller. See AR-82 for the open question of admin LCI management against this rule.

1.6 Layout and breakpoints

  • Mobile-first. Side gutter 16 px under sm, max content width max-w-7xl, as App.tsx has today.
  • Screens 1–4, Settings and Admin data work from 360 px upward with no horizontal page scroll. Wide tables scroll inside their own container.
  • Screen 5 is desktop-only: lg, ≥ 1024 px. Below lg, its route shows a notice ("The detailed report needs a wider screen") with the JSON export action (§2.5.4) still available. The notice does not reflow the report.
  • Accessibility: keep the WAI-ARIA tab pattern already in ResultsView. The step bar is a <nav aria-label="Steps"> with aria-current="step". Every chart has a table equivalent (§2.4.3).

2 · Screens

Field tags (used in every table below):

Tag Meaning
Gate Blocks progress. The Next / Compute action is disabled until the field is valid, and the field shows why.
Needed Progress is allowed. If the field is empty or invalid, the dependent result renders as not provided or BLOCKED, honestly, with the field named.
Blocked-now The field or choice is accepted, but its result is BLOCKED today for a reason outside the user's control (a sourcing row). The UI says so at entry time, not only on the results.
Silent Present in the schema, shown read-only or collapsed, and inert in Phase 1. Sent as-is and used by nothing.
Derived Read-only, set by the app or the API.

Navigation: 1 Project → 2 Materials → 3 Review & compute → 4 Results → 5 Detailed report. Settings and Admin data are reachable from the header on every screen. §3 gives the state model.

2.1 Screen 1 — Project

This screen fills the existing ProjectCreate (schemas/project.py). No parallel model.

Field Schema path Type Canonical unit Validation Tag
Project name identity.name text — 1–120 chars, trimmed Gate
Country identity.iso_country select ISO 3166-1 alpha-3 CountryAlpha3 Gate. B6 needs the national grid factor (#26).
Climate zone identity.koppen_zone select KoppenZone BSh, BWh, Aw, Am, Af only (#16) Gate. Part of the FU lock-in (#18).
Programme identity.typology select Typology enum members only Gate. Part of the FU lock-in (#18).
Date of assessment identity.date_of_assessment date ISO date not in the future Gate (defaults to today)
Gross internal area functional_unit.gia number m2 > 0, finite Gate. It is the functional unit. With no geometry step (Option C deferred), the user enters it directly.
Reference study period functional_unit.rsp_years integer years 1–200 Gate, default 30 (#15). Helper text: "30 vs 50 years is an open decision (#33, AR-12); 30 applies until it is decided."
Region, city identity.region, identity.city text — ≤ 120 chars Silent
Latitude, longitude identity.latitude, identity.longitude number degrees existing bounds Silent. Inert in Phase 1: AR-80 was resolved on 2026-10-01 as fork (b), supplied kWh/yr. They become inputs in Phase 2 with fork (a), when the tool computes B6.
Comfort setpoint, occupancy functional_unit.comfort_setpoint_celsius, .occupancy_profile number, select degC, enum existing bounds Silent (same reason)
Modules system_boundary.modules_included chips LCModule — Derived: A1–A3, B4, B6. A4 and A5 show as not assessed, cut-off (#32). Not editable in Phase 1.
Indicator system_boundary.indicators_reported chip ImpactIndicator — Derived: GWP only (#32)
LCIA method, uncertainty n, grid policy, max cascade step methodology.* — — — Silent, collapsed under Method settings

Actions: Load Keur Songho example (§2.2.4) · Next.

2.2 Screen 2 — Materials (Option A)

Two variant tabs over the same project context:

Variant Tag Notes
Earth (study variant) Gate: at least one valid row Always present
Conventional / BTC Needed Optional. With no rows, the comparison renders pending, reason "no conventional set provided". Blocked-now: Chiwara's file (AR-79) has not arrived, so there is nothing real to enter yet. The tab still works.

Each tab holds one material table and one B6 block. B4's only user input is an optional per-row finishes fraction with its basis (§2.2.3).

2.2.1 Material table (the Option-A contract)

One row per material. Material × quantity × unit, where the unit comes from the LCI record.

Column Type Validation Tag
Material searchable select over the LCI list (§0.1, from GET /lci/materials) Must be a record id from the list. No duplicate material within a variant: the engine keys by material, so a duplicate is rejected at entry with "already in row n — edit that row". Gate (per row)
Quantity number, in the display unit > 0, finite. Remove a row rather than enter 0. Gate (per row)
Unit label Set by the record's input_unit (today kg for every record). Shown in the display unit (kg or lb). Not user-editable. No unit is selectable that the record does not declare. Derived
Record status chip From the list: usable / provisional / blocked with its reason Derived. A blocked record (for example Earth, BLOCKED under #26) is Blocked-now: it may be added, the chip warns at once, and its A1–A3 result is BLOCKED.
Geography tag chip pending_african_data → "ÖKOBAUDAT-interim, EU-contextualised" (#26) Derived
Basis / note text ≤ 280 chars Needed. Where the quantity comes from. It is echoed into the report and never used in calculation.

Import: CSV or JSON, with columns material_id, quantity, unit[, note].

  • A row whose unit is not the record's canonical unit, or its display-unit equivalent, is rejected, not converted by guesswork.
  • An unknown material_id is rejected, as is a duplicate.
  • The import shows n accepted · m rejected with every rejection and its reason. Nothing is dropped silently. Accepted rows merge only after the user confirms.
  • Parsing is client-side. It is not calculation.

Empty state: "Add materials or load the Keur Songho example."

2.2.2 B6 block: operational energy (kept simple per AR-80)

One block per variant, because earth and conventional buildings differ in operational energy.

Field Type Canonical unit Validation Tag
Annual delivered energy number kWh per year ≥ 0, finite Needed. If empty, B6 renders not provided and the whole-life headline is pending.
Energy carrier select, populated from GET /lci/energy-carriers enum a listed carrier Needed. Default: national grid electricity for the project country.
Basis text — ≤ 280 chars; required when the kWh value is set Needed. AR-80: a supplied kWh/yr needs its own provenance (who, method, date).
  • Blocked-now: EF_grid is unsourced (AR-65), so B6 GWP is BLOCKED even with valid inputs. The block says so at entry time: "B6 will compute energy (kWh) but its carbon is BLOCKED until a grid factor is sourced (AR-65)."
  • Gated on AR-80. If AR-80 decides the tool computes kWh from the thermal model, this block is replaced by thermal inputs. That would be a spec revision, not a field addition.

2.2.3 B4 takes a per-row finishes fraction

(Amended Session 42: this section was "B4 needs no user input". That assumed the finishes fraction f was LCI-side. It is a property of the design, not of the material — the §3.3 fractions describe Keur Songho — so it is a per-row project input. See the B4 design spec §2.)

B4 is computed per row as ⌈RSP / RSL⌉ − 1 × f × the row's A1–A3 impact, from the per-material RSL table (decision #24; held on the LCI side), the RSP from Screen 1, and each row's finishes fraction f.

Column Type Validation Tag
Finishes fraction number, 0–1 Finite, in [0, 1]. Empty = not provided → that row's B4 is BLOCKED. 0 = nothing replaced (structural). Never defaulted. Needed (per row)
Finishes basis text ≤ 280 chars; required whenever a fraction is entered, including 0 Needed (per row)

The request fields are finishes_fraction and finishes_basis on each material row; both are optional, so a request without them is valid and returns B4 incomplete. The wizard does not collect them yet (AR-93). RSL stays LCI-side and is not user-editable.

Blocked-now: every RSL is uncited today (AR-12), so every B4 row with f > 0 (or no f) is BLOCKED until sourced. There is no "never replaced" default (#24). A row with an attributed f = 0 is a real zero and does not consult the RSL.

2.2.4 Keur Songho built-in example

Load Keur Songho example (on Screens 1 and 2) replaces the whole draft with the example request from GET /projects/examples/keur-songho (§6.2). It asks for confirmation first if the draft is non-empty. Its earth set is the 18-material A1–A3 inventory. It carries a Derived badge, "Reference example — parity-validated against 2_lca_vn.xlsx (A1–A3)". Every field stays editable, and an edit drops the badge.

The example masses are the pre-correction path

The example's masses are _KEUR_SONGHO_WEIGHTS_KG, which is pre-correction (AR-46). The detailed report says so in the example's provenance line until AR-46 closes (Phase-1 step 2).

2.3 Screen 3 — Review & compute

A read-only echo of every input, grouped as Project · Earth set · Conventional set · B6. Each group has an Edit link that jumps to that step with the field focused.

Element Content Tag
Flags summary Counts: materials whose record is blocked, provisional, or pending African data; B6 not provided; conventional set not provided Derived (from the list metadata, before compute)
FU lock-in panel Shown when both sets exist. GIA, RSP, climate and programme are shared by construction, because there is one project context. Derived
Same building attestation Checkbox: "Both sets describe the same building and functional unit." Amended #29: with supplied sets, the lock-in is asserted on the inputs. Needed. Unchecked → no comparison is requested, and the result says comparison pending: same-building not attested.
Compute POST /projects/compute (§6) Gate: Screens 1–2 valid
  • While computing: the button shows progress. Inputs stay editable. A new compute aborts the one in flight (AbortSignal).
  • On error: the API message renders verbatim in the blocked style (as LoadState does now). The draft is kept.
  • On 422: each validation error is mapped to its field. The UI jumps to the first one.

2.4 Screen 4 — Results (sleek dashboard)

Built for every width. One variant is primary (earth). The conventional variant appears in the comparison elements only.

2.4.1 Headline

Element Source (§6.3) Non-value rendering
Whole-life GWP (A1–A3 + B4 + B6) summary.total null → pending, with summary.incomplete_reason listed (the AggregatePanel pattern)
Per m² GIA summary.total_per_m2 same
Per m² GIA per year summary.total_per_m2_year same
Tag chips summary.provisional_count, .interim_geography_count, .user_entered_count Hidden only when the count is 0

2.4.2 Module cards

Three cards, in order: A1–A3, B4, B6. Each card shows:

  • the module total, or BLOCKED / not provided with its reason count;
  • the share of the whole-life total (API-supplied; null whenever the whole-life total is null);
  • blocked_count;
  • one line of secondary data where the module has a factor-independent quantity: B6 kWh/yr (as A5's total_energy_demand does), and B4 replacements count.

A4 and A5 are folded into one muted card, "A4 Transport · A5 Construction — not assessed (documented cut-off, #32)". Its disclosure shows the justification when AR-81 provides it. Until then it shows "cut-off justification pending (AR-81)". The card never shows a number, and nothing from A4/A5 enters the headline.

2.4.3 Stat cards and graphs

Element Content Rules
Stat cards Materials entered · materials blocked · RSP · GIA Mono figures
Module split Horizontal bar per module (A1–A3, B4, B6) with the value label A module with total = null gets a labelled blocked row with no bar; it is never a zero-length bar. Percent labels only when share is non-null.
Earth vs conventional Paired horizontal bars per module (--primary for earth, --series-conventional for conventional), plus a whole-life pair Drawn only when a conventional set was provided. Each bar follows the module-split rule.
vs-conventional % comparison.delta_pct, signed (formatSignedPercent), with its band when present Shown only when the API returns a non-null delta (#18, amended #29: both totals exist and a band exists). Otherwise pending, with comparison.reasons listed. The browser never computes the ratio.
  • Charts are hand-rolled SVG (two bar forms only). No chart library in Phase 1.
  • Each chart has a visually hidden <table> equivalent and a Show as table toggle.

In Phase 1 the vs-conventional % will read pending, even with both totals

Decision #18 requires a Monte Carlo band on every comparison. services.uncertainty is Phase 2 and needs the Ciroth table (AR-13), so the API will return delta = null with the reason "no uncertainty band" even once both totals exist. The UI renders that honestly. Whether Phase 1 may show an explicitly unbanded delta is a decision, not a UI choice. It is recorded as AR-83.

Actions: Open detailed report (desktop only; hidden below lg) · Edit inputs (→ Screen 2) · Re-compute (visible when the results are stale, §3.3).

2.5 Screen 5 — Detailed report (desktop only, ≥ 1024 px)

The audit tier. Everything the API returns is reachable here, and nothing here is computed in the browser.

2.5.1 Structure

  1. Header: project, variant switcher (earth / conventional), engine version, LCI version, computed-at time, input hash. It carries a stale banner when the results no longer match the inputs (§3.3).
  2. Per-module, per-material tables: A1–A3 (reuse A1A3Table as-is), B4, B6. A4/A5 appear under Not assessed modules only as the cut-off statement. Their tables are not shown in Phase 1, even for Keur Songho, where values exist. This keeps the report consistent with #32.
  3. Verdict chips on every row, from the existing primitives: StatusChip, FormulaVerdictChip (with the Δ marker; FormulaVerdictDetail states the reference value and Δ for CORRECTED rows), ProvenanceChip, PendingAfricanDataBadge.
  4. Provenance drill-down (the ExpandableRow pattern): citation (Citation), source EPD id, cascade step, the five Weidema axes (PedigreeScore, as a five-cell mono strip, 1–5 each; not scored where the API returns null), geography note, blocked reason, and the user's quantity basis note from Screen 2.
  5. Reasons index: every incomplete_reason and blocked_reason in one list, grouped by module. This is the page a reviewer reads first.
  6. Inputs echo: the request as the API echoed it (inputs_echo), in display units with the canonical value beside it.

2.5.2 B4 table columns (when the module lands)

Material · RSL (years, with verdict) · RSP · replacements · quantity replaced · factor · impact · status · reason.

2.5.3 B6 table columns (when the module lands)

Carrier · annual energy (kWh/yr) · RSP · factor (kg CO₂-eq/kWh, with provenance) · impact · status · reason. It also carries the "simplified — indicative" disclaimer (#17), verbatim from the API.

2.5.4 Export

Format Content Notes
JSON The response envelope, verbatim Byte-for-byte what the API returned. Available at every width.
CSV One file per module table, per variant Canonical units in the header (impact_kg_CO2_eq). Nulls export as the empty field plus a status and reason column, never as 0.
Print / PDF Print stylesheet of the report Light theme forced. Includes the reasons index and the ÖKOBAUDAT attribution line (#25).

Admin only: a Raw response disclosure showing the JSON inline.

2.6 Screen transitions and empty states

Situation Behaviour
Screen 4 or 5 opened with no results "No results yet — review and compute." with a link to Screen 3
Mock mode The existing non-dismissible MockBanner on every screen
API unreachable (live) Blocked-style error with the message. The draft is kept.

2.7 Settings

Field Type Values Default Tag
Units segmented metric / imperial metric Stored per browser
Theme segmented system / light / dark system Reuse useTheme
Role segmented LCA user / admin LCA user Stored per browser. Helper: "A display mode, not a login."
Clear draft button — — Confirms first

All Settings values go in localStorage, with every access in try/catch (the theme.ts pattern). The app works with storage unavailable.

2.8 Admin data (admin role only)

Read-only in Phase 1. See AR-82.

Tab Content Source
LCI records Every record the Materials picker offers: id, label, category, input unit, factor, status and reason, geography tag, cascade step, five Weidema axes, citation, source_epd_id, last modified. Filter by status. GET /lci/materials?detail=full
Gate report For each seed/LCI file: passes the provenance gate yes/no, and why not Same endpoint
RSL table Per material: RSL, verdict, citation GET /lci/rsl
Energy carriers Carrier, country, factor, verdict, citation GET /lci/energy-carriers

Gated on AR-77 / AR-78: the columns follow Chiwara's LCI structure once it arrives. The interim build shows what the seed EPDs and the A1–A3 factor table carry today.


3 · Wizard state model

3.1 Shape

One client-side draft, the single source of truth for Screens 1–3:

Draft
  project:  ProjectCreate                       // Screen 1
  variants: {
    earth:        { materials: Row[], b6: B6Input }
    conventional: { materials: Row[], b6: B6Input } | null
  }
  attest_same_building: boolean                 // Screen 3
  example: "keur-songho" | null                 // cleared on any edit
  Row     = { material_id, quantity: Quantity /*canonical*/, note }
  B6Input = { annual_energy: Quantity(kWh) | null, carrier | null, basis }
Session
  step: 1..5
  result: { envelope, input_hash, computed_at } | null

3.2 Rules

  1. Linear steps, free back and forth. The step bar shows steps 1–5. Any step already reachable can be clicked. Next is disabled only by a Gate field on the current step. Screens 4–5 are reachable once a result exists.
  2. State persists. The draft is saved to localStorage (key chwr-draft-v1), debounced, in try/catch. It is a per-browser convenience, not a project store: Phase 1 stores no projects server-side (design §3.2). Export draft / Import draft as JSON is the portable form.
  3. Edit any step. Every Review group and every results figure that comes from an input links to its field.
  4. No geometry or envelope step. Option C is deferred (#33). GIA is entered directly.
  5. Validation is local for shape (types, ranges, duplicates) and authoritative on the server. A server 422 overrides local validity.

3.3 Re-compute and staleness

  • input_hash is a stable hash of the canonical request body.
  • When the draft's hash differs from the result's hash, Screens 4 and 5 show a stale banner, "Inputs changed since this result — re-compute." The figures stay visible but are dimmed, and CSV and print export are disabled. JSON export stays: it is the verbatim envelope of the old request, and is labelled so.
  • No auto-recompute. Compute is always a user action.

4 · The two-tier results contract (what each tier renders)

Sleek dashboard (Screen 4) Detailed report (Screen 5)
Width All ≥ 1024 px
Headline Whole-life total, per m², per m²·yr, tag chips Same, in the header
Modules Cards A1–A3 / B4 / B6; A4/A5 folded as not assessed Per-module per-material tables; A4/A5 as the cut-off statement
Rows Counts only Every row, every verdict chip
Provenance None on the face Full drill-down: citation, EPD id, cascade step, five axes, geography, reasons
Comparison Paired bars; % only when the API's delta is non-null, else pending with reasons Side-by-side variant switch; the comparison's reasons in the reasons index
Graphs Module split; earth vs conventional None (tables are the report)
Export — JSON · CSV · print
Reasons Counted, with the first reasons listed under a null headline Complete reasons index

5 · Build order: contract-first, mock-first

The frontend is built against this contract in mock mode first, reusing the #50 layer, and lights up per endpoint as the backend lands.

  1. Contract types. Until the backend generates them, hand-write the §6 request and response types in frontend/src/api/contract.ts, marked provisional. When the endpoint lands, the generated schema.gen.ts replaces them. The openapi-drift CI job and tsc then catch any divergence. As built (Session 38): done for POST /projects/compute; contract.ts is gone.
  2. Extend Api with post. In mock mode, post serves backend-produced goldens only (the #50 rule). The one mocked compute is the Keur Songho example request. Any other request body in mock mode gets "mock: no fixture for these inputs", rendered as an error state.
  3. No invented numbers in mock mode. A comparison fixture does not exist until Chiwara's file (AR-79) and a backend golden do. Chart and comparison states are exercised in unit tests with fixtures under frontend/src/test/, and every such fixture is labelled synthetic in its name and never shown in the running app.
  4. Until POST /projects/compute exists, Screen 4/5 development can render the existing GET /lca/a1-a3/keur-songho golden inside the envelope shape. Do this in tests only; the running app must not assemble an envelope client-side.

5.1 Readiness per screen

Screen Build-ready now (mock) Lights up live when Gated on
Design language, units, Settings, role flag Yes — —
1 Project Yes. ProjectCreate exists. Compute endpoint (AR-72) RSP default stays 30 until #33 (b) is decided (AR-12). No build gate.
2 Materials: table + import Yes, over the 18-row interim list GET /lci/materials AR-78 / AR-77: the final record shape and id scheme come from Chiwara's LCI structure. The interim list is keyed by MaterialCategory.
2 Materials: conventional tab Yes (UI) — AR-79: no real data to enter
2 Materials: B6 block Provisionally, as specified GET /lci/energy-carriers, lca.b6 AR-80 (may replace the block); AR-65 keeps B6 GWP BLOCKED
2 Materials: B4 line Yes (read-only) lca.b4, GET /lci/rsl AR-12 keeps B4 BLOCKED
3 Review & compute Yes (mock compute: Keur Songho only) AR-72 (POST /projects/compute) —
4 Results: headline, A1–A3 card, A4/A5 fold Yes AR-72 AR-81 for the fold's justification text
4 Results: B4 / B6 cards Yes, as BLOCKED states Phase-1 steps 3–4 AR-80 (B6)
4 Results: earth vs conventional, % Tests only (synthetic fixtures) Phase-1 step 5 AR-79; the % also on AR-83
5 Detailed report Yes. The A1–A3 table and drill-down exist today. AR-72 B4/B6 tables on steps 3–4
Admin data Interim (seed EPDs + A1–A3 table) GET /lci/* AR-77 / AR-78; AR-82 for any write action

6 · API contract this UI needs

This section states requirements, not a backend design. Names follow design §3.2 where one already exists. The backend session that builds these owns the final schema, and this page is updated to match.

6.1 Backend prerequisites

# Prerequisite Register / roadmap
P1 Project intake plus a generic compute endpoint, taking inputs in the body (stateless) AR-72; Phase-1 step 6
P2 lca.b4 result model and module Phase-1 step 3; AR-12
P3 lca.b6 result model and module, with a supplied-kWh input path Phase-1 step 4; AR-80, AR-65
P4 Comparison from two provided sets: the same engine on both, delta rules per #18 and amended #29 Phase-1 step 5; AR-79, AR-83
P5 A material list endpoint carrying the record metadata the picker needs Phase-1 steps 1–2; AR-77, AR-78
P6 A "not assessed" representation for A4/A5 in the envelope #32; AR-81
P7 The built-in Keur Songho example as a request body #33

6.2 Endpoints

Method and path Purpose
GET /lci/materials Built (Session 35), schema LciMaterialList. The pickable list (§0.1). Per record: material_id, label, category, input_unit (kg), factor + factor_unit, status (computed / provisional / blocked — the engine's MaterialResultStatus; the UI maps computed → usable), selectable, blocked_reason, and provenance { source_citation, provenance_verdict, pending_african_data, geographic_provenance, source_epd_id }. Every MaterialCategory appears once: the 18 factor-table rows are selectable; the 7 generic umbrella categories and raw_earth (AR-53) follow with selectable: false and a reason. Not yet built: ?detail=full (cascade step, pedigree, gate result).
GET /lci/energy-carriers?country= B6 carriers valid for a country, each with its factor status
GET /lci/rsl B4 RSL table (admin view)
GET /projects/examples/keur-songho The built-in example as a ComputeRequest
POST /projects/compute Built (Session 37), schemas ComputeRequest → ResultEnvelope (schemas/compute.py). See As built under §6.3. Not yet live in the frontend (the flip is a follow-up).

A separate POST /projects/compare (design §3.2) is equally acceptable. The UI needs only that one call returns both variants and the comparison.

6.3 Shapes (requirements)

ComputeRequest
  project:  ProjectCreate                                  // existing schema
  variants: [ VariantInput ]  (1..2, keys unique)
  attest_same_building: bool                               // required true for a comparison

VariantInput
  key:  "earth" | "conventional"
  materials: [ { material_id, quantity: Quantity, note? } ] (≥ 1; unique material_id;
             quantity.unit must equal the record's input_unit — else 422, no conversion)
  b6: { annual_energy: Quantity(kWh) | null, carrier | null, basis? }

ResultEnvelope
  engine_version, lci_version, computed_at, input_hash
  inputs_echo: ComputeRequest                              // canonical, as received
  variants: [ VariantResult ]
  comparison: Comparison | null                            // null when one variant
  cut_off: { modules: ["A4","A5"], status: "not_assessed",
             justification: str | null }                   // null until AR-81

VariantResult
  key
  summary:
    total, total_per_m2, total_per_m2_year: Quantity | null
    incomplete_reason: [str] | null
    provisional_count, interim_geography_count, user_entered_count, blocked_count: int
    modules: [ { module: "A1-A3"|"B4"|"B6",
                 status: "complete" | "incomplete" | "not_provided",
                 total: Quantity | null, share: float | null,
                 blocked_count: int, incomplete_reason: [str] | null,
                 secondary: Quantity | null } ]            // B6 kWh/yr; B4 replacements
  detail:
    a1_a3: A1A3Result                                      // existing model, unchanged
    b4:    B4Result                                        // new (P2)
    b6:    B6Result                                        // new (P3)

Comparison
  per_module + whole_life: [ { module, earth_total, conventional_total,
                               delta_abs: Quantity | null, delta_pct: float | null,
                               band: { low, high } | null, reasons: [str] | null } ]

As built (Session 37). The request is exactly the shape the #58 wizard emits (toComputeRequest): project: ProjectCreate (unchanged; no parallel model), variants (1–2, unique keys, earth required), attest_same_building. A material row is {material_id, quantity: {value, unit}, note?}. b6 is {annual_energy: Quantity(kWh) | null, carrier: "grid" | "diesel" | null, basis?}, and basis is required when annual_energy is set. Validation, all as 422 in FastAPI's detail[{loc, msg, type}] shape:

  • an unknown material_id gives unknown_material; a non-selectable one (umbrella categories, raw_earth) gives material_not_selectable, with the list's reason;
  • a unit the record does not accept gives unit_not_accepted. kg is accepted for every record. m2 is also accepted for glass, PE film and ceramic tile, whose vendored seed EPDs are declared per m². That row comes back BLOCKED, declared-unit handling pending (seam), so the declared-unit seam (AR-46, AR-59, AR-63) fills in the m² path without a contract change. In kg those three behave exactly as in GET /lci/materials;
  • duplicate ids, a quantity ≤ 0, and a non-kWh B6 unit are shape errors.

The response follows the ResultEnvelope above, with these differences:

  • input_hash, inputs_echo, variants, cut_off and comparison (always null) are built, plus comparison_pending_reason: [str];
  • engine_version, lci_version, computed_at and user_entered_count are not built yet;
  • cut_off carries reason ("not assessed — cut-off (decision #32)"); justification stays null (AR-81);
  • summary.modules lists all five Phase-1 modules. A4 and A5 have status: "not_assessed" and in_whole_life: false. B4 is incomplete unless every row is COMPUTED (today only an all-f = 0 variant completes it); B6 is incomplete with carbon BLOCKED, or not_provided when annual_energy is null, and otherwise echoes it in secondary;
  • detail.b4 is a B4Result (per-row replacements, RSL verdict, echoed f and basis, and every gap named; B4 design spec §3); detail.b6 is a B6Result;
  • per-m² figures use kg_CO2_eq_m2 and kg_CO2_eq_m2_yr.

Rules the UI relies on:

  1. total is null whenever any contributing row is not COMPUTED. This is the existing lca._common.aggregate rule, applied again at module, variant and whole-life level.
  2. A module that is not implemented returns status: "incomplete" with a reason such as "lca.b4 not implemented". It is never omitted and never 0. This is how the UI renders B4/B6 cards before steps 3–4 land.
  3. not_provided is set only for an empty optional user input (B6 kWh). A missing defensible parameter is BLOCKED (design §6.3 rule 3).
  4. delta_* is non-null only when both totals are non-null and a band exists (#18, amended #29). Otherwise reasons names every failed condition.
  5. share is null whenever the whole-life total is null.
  6. Every per-row model carries its full provenance. A4/A5-style rows that carry axis 4 only are not acceptable for B4/B6: the detailed report needs the five-axis pedigree on every row.

7 · Out of scope for this page

  • Backend schema and route design (§6 states requirements only).
  • The Option-B works → materials catalog UI (fast-follow, AR-75) and any geometry input (Option C, AR-76).
  • Monte Carlo bands (Phase 2, services.uncertainty), beyond rendering one when the API returns it.
  • Server-side project storage, authentication, multi-user.
  • Indicators other than GWP (#32).