A single-page, static web app for publishing Digital Product Passports for our solar panels. Everything runs client-side (no backend), so it works on GitHub Pages and any static host.
-
The page loads index.html and reads the query string ?serial=....
-
It fetches /passports/.json (case-sensitive filename) and renders:
-
Header KPIs (power, efficiency, etc.)
-
“Passport info” (origin, compliance, hazardous substances…)
-
Composition doughnut (Material / Chemical / Value (price) views)
-
Component Explorer (per-component: Specimen info, Chemical, Sourcing, Design)
-
Mechanical properties explorer (and Use / EoL / Sorting / Regulations / Environment)
-
Extra categories (e.g., “General composition”)
-
If the dataset for a module is missing (404), the page shows an inline error.
repo-root/
├─ index.html # App (HTML + CSS + JS in one file)
├─ passports/ # One JSON per module (by SERIAL)
│ ├─ BS-2025-0001.json
│ ├─ KS2508P01003.json
│ └─ … more
├─ assets/ # Static assets
│ ├─ biosphere-logo-white.png
│ ├─ biosphere-logo-dark.png # (optional, used in light mode)
│ └─ … images, PDFs, etc.
└─ README.md # This file
- Open directly with Live Server (VS Code) or:
python3 -m http.server 8080
-
Settings → Pages → Source: main (or our default branch) → Root.
-
Public URL will be something like:
https://<org>.github.io/<repo>/?serial=KS2508P01003
- Use this URL in your QR codes if you want to deep-link to a specific module.
Put a file in passports/.json. Keep keys consistent with examples below.
{
"uid": "urn:bs:pv:KS2508P01003",
"version": "1.0",
"last_updated": "2025-10-01",
"model": "V1.4 IBC",
"manufacturer": "Biosphere Solar",
"manufacture_date": "2025-01-07",
"power_w": 392,
"cell_type": "TOPCon",
"dimensions_mm": "1952 x 1134 x 35",
"weight_kg": 26.37,
"efficiency_pct": 21.6,
"co2e_kg": null,
"lifetime_years": 25,
"warranty_years": 25,
"certifications": ["IEC 61215", "IEC 61730"],
"materials": {
"Glass": "Low iron semi tempered soda lime glass",
"Cells": "Bifacial TOPCon",
"Frame": "6005 Alu",
"Edge Seal": "Desiccated PIB",
"Junction Box": "Copper and PPO"
},
"material_composition_pct": {
"glass": 45,
"cells": 15,
"edge seal": 3,
"junction box": 5,
"frames": 10
},
"chemical_composition_pct": {
"copper (cu)": 0.30,
"phosphorus (p)": 0.23,
"silicon (si)": 0.40,
"lead (pb)": 0.08
},
"cost_breakdown_eur": {
"glass": 120,
"cells": 51,
"edge seal": 0.8,
"junction box": 3.40,
"frames": 12
},
"safety_datasheet_url": "https://.../Safety-Datasheet.pdf",
"esg_statement_url": "https://.../ESG.pdf",
"lca_report_url": "https://.../LCA.pdf",
"epd_url": "Not Available",
"hs_code": null,
"origin": "EU",
"compliance": ["Reg. (EU) 2024/1781 framework", "IEC 61215", "IEC 61730"],
"hazardous_substances": ["Lead solder (SnPb) <0.1%", "No PFAS in encapsulant"],
"recycled_content": { "Glass": "0%", "Aluminium frame": "0%" },
"recyclability": "≥90% by weight via standard PV recycling with disassembly route",
"updates": ["2025-09-01: Junction box replaced by Installer Co."],
"components": [
{
"name": "Final sample panel",
"categories": {
"Component/Specimen information": {
"Part details": [
{ "label": "Length", "value": 1952, "unit": "mm", "datatype": "number" },
{ "label": "Width", "value": 1134, "unit": "mm", "datatype": "number" },
{ "label": "Height", "value": 35, "unit": "mm", "datatype": "number" }
]
},
"Chemical composition": { "Presence (Yes/No)": [ { "label": "SVHCs", "value": true, "datatype": "boolean", "comments": "Lead" } ] },
"Sourcing composition": { "—": [ { "label": "Virgin/Fossil (%)", "value": 100, "unit":"%","datatype":"number" } ] },
"Design": { "Lay up scheme": [ { "label": "Product drawings", "datatype": "file", "files": ["https://.../panel-datasheet.pdf"] } ] }
}
}
],
"extra_categories": {
"General composition": {
"Spacer Dot": {
"Resin system": [ { "label": "Resin details", "value": "Acrylate Resin mixture", "datatype": "text" } ],
"Fibre": [ { "label": "Fibre type", "value": null, "datatype": "text" } ]
}
},
"Use": { "Final sample panel": { "Instructions of use and assembly": [ { "label": "Instructions (PDF)", "datatype": "file", "files": ["https://.../use.pdf"] } ] } },
"End of life (EoL)": {
"Final sample panel": {
"Recycling": [
{ "label": "Recycler name", "value": "Solar2Cycle", "datatype": "text" },
{ "label": "Can the product be mechanically recycled?", "value": true, "datatype": "boolean" }
]
}
},
"Mechanical properties": {
"Final sample panel": {
"—": [ { "label": "Tensile strength", "value": 120, "unit":"MPa", "datatype":"number" } ]
}
},
"Sorting": { "Final sample panel": { "NDT Results": [ { "label":"NDT", "value":"Yes", "datatype":"text"} ] } },
"Regulations": { "Final sample panel": { "REACH": [ { "label": "REACH status", "value":"Internal declaration only", "datatype":"text"} ] } },
"Environment": { "Final sample panel": { "LCA data": [ { "label":"Energy Use", "value":"450–730", "unit":"kWh/kWp", "datatype":"text"} ] } }
}
}
-
Serial & filename must match exactly:
passports/KS2508P01003.json ↔ ?serial=KS2508P01003. -
Keys & labels:
-
Our color map uses normalized labels (“glass”, “cells”, “frame”…). If you change a label (e.g., "junction box, cables, MC4" vs "junction box"), the chart still renders, but it’ll use a fallback color. Keep names consistent or extend the color map in index.html (MATERIAL_MAP) to include our new label.
-
For Chemical keys, we normalize lower-case but our JSON should still be consistent (e.g., phosphorus (p) vs phosphorus (po)).
-
Hide empty tabs automatically:
If
material_composition_pctis missing/empty → the Material tab is hidden. Ifchemical_composition_pctis missing/empty → Chemical tab is hidden. Ifcost_breakdown_euris missing/empty → Value tab is hidden. -
Booleans: use real booleans
(true/false)where possible. Strings “Yes/No” also render, but prefer booleans. -
Files: to keep sensitive docs private, link to Google Drive with viewer permission restricted to our org. The UI only shows an Open link; Drive controls the access.
-
Material composition (%) →
material_composition_pctValues must be numbers or strings with numbers (e.g., 45 or "45%"). -
Chemical composition (%) →
chemical_composition_pctSame numeric rules. -
Value (by price, €) →
cost_breakdown_eurAccepts numbers(120)or strings like"€120.00". The app parses them and converts to percentages under the hood.If two datasets present (e.g., Material + Value), all three tabs appear. If one is absent, its tab is omitted.
-
Component Explorer is driven by
components[].categories.- Category names you can use (free text):
Component/Specimen information,Chemical composition,Sourcing composition,Design, …
- Category names you can use (free text):
-
Each category contains groups (object keys), each group is an array of fields like:
{ "label":"Width", "value": 1134, "unit":"mm", "datatype":"number" }
Other datatypes we support: text, number, boolean, file (files: [urls]).
-
Mechanical properties explorer (right column) is driven from
extra_categories:-
It shows only these categories when present:
“Mechanical properties”,“Use”,“End of life (EoL)”,“Sorting”,“Regulations”,“Environment”. -
Put them inside
extra_categories(not top-level). This is a common mistake that hides the content.
-
- Colors live in CSS variables at the top of
index.html.
:root{
--brand:#00d18f; --brand-2:#6f7dff;
--bg1:#222d1b; /* dark green */
--bg2:#aabf7b; /* soft olive */
--card:#0f1326;
--line:#20274a;
--muted:#9aa3b2;
--text:#e5e9f0;
}
:root[data-theme="light"]{
--brand:#166534; --brand-2:#1d4ed8;
--bg1:#f7fafc; --bg2:#eef2f7; /* override if you want a light olive theme */
--card:#ffffff; --line:#e2e8f0;
--muted:#475569; --text:#0f172a;
}
-
Background gradient is set on the
bodyrule; tweak there. -
Fonts are injected via Google Fonts link and then used by CSS variables:
- Title: Source Code Pro (ExtraBold), Headings: Exo 2, Body: PT Sans.
-
Logo: in the “phone” header we use:
<div class="pill pill-brand">
<img src="assets/biosphere-logo-white.png" alt="Biosphere Solar" class="brand-logo" />
<span class="brand-abbrev">DPP</span>
</div>
If the white logo disappears in light mode, add a dark variant and toggle with CSS:
<img src="assets/biosphere-logo-white.png" class="brand-logo brand-logo--dark" alt="Biosphere Solar">
<img src="assets/biosphere-logo-dark.png" class="brand-logo brand-logo--light" alt="Biosphere Solar">
.brand-logo--light{ display:none; }
:root[data-theme="light"] .brand-logo--dark{ display:none; }
:root[data-theme="light"] .brand-logo--light{ display:block; }
-
Create a JSON:
passports/<SERIAL>.jsonfollowing the skeleton. -
Test locally:
/?serial=<SERIAL> -
Commit & push → GitHub Pages updates automatically.
-
(Optional) Make a QR code that points to:
-
the DPP landing/search page (preferred):
https://<org>.github.io/<repo>/ -
or deep-link directly to a module:
https://<org>.github.io/<repo>/?serial=<SERIAL>
Any QR generator is fine (Adobe, QRCode Monkey, etc.). Save as SVG/PNG; test with a phone.
-
Header KPIs: update the top-level fields in JSON (
power_w,efficiency_pct, etc.). -
Docs (Safety, ESG, LCA, EPD): update those 4 URLs.
-
Composition tabs:
-
Update
material_composition_pct,chemical_composition_pct,cost_breakdown_eur. -
Remove a dataset entirely to hide its tab.
-
-
Explorer tables:
- Add fields inside
components[].categoriesper component.
- Add fields inside
-
Mechanical / Use / EoL etc.:
- Put them inside
extra_categoriesunder the exact category names listed above.
- Put them inside
-
Recycled content & bars:
- Put non-zero percentages in
recycled_content; zero or missing values are hidden automatically to avoid empty bars.
- Put non-zero percentages in
-
JSON not loading: 404 in console → filename/serial mismatch. Fix: make sure
passports/<SERIAL>.jsonexists and the URL param matches the case. -
Material tab shows but no chart: one of the values is not numeric. Fix: ensure every value in
*_composition_pctis a number (or string that contains only a number/percent). -
Wrong colors or duplicated colors: labels don’t match the color map. Fix: stick to our names (
glass,cells,frame,edge seal,junction box,tabbing wire,bus bars,frames) or add the label toMATERIAL_MAPinindex.html. -
Mechanical/EoL missing: you placed those categories at the root of the JSON. Fix: move them under extra_categories.
-
Logo shows “broken image” on mobile: wrong path. Fix:
src="assets/<file>"(relative toindex.html) and commit the file.
-
Background: change
--bg1/--bg2in:rootand:root[data-theme="light"]. -
Buttons: the green brand color is
--brand; outline uses--line. -
Table colors are controlled centrally:
th{ color: var(--muted); font-weight:600; width:38%; }
td{ color: var(--text); }
-
Font stacks are defined once; swap families in the CSS variables if needed.
-
Composition colors: edit
MATERIAL_MAP/CHEMICAL_MAP. New labels fall back to a distinct palette.
-
Use a JSON linter (e.g., jsonlint.com or VS Code built-in) to avoid trailing commas / missing quotes.
-
Prefer numbers for numeric values; use null when unknown (not empty strings).
-
Keep units in
unitfields (e.g.,"mm","W","%")—not insidevalue.
-
Always provide
alt=""text for images (logo already has it). -
Links open in new tabs and are labeled “Open”.
-
Tabs auto-wrap on small screens; tables do not overflow.
-
Anyone can add a module by PR that adds a JSON file under
passports/. -
If you change the schema (new categories, fields), keep it backward compatible or add guards in
index.html. -
If in doubt about where a piece of data should live, put it in
extra_categories→General compositionunder the relevant component.
-
Put raw costs in
cost_breakdown_eur. -
The app converts them to % for the Value chart automatically:
\text{% of item} = \frac{\text{cost}_i}{\sum \text{costs}} \times 100
-
You can still keep Material (% by mass) and Chemical (% by element) if you have those; otherwise, remove the section to hide the tab.
- Code: MIT
- Data & docs (JSON, README, PDFs, images except logo): CC BY 4.0
- Logos in
/assets: All rights reserved
Third-party:
- Chart.js (MIT)
- Google Fonts (OFL 1.1)