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_a3accepts only these 18 categories. It raisesKeyErrorfor the genericMaterialCategorymembers (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 withcolor-mix(in oklab, var(--foreground) 45%, var(--background)). It must stay distinguishable from--primaryand 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-monoalready setstabular-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
ExpandableRowpattern) 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 existingBadgedoes.
1.3 The honest-BLOCKED rule (binding on every screen)¶
- 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
BlockedValueandAggregatePanel. - Always a reason. Every non-value shows the API's
blocked_reasonorincomplete_reasonverbatim. If the API gave none, the UI says "the API gave no reason", asAggregatePanelalready does. That is a bug report, not a state to design around. - 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). - A genuine 0 is a value.
formatValue(0)is"0.0", which is visibly a number (water in A1–A3). The existinglib/format.tsrules stand. - Tags propagate. A headline carries a chip whenever any contributing row is PROVISIONAL,
pending_african_dataor 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 widthmax-w-7xl, asApp.tsxhas 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. Belowlg, 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">witharia-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
unitis not the record's canonical unit, or its display-unit equivalent, is rejected, not converted by guesswork. - An unknown
material_idis 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_gridis 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
LoadStatedoes 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
shareof the whole-life total (API-supplied;nullwhenever the whole-life total isnull); blocked_count;- one line of secondary data where the module has a factor-independent quantity: B6
kWh/yr(as A5'stotal_energy_demanddoes), 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¶
- 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).
- Per-module, per-material tables: A1–A3 (reuse
A1A3Tableas-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. - Verdict chips on every row, from the existing primitives:
StatusChip,FormulaVerdictChip(with the Δ marker;FormulaVerdictDetailstates the reference value and Δ for CORRECTED rows),ProvenanceChip,PendingAfricanDataBadge. - Provenance drill-down (the
ExpandableRowpattern): 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 returnsnull), geography note, blocked reason, and the user's quantity basis note from Screen 2. - Reasons index: every
incomplete_reasonandblocked_reasonin one list, grouped by module. This is the page a reviewer reads first. - 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¶
- 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.
- State persists. The draft is saved to
localStorage(keychwr-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. - Edit any step. Every Review group and every results figure that comes from an input links to its field.
- No geometry or envelope step. Option C is deferred (#33). GIA is entered directly.
- 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_hashis 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.
- 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 generatedschema.gen.tsreplaces them. Theopenapi-driftCI job andtscthen catch any divergence. As built (Session 38): done forPOST /projects/compute;contract.tsis gone. - Extend
Apiwithpost. In mock mode,postserves 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. - 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. - Until
POST /projects/computeexists, Screen 4/5 development can render the existingGET /lca/a1-a3/keur-songhogolden 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_idgivesunknown_material; a non-selectable one (umbrella categories,raw_earth) givesmaterial_not_selectable, with the list's reason; - a unit the record does not accept gives
unit_not_accepted.kgis accepted for every record.m2is 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. Inkgthose three behave exactly as inGET /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_offandcomparison(alwaysnull) are built, pluscomparison_pending_reason: [str];engine_version,lci_version,computed_atanduser_entered_countare not built yet;cut_offcarriesreason("not assessed — cut-off (decision #32)");justificationstaysnull(AR-81);summary.moduleslists all five Phase-1 modules. A4 and A5 havestatus: "not_assessed"andin_whole_life: false. B4 isincompleteunless every row is COMPUTED (today only an all-f = 0variant completes it); B6 isincompletewith carbon BLOCKED, ornot_providedwhenannual_energyis null, and otherwise echoes it insecondary;detail.b4is aB4Result(per-row replacements, RSL verdict, echoedfand basis, and every gap named; B4 design spec §3);detail.b6is aB6Result;- per-m² figures use
kg_CO2_eq_m2andkg_CO2_eq_m2_yr.
Rules the UI relies on:
totalisnullwhenever any contributing row is not COMPUTED. This is the existinglca._common.aggregaterule, applied again at module, variant and whole-life level.- 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. not_providedis set only for an empty optional user input (B6 kWh). A missing defensible parameter is BLOCKED (design §6.3 rule 3).delta_*is non-null only when both totals are non-null and a band exists (#18, amended #29). Otherwisereasonsnames every failed condition.shareisnullwhenever the whole-life total isnull.- 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
pedigreeon 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).