spread

https://git.tonybtw.com/spread.git git://git.tonybtw.com/spread.git

added spread bom re-quoting webapp

Commit
37e229a483c568e4fddf06371f994de7e5f42cb2
Author
tonybanters <tonyoutoften@gmail.com>
Date
2026-07-05 14:51:54

Diff

diff --git a/.air.toml b/.air.toml
new file mode 100644
index 0000000..22d33c0
--- /dev/null
+++ b/.air.toml
@@ -0,0 +1,21 @@
+root = "."
+tmp_dir = "tmp"
+
+[build]
+  # Rebuild the self-host binary. Embedded assets (index.html, app.js,
+  # example-bom.csv, schema.sql) are picked up because they're in include_ext.
+  cmd = "go build -o ./tmp/spread ."
+  bin = "./tmp/spread"
+  include_ext = ["go", "html", "js", "css", "sql", "csv"]
+  exclude_dir = ["tmp", ".pgdata", ".pgsock", "api", ".git"]
+  exclude_regex = ["_test\\.go"]
+  delay = 300
+  stop_on_error = true
+  send_interrupt = true
+  kill_delay = "2s"
+
+[log]
+  time = true
+
+[misc]
+  clean_on_exit = true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c9314df
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+# air live-reload
+/tmp/
+# local dev Postgres (from `nix develop` + pg-start)
+/.pgdata/
+/.pgsock/
+# built binaries
+/spread
+/spread-server
+# direnv, if used
+/.direnv/
diff --git a/.vercelignore b/.vercelignore
new file mode 100644
index 0000000..3beb2ae
--- /dev/null
+++ b/.vercelignore
@@ -0,0 +1,16 @@
+# Self-host-only entrypoint — Vercel serves the endpoint via api/analyze.go
+# and the static files from its CDN, so the standalone binary isn't deployed.
+main.go
+README.md
+
+# self-host-only app package (accounts/Postgres) — not used by the demo function
+internal/app/
+
+# local dev tooling
+flake.nix
+flake.lock
+.air.toml
+.gitignore
+tmp/
+.pgdata/
+.pgsock/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ffd352f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,182 @@
+# Spread
+
+Upload a bill of materials, re-quote every line against a supplier catalog, see
+market price / Spread price / savings line by line. We keep 20% of what you
+save, or nothing.
+
+The frontend is the landing page (`index.html` + `app.js`). The pricing engine
+is a small Go package (`internal/spread`) with **no external dependencies** —
+CSV parsing is stdlib, and the `.xlsx` reader is a hand-rolled unzip + XML pass.
+
+One core package, two entrypoints:
+
+- `api/analyze.go` — Vercel Go serverless function (`POST /api/analyze`)
+- `main.go` — self-hostable single binary that embeds the static files and
+  serves the same handler
+
+## Develop (live reload)
+
+The flake dev shell provides Go, `air` (live reload), and a local Postgres:
+
+```sh
+nix develop        # drops you in the dev shell with go + air + pg tools
+air                # builds + runs, rebuilds on any .go/.html/.js/.sql change
+```
+
+Open http://localhost:8137. `air` watches everything including the embedded
+assets, so edits to `index.html` / `app.js` reload too. Demo mode needs no
+database — accounts stay dark until you start Postgres.
+
+For the account routes (`/login`, `/admin`, `/app`), from inside `nix develop`:
+
+```sh
+pg-start                              # spins up a throwaway Postgres under ./.pgdata
+spread-admin you@example.com secret   # create/reset the admin user
+air                                   # /admin and /app are now live
+pg-stop                               # when you're done
+```
+
+`SPREAD_DB_OPTIONAL=1` is set in the dev shell, so `air` still serves the public
+demo even if you haven't run `pg-start`.
+
+### Admin CMS
+
+Log in as the admin (internal subscriber) to reach `/admin`:
+
+- **`/admin/catalog`** — the live supplier catalog (part number, description,
+  spread price). "Seed demo catalog" loads the five landing-page parts. This
+  catalog drives authed re-quotes; the public demo keeps its built-in prices.
+- **`/admin/subscribers`** — create customer subscribers and their users.
+  Leave the password blank to email them a one-time invite link to set their
+  own; fill it in to set a password directly.
+- **`/admin/boms`** — review every subscriber's uploaded BOMs; open one to see
+  the full line-by-line re-quote.
+
+Customers (any non-internal subscriber) land on `/app`, where they upload a BOM,
+have it re-quoted against the live catalog, and see it saved to their account.
+`/app/bom/{id}` is the saved report; a customer can only open their own, the
+internal subscriber can open any. `/admin` redirects customers back to `/app`.
+
+## Accounts: invites, resets, email
+
+New users get a one-time invite link (or you set a password directly). Both
+invites and `/forgot` password resets use short-lived, single-use tokens.
+
+Email goes through a small `Mailer` interface picked by `MAIL_BACKEND`:
+
+- `log` (default) — writes the message + link to the server log; the whole
+  invite/reset flow works locally with no email setup (copy the link from the log).
+- `smtp` — stdlib `net/smtp`; set `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` /
+  `SMTP_PASS` / `MAIL_FROM`.
+- `sendgrid` — SendGrid v3 API; set `SENDGRID_API_KEY` / `MAIL_FROM`.
+
+`SPREAD_BASE_URL` sets the host used in emailed links (default
+`http://localhost:8137`).
+
+## Security notes
+
+- **CSRF**: all state-changing forms carry a token validated against a
+  double-submit cookie (`SameSite=Lax`, httponly). The public `/api/analyze`
+  demo endpoint is intentionally open (no auth, no CSRF).
+- Sessions and one-time tokens are stored hashed; passwords are bcrypt.
+- Set `SPREAD_SECURE_COOKIES=1` behind HTTPS so cookies are marked `Secure`.
+
+## Run without the flake
+
+```sh
+nix shell nixpkgs#go --command go run .   # or just `go run .` with Go installed
+```
+
+Then open http://localhost:8137. Override the port with `PORT=9000 go run .`.
+
+The example section loads `example-bom.csv` on first paint, which reproduces the
+canonical demo figures: **$883.50 total savings · $176.70 our cut · $706.80 net**.
+
+## The demo BOM
+
+`example-bom.csv` uses a standard BOM layout. The parser is tolerant of column
+order and header naming (case-insensitive, punctuation-stripped):
+
+| Canonical field | Accepted headers |
+|-----------------|------------------|
+| Part number     | part number, part, PN, MPN, mfr part number |
+| Description     | description, desc, name, component |
+| Manufacturer    | manufacturer, mfr, mfg, brand |
+| Quantity        | quantity, qty, count |
+| Unit price      | unit price, price, cost, unit cost, current price |
+
+Parts found by exact part number get a fixed catalog price. Everything else is
+estimated from a category model (a per-category discount plus a small volume
+bonus) and flagged `· est.` in the UI. Replace `catalog.go` with real supplier
+lookups to go live — nothing else changes.
+
+Swap in your real BOM later by uploading it, or replace `example-bom.csv`.
+
+## Deploy to Vercel
+
+The repo is a zero-config Vercel project:
+
+- static files (`index.html`, `app.js`, `example-bom.csv`) served from the CDN
+- `api/analyze.go` auto-detected as a Go function via `go.mod`
+- `main.go` is excluded from the deploy (see `.vercelignore`)
+
+```sh
+vercel        # preview
+vercel --prod # production
+```
+
+## Self-host on your own box
+
+Build one static binary and run it behind nginx (no runtime deps):
+
+```sh
+go build -o spread .
+PORT=8080 ./spread
+```
+
+### NixOS (full app, TLS, Postgres)
+
+The flake ships `packages.default` (the `buildGoModule` binary) and
+`nixosModules.default` (systemd service + Postgres + nginx + Let's Encrypt).
+In your host flake:
+
+```nix
+{
+  inputs.spread.url = "path:/home/tony/spread";   # or a git URL
+  # ...
+  outputs = { self, nixpkgs, spread, ... }: {
+    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
+      modules = [
+        spread.nixosModules.default
+        {
+          services.spread = {
+            enable = true;
+            domain = "spread.example.com";   # the subdomain you point here
+            acmeEmail = "you@example.com";
+          };
+        }
+      ];
+    };
+  };
+}
+```
+
+`nixos-rebuild switch`, then bootstrap the first admin once:
+
+```sh
+sudo -u spread \
+  DATABASE_URL='postgresql:///spread?host=/run/postgresql' \
+  SPREAD_ADMIN_PASSWORD='...' \
+  $(readlink -f /run/current-system)/sw/bin/spread init-admin you@example.com
+# (or: nix run .#default -- init-admin ... with the same env)
+```
+
+**Networking (home box behind NAT):** point an A record for the subdomain at
+your public IP, and forward TCP **80 + 443** on your router to this machine.
+Let's Encrypt needs 80/443 reachable; the app itself stays on `127.0.0.1:8137`
+behind nginx. If your public IP is dynamic, either use DDNS or just update the
+A record before a demo.
+
+For real email set `services.spread.mailBackend = "smtp"` (or `"sendgrid"`) and
+point `environmentFile` at a secrets file (`SMTP_*` / `SENDGRID_API_KEY` /
+`MAIL_FROM`) kept out of the Nix store.
diff --git a/api/analyze.go b/api/analyze.go
new file mode 100644
index 0000000..f8a9ef1
--- /dev/null
+++ b/api/analyze.go
@@ -0,0 +1,14 @@
+// Package handler is the Vercel Go serverless function for POST /api/analyze.
+// All logic lives in the shared spread package; this is just the entrypoint
+// Vercel's Go runtime discovers.
+package handler
+
+import (
+	"net/http"
+
+	"spread/internal/spread"
+)
+
+func Handler(w http.ResponseWriter, r *http.Request) {
+	spread.Handler(w, r)
+}
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..b08ede4
--- /dev/null
+++ b/app.js
@@ -0,0 +1,158 @@
+// Spread — frontend glue. Sends an uploaded BOM to /api/analyze and renders
+// the returned line-by-line re-quote into the example table.
+(function () {
+  "use strict";
+
+  var fileInput = document.getElementById("file-input");
+  var dropzone = document.getElementById("dropzone");
+  var wrapper = document.getElementById("bom-wrapper");
+  var body = document.getElementById("bom-body");
+  var title = document.getElementById("bom-title");
+  var meta = document.getElementById("bom-meta");
+  var errorBanner = document.getElementById("error-banner");
+  var els = {
+    savings: document.getElementById("t-savings"),
+    cut: document.getElementById("t-cut"),
+    net: document.getElementById("t-net"),
+  };
+
+  function money(n, digits) {
+    return "$" + Number(n).toLocaleString("en-US", {
+      minimumFractionDigits: digits,
+      maximumFractionDigits: digits,
+    });
+  }
+  function qty(n) {
+    return Number(n).toLocaleString("en-US");
+  }
+  function esc(s) {
+    var d = document.createElement("div");
+    d.textContent = s == null ? "" : String(s);
+    return d.innerHTML;
+  }
+
+  function showError(msg) {
+    errorBanner.textContent = msg;
+    errorBanner.classList.add("error");
+  }
+  function clearError() {
+    errorBanner.textContent = "";
+    errorBanner.classList.remove("error");
+  }
+
+  function render(report) {
+    var lines = report.lines || [];
+    var rows = lines.map(function (l) {
+      var pn = l.part_number || "";
+      var estTag = l.matched ? "" : ' <span class="est">· est.</span>';
+      var saveClass = l.line_saving > 0 ? "save" : "save zero";
+      return (
+        "<tr>" +
+        '<td class="part">' + esc(l.description) +
+          '<span class="pn">' + esc(pn) + estTag + "</span></td>" +
+        '<td class="num">' + qty(l.quantity) + "</td>" +
+        '<td class="num market">' + money(l.unit_price, 4) + "</td>" +
+        '<td class="num ours">' + money(l.spread_price, 4) + "</td>" +
+        '<td class="num ' + saveClass + '">' + money(l.line_saving, 2) + "</td>" +
+        "</tr>"
+      );
+    });
+    body.innerHTML = rows.join("") ||
+      '<tr><td class="part" colspan="5" style="color:var(--text-tertiary)">No line items found.</td></tr>';
+
+    var t = report.totals || {};
+    els.savings.textContent = money(t.savings || 0, 2);
+    els.cut.textContent = money(t.our_cut || 0, 2);
+    els.net.textContent = money(t.net_savings || 0, 2);
+
+    var partWord = (t.lines === 1) ? "part" : "parts";
+    title.innerHTML = esc(report.bom_ref || "BOM") +
+      ' &nbsp;<span class="dim">· ' + (t.lines || 0) + " " + partWord + "</span>";
+    meta.textContent = "analysis complete";
+  }
+
+  function analyze(file) {
+    clearError();
+    wrapper.classList.add("busy");
+    meta.textContent = "re-quoting…";
+
+    var fd = new FormData();
+    fd.append("bom", file, file.name);
+
+    fetch("/api/analyze", { method: "POST", body: fd })
+      .then(function (res) {
+        return res.json().then(function (data) {
+          if (!res.ok) throw new Error(data.error || "Analysis failed (" + res.status + ")");
+          return data;
+        });
+      })
+      .then(function (report) {
+        render(report);
+      })
+      .catch(function (err) {
+        showError(err.message || "Something went wrong parsing that file.");
+        meta.textContent = "upload failed";
+      })
+      .finally(function () {
+        wrapper.classList.remove("busy");
+      });
+  }
+
+  function loadExample() {
+    clearError();
+    wrapper.classList.add("busy");
+    meta.textContent = "re-quoting…";
+    fetch("/example-bom.csv")
+      .then(function (res) {
+        if (!res.ok) throw new Error("could not load example");
+        return res.blob();
+      })
+      .then(function (blob) {
+        analyze(new File([blob], "example-bom.csv", { type: "text/csv" }));
+      })
+      .catch(function (err) {
+        showError(err.message);
+        wrapper.classList.remove("busy");
+      });
+  }
+
+  // Wire the upload buttons.
+  Array.prototype.forEach.call(document.querySelectorAll("[data-upload]"), function (btn) {
+    btn.addEventListener("click", function () { fileInput.click(); });
+  });
+  Array.prototype.forEach.call(document.querySelectorAll("[data-example]"), function (btn) {
+    btn.addEventListener("click", function (e) {
+      e.preventDefault();
+      document.getElementById("example").scrollIntoView({ behavior: "smooth" });
+      loadExample();
+    });
+  });
+
+  fileInput.addEventListener("change", function () {
+    if (fileInput.files && fileInput.files[0]) analyze(fileInput.files[0]);
+    fileInput.value = "";
+  });
+
+  // Drag and drop.
+  ["dragenter", "dragover"].forEach(function (ev) {
+    dropzone.addEventListener(ev, function (e) {
+      e.preventDefault();
+      dropzone.classList.add("drag");
+    });
+  });
+  ["dragleave", "drop"].forEach(function (ev) {
+    dropzone.addEventListener(ev, function (e) {
+      e.preventDefault();
+      if (ev === "dragleave" && dropzone.contains(e.relatedTarget)) return;
+      dropzone.classList.remove("drag");
+    });
+  });
+  dropzone.addEventListener("drop", function (e) {
+    if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]) {
+      analyze(e.dataTransfer.files[0]);
+    }
+  });
+
+  // Load the example on first paint so the section is never empty.
+  loadExample();
+})();
diff --git a/assets/company-logos/alibaba.svg b/assets/company-logos/alibaba.svg
new file mode 100644
index 0000000..3a85067
--- /dev/null
+++ b/assets/company-logos/alibaba.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="773" height="122"><rect width="100%" height="100%" fill="none"/><g class="currentLayer"><title>Layer 1</title><path d="M487.347 82.828c0 12.332-6.25 18.498-18.741 18.498-8.006 0-12.006-2.926-12.006-8.798 0-5.323 4.217-8.716 12.62-10.185 3.105-.544 9.143-.818 18.127-.818zm35.239-16.99c0-21.399-16.204-31.45-47.942-32.108-33.079-.683-47.053 7.93-49.832 26.019h34.3c1.654-5.891 5.616-9.188 13.826-8.9 8.07.287 14.077 4.382 14.377 15.104-18.46.275-32.057 1.348-41.392 3.585-18.013 4.32-26.964 14.14-26.964 27.5 0 9.004 3.987 15.579 11.935 19.706 5.988 3.093 13.757 4.978 23.303 4.646 15.642-.544 26.914-5.003 33.897-14.23v12.357h34.504zm215.44 16.99c0 12.332-6.255 18.498-18.74 18.498-8.007 0-12.007-2.926-12.007-8.798 0-5.323 4.211-8.716 12.62-10.185 3.105-.544 9.137-.818 18.127-.818zm35.239-16.99c0-21.399-16.21-31.45-47.941-32.108-33.086-.683-47.06 7.93-49.833 26.019h34.3c1.654-5.891 5.616-9.188 13.827-8.9 8.063.287 14.07 4.382 14.37 15.104-18.453.275-32.057 1.348-41.392 3.585-18.006 4.32-26.958 14.14-26.958 27.5 0 9.004 3.98 15.579 11.93 19.706 5.993 3.093 13.763 4.978 23.302 4.646 15.649-.544 26.92-5.003 33.897-14.23v12.357h34.504zM59.577 72.005 79.148 28.44l18.537 43.565zM59.717 0 0 119.652h38.772l9.476-21.565h59.718l8.817 21.565h43.028L102.848 0zm111.197 119.648h37.577V.003h-37.577zM232.338.003v23.762h37.576V.003zm0 36.69v82.955h37.576V36.693zM364.89 91.505c-6.895 9.489-15.495 11.591-25.789 6.326a26 26 0 0 1-1.565-1.125c-3.24-3.01-5.304-6.702-6.186-11.086q-1.984-9.66.614-19.13c1.233-4.281 3.623-7.655 7.182-10.102 4.09-2.358 8.44-3.208 13.035-2.556 6.428 1.016 10.958 4.613 13.61 10.792 1.635 4.051 2.421 8.294 2.357 12.71q.096 7.502-3.258 14.171m-71.085 28.185h36.69v-15.086c9.718 12.402 21.999 17.603 36.918 17.067 8.313-.422 15.962-3.304 22.888-8.14 9.386-7.496 14.46-17.368 15.214-29.63 1.067-12.625-1.987-24.07-9.17-34.344-6.99-8.658-15.897-13.635-26.727-14.907-15.06-1.993-28.306 2.972-38.83 14.396V.114h-36.945zm321.671-28.185c-6.894 9.489-15.495 11.591-25.788 6.326a26 26 0 0 1-1.566-1.125c-3.24-3.01-5.303-6.702-6.185-11.086q-1.984-9.66.613-19.13c1.234-4.281 3.623-7.655 7.182-10.102 4.09-2.358 8.441-3.208 13.035-2.556 6.428 1.016 10.958 4.613 13.604 10.792 1.642 4.051 2.428 8.294 2.364 12.71q.096 7.502-3.259 14.171m-71.084 28.185h36.689v-15.086c9.718 12.402 22 17.603 36.92 17.067 8.312-.422 15.96-3.304 22.887-8.14 9.386-7.496 14.46-17.368 15.213-29.63 1.068-12.625-1.987-24.07-9.169-34.344-6.99-8.658-15.897-13.635-26.727-14.907-15.06-1.993-28.307 2.972-38.83 14.396V.114H544.43z" class="selected" style="fill:#ff5a00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.38963"/></g></svg>
\ No newline at end of file
diff --git a/assets/company-logos/atlassian.svg b/assets/company-logos/atlassian.svg
new file mode 100644
index 0000000..f636663
--- /dev/null
+++ b/assets/company-logos/atlassian.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" id="svg36" width="539.996" height="66.959" viewBox="0 0 539.996 66.959"><defs id="defs9"><linearGradient id="linear-gradient" x1="28.54" x2="11.41" y1="69.49" y2="99.17" gradientTransform="translate(-.004 -33.961)" gradientUnits="userSpaceOnUse"><stop id="stop4" offset="0" stop-color="#0052cc"/><stop id="stop6" offset=".92" stop-color="#2684ff"/></linearGradient><style id="style2">.cls-3{fill:#0052cc}</style></defs><title id="title11">atlassian-logo-gradient-horizontal-blue</title><path id="path13" d="M19.636 30.519a1.88 1.88 0 0 0-3.2.35L.206 63.329a1.94 1.94 0 0 0 1.73 2.81h22.6a1.87 1.87 0 0 0 1.74-1.1c4.87-10 1.92-25.37-6.64-34.52" style="fill:url(#linear-gradient)"/><path id="path15" d="M31.546 1.039a42.81 42.81 0 0 0-2.5 42.27l10.95 21.73a1.94 1.94 0 0 0 1.73 1.08h22.6a2 2 0 0 0 1.67-2.79l-31.15-62.29a1.83 1.83 0 0 0-3.3 0" style="fill:#2684ff"/><path id="polygon17" d="m509.01 69.6 16.41 30.5H540V45.66h-11.6v35.13l-4.39-10.19-13.18-24.94h-18.31v54.44h11.6V58.58z" class="cls-3" style="fill:#0052cc" transform="translate(-.004 -33.961)"/><path id="rect19" d="M406.056 11.699h12.68v54.44h-12.68z" class="cls-3" style="fill:#0052cc"/><path id="path21" d="M391.426 50.039c0-9.53-5.05-14-19.31-17.07-7.87-1.74-9.77-3.48-9.77-6 0-3.15 2.81-4.48 8-4.48 6.3 0 12.52 1.91 18.4 4.56v-12.45a41.6 41.6 0 0 0-18-3.73c-14.09 0-21.38 6.13-21.38 16.16 0 8 3.73 14.41 18.31 17.23 8.7 1.82 10.53 3.23 10.53 6.13s-1.83 4.64-8 4.64a45 45 0 0 1-21.13-5.71v13.09c4.39 2.15 10.19 4.55 21 4.55 15.24 0 21.29-6.79 21.29-16.9" class="cls-3" style="fill:#0052cc"/><path id="path23" d="M338.646 50.039c0-9.53-5-14-19.3-17.07-7.87-1.74-9.78-3.48-9.78-6 0-3.15 2.82-4.48 8-4.48 6.3 0 12.51 1.91 18.39 4.56v-12.45a41.6 41.6 0 0 0-18-3.73c-14.08 0-21.38 6.13-21.38 16.16 0 8 3.73 14.41 18.32 17.23 8.7 1.82 10.52 3.23 10.52 6.13s-1.82 4.64-8 4.64a45 45 0 0 1-21.13-5.71v13.09c4.4 2.15 10.2 4.55 21 4.55 15.25 0 21.29-6.79 21.29-16.9" class="cls-3" style="fill:#0052cc"/><path id="polygon25" d="M210.92 88.33V45.66h-12.76v54.44h26.06l4.1-11.77z" class="cls-3" style="fill:#0052cc" transform="translate(-.004 -33.961)"/><path id="polygon27" d="M173.52 100.1V57.42h15.08V45.66h-41.93v11.76h14.09v42.68z" class="cls-3" style="fill:#0052cc" transform="translate(-.004 -33.961)"/><path id="path29" d="M128.166 11.699h-16.72l-19 54.44h14.55l2.7-9.17a36 36 0 0 0 20.3 0l2.69 9.17h14.5zm-8.36 35.46a24.8 24.8 0 0 1-7-1l7-23.76 7 23.76a24.8 24.8 0 0 1-7 1" class="cls-3" style="fill:#0052cc"/><path id="path31" d="M269.136 11.699h-16.72l-19 54.44h14.5l2.7-9.17a36 36 0 0 0 20.3 0l2.69 9.17h14.5zm-8.36 35.46a24.7 24.7 0 0 1-7-1l7-23.76 7 23.76a24.7 24.7 0 0 1-7 1" class="cls-3" style="fill:#0052cc"/><path id="path33" d="M464.416 11.699h-16.72l-19 54.44h14.5l2.69-9.17a36 36 0 0 0 20.3 0l2.69 9.17h14.51zm-8.36 35.46a24.8 24.8 0 0 1-7-1l7-23.76 7 23.76a24.8 24.8 0 0 1-7 1" class="cls-3" style="fill:#0052cc"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/automattic.svg b/assets/company-logos/automattic.svg
new file mode 100644
index 0000000..490633d
--- /dev/null
+++ b/assets/company-logos/automattic.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 494.35 38.19"><path fill="#3499cd" d="M179.25 38.19c-12.58 0-20.74-9.07-20.74-18.51v-1.17c0-9.6 8.16-18.51 20.74-18.51s20.81 8.91 20.81 18.51v1.17c0 9.44-8.16 18.51-20.81 18.51m14.08-19.58c0-6.88-5-13-14.08-13s-14 6.13-14 13v.86c0 6.88 5 13.12 14 13.12s14.08-6.24 14.08-13.12z"/><path fill="#f5f4f0" d="M37.12 36.8 32.43 28H11.57L7 36.8H0L19.2 1.28h5.55L44.27 36.8zM21.81 8.21l-7.73 14.94h15.73zm52.75 29.98c-12.75 0-18.67-6.94-18.67-16.16V1.28h6.62v20.85c0 6.56 4.32 10.46 12.53 10.46 8.43 0 11.89-3.9 11.89-10.46V1.28h6.67V22c0 8.83-5.6 16.19-19.04 16.19m56.16-31.36v30h-6.67v-30h-15.52V1.28h37.71v5.55zM259.47 36.8V8.69l-1.76 3.1-14.88 25h-3.25l-14.72-25-1.76-3.1V36.8h-6.51V1.28h9.23l14 24.37 1.66 3 1.65-3L257 1.28h9.12V36.8zm57.07 0-4.7-8.8H291l-4.53 8.8h-7l19.2-35.52h5.54l19.47 35.52zM301.23 8.21l-7.73 14.94h15.73zm49.33-1.38v30h-6.66v-30h-15.52V1.28h37.7v5.55zm48.91 0v30h-6.67v-30h-15.52V1.28H415v5.55zm31.95 29.97V4.64c2.66 0 3.73-1.44 3.73-3.36H438V36.8zm58.77-25.49A20.7 20.7 0 0 0 476 5.6c-9.49 0-14.83 6.51-14.83 13.28v.69c0 6.72 5.39 13 15.31 13a20.78 20.78 0 0 0 13.92-5.71l4 4.21a26.53 26.53 0 0 1-18.4 7.12c-13.44 0-21.6-8.75-21.6-18.35v-1.17c0-9.6 8.91-18.67 21.92-18.67 7.52 0 14.35 3.15 18 7.09zm-310.118 1.863-6.107 9.46a2.58 2.58 0 0 0 .767 3.567l.009.005a2.58 2.58 0 0 0 3.567-.768l6.108-9.46a2.58 2.58 0 0 0-.768-3.566l-.009-.006a2.58 2.58 0 0 0-3.567.768"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/aws.svg b/assets/company-logos/aws.svg
new file mode 100644
index 0000000..a3e86a8
--- /dev/null
+++ b/assets/company-logos/aws.svg
@@ -0,0 +1 @@
+<svg fill="#f5f4f0" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" viewBox="0 0 304 182"><style>.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#f90}</style><path d="M86.4 66.4c0 3.7.4 6.7 1.1 8.9.8 2.2 1.8 4.6 3.2 7.2.5.8.7 1.6.7 2.3 0 1-.6 2-1.9 3L83.2 92c-.9.6-1.8.9-2.6.9-1 0-2-.5-3-1.4-1.4-1.5-2.6-3.1-3.6-4.7-1-1.7-2-3.6-3.1-5.9Q59.2 94.7 41.5 94.7c-8.4 0-15.1-2.4-20-7.2s-7.4-11.2-7.4-19.2c0-8.5 3-15.4 9.1-20.6s14.2-7.8 24.5-7.8c3.4 0 6.9.3 10.6.8s7.5 1.3 11.5 2.2v-7.3c0-7.6-1.6-12.9-4.7-16-3.2-3.1-8.6-4.6-16.3-4.6-3.5 0-7.1.4-10.8 1.3s-7.3 2-10.8 3.4c-1.6.7-2.8 1.1-3.5 1.3s-1.2.3-1.6.3c-1.4 0-2.1-1-2.1-3.1v-4.9c0-1.6.2-2.8.7-3.5s1.4-1.4 2.8-2.1Q28.75 5 36.1 3.2C41 1.9 46.2 1.3 51.7 1.3c11.9 0 20.6 2.7 26.2 8.1 5.5 5.4 8.3 13.6 8.3 24.6v32.4zM45.8 81.6c3.3 0 6.7-.6 10.3-1.8s6.8-3.4 9.5-6.4c1.6-1.9 2.8-4 3.4-6.4s1-5.3 1-8.7v-4.2c-2.9-.7-6-1.3-9.2-1.7s-6.3-.6-9.4-.6c-6.7 0-11.6 1.3-14.9 4s-4.9 6.5-4.9 11.5c0 4.7 1.2 8.2 3.7 10.6 2.4 2.5 5.9 3.7 10.5 3.7m80.3 10.8c-1.8 0-3-.3-3.8-1-.8-.6-1.5-2-2.1-3.9L96.7 10.2c-.6-2-.9-3.3-.9-4 0-1.6.8-2.5 2.4-2.5h9.8c1.9 0 3.2.3 3.9 1 .8.6 1.4 2 2 3.9l16.8 66.2 15.6-66.2c.5-2 1.1-3.3 1.9-3.9s2.2-1 4-1h8c1.9 0 3.2.3 4 1 .8.6 1.5 2 1.9 3.9l15.8 67 17.3-67c.6-2 1.3-3.3 2-3.9.8-.6 2.1-1 3.9-1h9.3c1.6 0 2.5.8 2.5 2.5 0 .5-.1 1-.2 1.6s-.3 1.4-.7 2.5l-24.1 77.3q-.9 3-2.1 3.9c-.8.6-2.1 1-3.8 1h-8.6c-1.9 0-3.2-.3-4-1s-1.5-2-1.9-4L156 23l-15.4 64.4c-.5 2-1.1 3.3-1.9 4s-2.2 1-4 1zm128.5 2.7c-5.2 0-10.4-.6-15.4-1.8s-8.9-2.5-11.5-4c-1.6-.9-2.7-1.9-3.1-2.8s-.6-1.9-.6-2.8v-5.1c0-2.1.8-3.1 2.3-3.1q.9 0 1.8.3c.6.2 1.5.6 2.5 1 3.4 1.5 7.1 2.7 11 3.5 4 .8 7.9 1.2 11.9 1.2 6.3 0 11.2-1.1 14.6-3.3s5.2-5.4 5.2-9.5c0-2.8-.9-5.1-2.7-7s-5.2-3.6-10.1-5.2L246 52c-7.3-2.3-12.7-5.7-16-10.2-3.3-4.4-5-9.3-5-14.5q0-6.3 2.7-11.1c1.8-3.2 4.2-6 7.2-8.2 3-2.3 6.4-4 10.4-5.2s8.2-1.7 12.6-1.7c2.2 0 4.5.1 6.7.4 2.3.3 4.4.7 6.5 1.1 2 .5 3.9 1 5.7 1.6q2.7.9 4.2 1.8c1.4.8 2.4 1.6 3 2.5q.9 1.2.9 3.3v4.7c0 2.1-.8 3.2-2.3 3.2-.8 0-2.1-.4-3.8-1.2q-8.55-3.9-19.2-3.9c-5.7 0-10.2.9-13.3 2.8s-4.7 4.8-4.7 8.9c0 2.8 1 5.2 3 7.1s5.7 3.8 11 5.5l14.2 4.5c7.2 2.3 12.4 5.5 15.5 9.6s4.6 8.8 4.6 14c0 4.3-.9 8.2-2.6 11.6-1.8 3.4-4.2 6.4-7.3 8.8-3.1 2.5-6.8 4.3-11.1 5.6-4.5 1.4-9.2 2.1-14.3 2.1" style="fill:#f5f4f0"/><path d="M273.5 143.7c-32.9 24.3-80.7 37.2-121.8 37.2-57.6 0-109.5-21.3-148.7-56.7-3.1-2.8-.3-6.6 3.4-4.4 42.4 24.6 94.7 39.5 148.8 39.5 36.5 0 76.6-7.6 113.5-23.2 5.5-2.5 10.2 3.6 4.8 7.6" class="st1"/><path d="M287.2 128.1c-4.2-5.4-27.8-2.6-38.5-1.3-3.2.4-3.7-2.4-.8-4.5 18.8-13.2 49.7-9.4 53.3-5 3.6 4.5-1 35.4-18.6 50.2-2.7 2.3-5.3 1.1-4.1-1.9 4-9.9 12.9-32.2 8.7-37.5" class="st1"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/bytedance.svg b/assets/company-logos/bytedance.svg
new file mode 100644
index 0000000..c853054
--- /dev/null
+++ b/assets/company-logos/bytedance.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1621.904" height="279.815" viewBox="0 0 16219.041 2798.148"><path d="M5889.244 198.143 6384.6 341.246v1981.428l-495.357 143.103z" style="fill:#3259b4;fill-opacity:1;stroke:none;stroke-width:10px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" transform="translate(-5889.244 -88.063)"/><path d="m6670.807 1210.873 484.35 121.087v1067.77l-484.35 99.07z" style="fill:#3c8cff;fill-opacity:1;stroke:none;stroke-width:10px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" transform="translate(-5889.244 -88.063)"/><path d="m7496.402 1001.722 451.325-121.087v1309.944l-451.325-132.096z" style="fill:#00c8d2;fill-opacity:1;stroke:none;stroke-width:10px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" transform="translate(-5889.244 -88.063)"/><path d="m8244.942 88.063 495.357 143.104v2223.602l-495.358 121.087z" style="fill:#78e6dd;fill-opacity:1;stroke:none;stroke-width:10px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" transform="translate(-5889.244 -88.063)"/><path d="M20976.329 1586.941h874.026q-22.003-165.89-141.8-268.814-119.796-102.924-290.934-102.924-173.582 0-298.269 105.346-123.464 104.135-143.023 266.392m0 184.053q12.225 179.21 141.8 290.61 130.799 111.4 328.83 111.4 127.13 0 237.149-53.278 110.016-53.279 201.697-157.414l167.471 133.196q-125.909 145.305-276.266 214.325-149.134 69.02-337.385 69.02-155.246 0-289.713-55.7-134.465-55.7-231.036-158.625-84.347-90.816-129.576-209.481-45.229-119.877-45.229-250.651 0-309.984 187.03-501.302 187.028-191.318 488.966-191.318 308.047 0 497.521 204.638 190.697 203.427 190.697 537.628v16.952zm-512.541-423.806q-60.745-67.809-138.847-99.292-76.862-32.693-176.038-32.693-192.155 0-316.126 135.618-122.73 134.407-122.73 345.099t121.491 343.888q122.73 133.196 317.365 133.196 105.375 0 185.957-36.326 81.819-36.326 138.846-107.768l152.484 170.733q-104.136 93.238-219.428 136.83-114.053 42.38-257.859 42.38-147.525 0-272.736-48.435-125.209-48.435-216.948-140.462-97.937-96.87-148.765-221.59-49.589-125.93-49.589-272.446t49.589-272.447q50.828-125.93 148.765-222.8 92.978-93.238 216.948-140.462 123.971-48.435 272.736-48.435 141.326 0 255.38 41.17 115.292 41.17 209.51 124.72zm-2257.02 998.971V1044.47h232.044v148.938q87.633-88.394 183.906-130.774 97.507-43.592 208.592-43.592 228.34 0 360.407 152.57t132.067 420.174v754.374h-234.512v-731.368q0-204.638-72.822-299.086-71.587-95.659-225.871-95.659-175.266 0-264.134 101.714-87.633 100.502-87.633 302.718v721.681zm-892.716-181.631q196.244 0 322.659-131.985t126.414-339.045q0-205.849-126.414-337.834-126.415-133.196-322.659-133.196-196.242 0-322.659 131.985-126.414 131.986-126.414 339.045 0 208.27 126.414 340.256 126.417 130.774 322.659 130.774m665.785 181.631h-228.75v-213.114q-81.87 115.033-199.856 173.155-116.783 56.911-268.481 56.911-134.843 0-257.645-50.856-121.6-52.068-216.712-150.149-90.296-94.448-136.045-213.114-45.751-118.665-45.751-257.916 0-301.507 179.389-486.771 180.592-185.264 476.764-185.264 149.29 0 273.296 61.755 124.007 60.543 211.895 176.787V1044.47h211.896zM15269.933 675.153v1448.205h180.032q393.672 0 592.908-182.842 200.436-182.842 200.436-541.26 0-359.63-200.436-541.261-199.236-182.842-592.908-182.842zm142.825-224.012q528.095 0 802.946 242.175 276.05 240.964 276.05 705.94 0 463.764-276.05 705.94-274.851 240.963-802.946 240.963h-388.87V451.141Zm-1850.776 1135.8h859.921q-21.648-165.89-139.511-268.814t-286.24-102.924q-170.781 0-293.455 105.346-121.471 104.135-140.715 266.392m0 184.053q12.028 179.21 139.513 290.61 128.687 111.4 323.522 111.4 125.079 0 233.321-53.278 108.241-53.279 198.443-157.414l164.768 133.196q-123.876 145.305-271.807 214.325-146.728 69.02-331.941 69.02-152.741 0-285.037-55.7t-227.307-158.625q-82.985-90.816-127.484-209.481-44.5-119.877-44.5-250.651 0-309.984 184.01-501.302 184.012-191.318 481.075-191.318 303.076 0 489.494 204.638 187.618 203.427 187.618 537.628v16.952zm-868.74-1043.767v317.246h-454.785l-349.805 823.398c-5.809 13.723-17.015 44.384-33.613 92.012a110663 110663 0 0 1-58.496 173.164c-20.747-72.653-38.597-131.995-53.535-178.008-14.109-46.013-24.074-75.06-29.883-87.168l-344.824-823.398h-265.137l558.926 1301.68-235.274 540.058h240.254l747.813-1656.27 278.359 8.262v837.93c0 108.978 28.069 186.87 84.199 233.691 56.129 46.013 150.447 69.024 282.95 69.024h147.695v-219.16h-138.027q-59.35-.001-86.954-26.641c-17.483-17.76-26.23-46.018-26.23-84.766v-810.078h251.211v-193.73h-251.211V727.227ZM9676.595 2346.159V451.141h367.142q212.556 0 335.397 19.374 124.221 18.163 205.655 60.544 139.403 72.652 211.175 184.053 73.153 110.19 73.153 249.44 0 115.033-67.632 210.692-66.25 95.659-189.091 156.203 201.513 58.122 305.031 181.63 104.898 122.299 104.898 302.72 0 224.011-201.514 377.792-200.134 152.57-499.645 152.57zm280.188-1078.889h231.879q193.233 0 298.13-78.706 106.278-78.707 106.278-221.59 0-150.149-106.278-226.434t-317.453-76.285h-212.556zm0 856.088h285.708q241.541 0 365.762-81.128 125.601-81.129 125.601-239.753 0-156.203-121.46-236.12-121.461-81.13-358.861-81.13h-296.75z" style="font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:2647.62px;line-height:1.25;font-family:&quot;Geometr706 Md BT&quot;;-inkscape-font-specification:&quot;Geometr706 Md BT&quot;;letter-spacing:0;word-spacing:0;fill:#3259b4;fill-opacity:1;stroke-width:93.437" transform="translate(-5889.244 -88.063)"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/docker.svg b/assets/company-logos/docker.svg
new file mode 100644
index 0000000..1182caa
--- /dev/null
+++ b/assets/company-logos/docker.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 2333.95 530.79"><path d="M661.56 218.08c-16.49-11.1-59.81-15.84-91.3-7.35-1.69-31.37-17.88-57.81-47.47-80.88l-10.95-7.35-7.3 11.03c-14.35 21.78-20.4 50.81-18.26 77.2 1.69 16.26 7.34 34.53 18.26 47.79-40.99 23.78-78.78 18.38-246.12 18.38H.06c-.75 37.79 5.32 110.47 51.54 169.64 5.11 6.54 10.7 12.86 16.78 18.95 37.58 37.63 94.36 65.23 179.26 65.3 129.53.12 240.5-69.9 308.01-239.18 22.21.36 80.85 3.98 109.55-51.47.7-.93 7.3-14.7 7.3-14.7l-10.94-7.35Zm-492.89-34.55H96.02v72.65h72.65zm93.85 0h-72.65v72.65h72.65zm93.86 0h-72.65v72.65h72.65zm93.86 0h-72.65v72.65h72.65zm-375.43 0H2.16v72.65h72.65zm93.86-91.76H96.02v72.65h72.65zm93.85 0h-72.65v72.65h72.65zm93.86 0h-72.65v72.65h72.65zm0-91.77h-72.65v72.65h72.65zm1973.06 419.3c0 18.94-14.87 33.81-34.21 33.81s-34.42-14.87-34.42-33.81 15.27-33.4 34.42-33.4 34.21 14.87 34.21 33.4m-60.07 0c0 14.87 11 26.68 26.07 26.68s25.46-11.81 25.46-26.47-10.8-26.89-25.65-26.89-25.87 12.02-25.87 26.68Zm20.58 17.52h-7.74v-33.4c3.04-.61 7.33-1.02 12.82-1.02 6.32 0 9.16 1.02 11.61 2.45 1.84 1.42 3.26 4.07 3.26 7.33 0 3.67-2.85 6.52-6.91 7.74v.41c3.24 1.21 5.08 3.66 6.1 8.14 1.01 5.09 1.62 7.13 2.45 8.35h-8.35c-1.02-1.22-1.64-4.27-2.65-8.15-.61-3.66-2.65-5.29-6.93-5.29h-3.66v13.45Zm.19-18.94h3.66c4.28 0 7.74-1.42 7.74-4.88 0-3.06-2.23-5.11-7.13-5.11-2.03 0-3.46.21-4.27.43zM1017.16 81.28c-4.79-4.68-10.54-7.06-17.43-7.06s-12.81 2.38-17.42 7.06c-4.62 4.68-6.88 10.68-6.88 17.83v119.4c-23.7-19.59-51.05-29.47-82.16-29.47-36.16 0-67.08 13.06-92.7 39.27-25.62 26.12-38.34 57.72-38.34 94.78s12.81 68.57 38.34 94.78c25.62 26.12 56.46 39.27 92.7 39.27s66.74-13.06 92.7-39.27c25.62-25.86 38.34-57.45 38.34-94.78V99.11c0-7.15-2.35-13.15-7.15-17.83m-48.18 274.11v.18c-4.27 10.15-10.11 19.06-17.51 26.65-7.4 7.68-16.12 13.68-26.05 18.18-10.02 4.5-20.65 6.71-32.06 6.71s-22.3-2.21-32.32-6.71-18.65-10.5-25.96-18.09c-7.32-7.59-13.15-16.5-17.42-26.65-4.27-10.24-6.45-21.09-6.45-32.57s2.18-22.33 6.45-32.57 10.11-19.06 17.42-26.65c7.32-7.59 16.03-13.59 25.96-18.09 10.02-4.5 20.74-6.71 32.32-6.71s22.04 2.21 32.06 6.71 18.65 10.5 26.05 18.18 13.24 16.59 17.51 26.65c4.27 10.15 6.45 20.92 6.45 32.39s-2.18 22.33-6.45 32.39m1130.79-83.75c-6.36-15.89-16.05-30.27-28.76-43.16l-.17-.09c-25.88-26.12-56.82-39.27-92.7-39.27s-67.09 13.06-92.71 39.27c-25.62 26.12-38.33 57.72-38.33 94.78s12.81 68.57 38.33 94.78c25.62 26.12 56.47 39.27 92.71 39.27 32.92 0 61.41-10.85 85.64-32.56 4.69-4.94 7.06-10.94 7.06-17.92s-2.26-13.15-6.89-17.83c-4.61-4.68-10.45-7.06-17.42-7.06-6.09.18-11.5 2.21-16.11 6.27-7.32 6.35-15.25 11.21-23.87 14.39-8.63 3.18-18.04 4.77-28.31 4.77-9.07 0-17.78-1.41-26.05-4.32-8.29-2.91-16.03-6.89-22.92-12.09-6.98-5.21-12.98-11.38-18.12-18.71-5.14-7.24-9.06-15.27-11.67-24.09h185.32c6.87 0 12.62-2.38 17.42-7.06s7.15-10.68 7.15-17.83c0-18.53-3.24-35.74-9.58-51.54Zm-200.48 26.65c2.53-8.74 6.36-16.77 11.5-24.09 5.15-7.24 11.24-13.5 18.21-18.71 7.06-5.21 14.72-9.18 23.17-12.09 8.44-2.91 17.06-4.32 25.97-4.32s17.51 1.41 25.86 4.32c8.37 2.91 16.05 6.88 22.92 12.09 6.98 5.21 13.07 11.38 18.21 18.71 5.22 7.24 9.16 15.27 11.86 24.09h-157.71Zm428.22-92.4c-4.36-4.32-9.85-7.68-16.47-10.15s-13.85-4.15-21.78-5.12c-7.84-.97-15.25-1.41-22.12-1.41-15.61 0-30.24 2.56-44 7.68-13.77 5.12-26.49 12.44-38.17 21.97v-4.76c0-6.88-2.35-12.71-7.15-17.56-4.78-4.85-10.45-7.32-17.15-7.32s-12.64 2.47-17.42 7.32c-4.8 4.85-7.15 10.77-7.15 17.56v218.25c0 6.88 2.35 12.71 7.15 17.56 4.78 4.85 10.53 7.32 17.42 7.32s12.45-2.47 17.15-7.32c4.8-4.85 7.15-10.77 7.15-17.56V323.18c0-11.65 2.18-22.59 6.45-32.83s10.11-19.06 17.51-26.65c7.42-7.59 16.13-13.59 26.05-17.92 10.02-4.41 20.66-6.62 32.08-6.62s22.2 2.03 32.06 6c3.91 1.77 7.32 2.65 10.28 2.65 3.4 0 6.62-.62 9.58-1.94s5.58-3.09 7.76-5.38 3.91-4.94 5.22-8.03c1.31-3 2.01-6.27 2.01-9.8 0-6.88-2.18-12.44-6.53-16.77h.08Zm-1023.02 65.84c-6.36-15.8-15.86-30.27-28.66-43.33-25.87-26.12-56.8-39.27-92.7-39.27s-67.08 13.06-92.7 39.27c-25.62 26.12-38.33 57.72-38.33 94.78s12.81 68.57 38.33 94.78c25.62 26.12 56.46 39.27 92.7 39.27s66.74-13.06 92.7-39.27c25.62-25.86 38.34-57.45 38.34-94.78-.18-18.53-3.4-35.65-9.67-51.45Zm-45.65 83.66v.18c-4.27 10.15-10.11 19.06-17.51 26.65-7.4 7.68-16.12 13.68-26.05 18.18s-20.65 6.71-32.06 6.71-22.3-2.21-32.32-6.71-18.65-10.5-25.96-18.09c-7.32-7.59-13.15-16.5-17.42-26.65-4.27-10.24-6.45-21.09-6.45-32.57s2.18-22.33 6.45-32.57 10.11-19.06 17.42-26.65c7.32-7.59 16.03-13.59 25.96-18.09 10.02-4.5 20.74-6.71 32.32-6.71s22.04 2.21 32.06 6.71 18.65 10.5 26.05 18.18 13.24 16.59 17.51 26.65c4.27 10.15 6.45 20.92 6.45 32.39s-2.18 22.33-6.45 32.39m569.78-141.38c0-3.35-.7-6.53-2-9.53-1.31-3-3.05-5.73-5.23-8.03-2.18-2.29-4.79-4.15-7.75-5.38s-6.18-1.94-9.58-1.94c-4.88 0-9.24 1.24-13.07 3.8l-139.92 93.11V99.29c0-7.06-2.35-12.97-7.14-17.83-4.79-4.85-10.45-7.32-17.16-7.32s-12.63 2.47-17.43 7.32c-4.79 4.85-7.14 10.77-7.14 17.83V432c0 6.88 2.35 12.8 7.14 17.74s10.54 7.41 17.43 7.41 12.46-2.47 17.16-7.41c4.79-4.94 7.14-10.86 7.14-17.74v-86.4l28.58-19.15 108.12 124.17c4.36 4.32 9.85 6.44 16.38 6.44 3.4 0 6.62-.62 9.58-1.94 2.96-1.24 5.58-3.09 7.75-5.38 2.18-2.29 3.92-4.94 5.23-8.03 1.31-3 2-6.27 2-9.53 0-6.53-2.26-12.36-6.8-17.47l-100.63-115.87 98.01-65.13c6.27-4.32 9.32-10.94 9.32-19.86v.18Zm-414.26 49.69c7.49-7.59 16.21-13.59 26.23-17.92 10.02-4.41 20.65-6.62 32.06-6.62 10.28 0 19.78 1.77 28.58 5.29 8.71 3.53 17.08 8.74 25 15.53 4.7 3.79 10.02 5.73 15.94 5.73 7.06 0 12.81-2.38 17.43-7.15s6.88-10.77 6.88-17.92-2.79-13.77-8.45-18.88c-24.05-21.71-52.53-32.57-85.38-32.57-36.16 0-67.08 13.06-92.7 39.27-25.62 26.12-38.33 57.72-38.33 94.78s12.81 68.57 38.33 94.78c25.62 26.12 56.46 39.27 92.7 39.27 32.76 0 61.25-10.85 85.38-32.57 5.14-5.29 7.76-11.38 7.76-18.44s-2.27-13.15-6.88-17.83c-4.62-4.68-10.45-7.06-17.42-7.06-5.92.18-11.07 1.94-15.42 5.29-7.84 6.88-16.03 12-24.83 15.44-8.71 3.44-18.21 5.12-28.58 5.12-11.41 0-22.04-2.21-32.06-6.62s-18.73-10.41-26.23-17.91c-7.49-7.5-13.42-16.5-17.69-26.65-4.27-10.24-6.45-21.18-6.45-32.83s2.18-22.59 6.45-32.83 10.19-19.06 17.69-26.65v-.09Z" style="fill:#1d63ed;stroke-width:0"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/google.svg b/assets/company-logos/google.svg
new file mode 100644
index 0000000..af303a7
--- /dev/null
+++ b/assets/company-logos/google.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="272" height="92" viewBox="0 0 272 92"><path fill="#ea4335" d="M115.75 47.18c0 12.77-9.99 22.18-22.25 22.18s-22.25-9.41-22.25-22.18C71.25 34.32 81.24 25 93.5 25s22.25 9.32 22.25 22.18m-9.74 0c0-7.98-5.79-13.44-12.51-13.44S80.99 39.2 80.99 47.18c0 7.9 5.79 13.44 12.51 13.44s12.51-5.55 12.51-13.44"/><path fill="#fbbc05" d="M163.75 47.18c0 12.77-9.99 22.18-22.25 22.18s-22.25-9.41-22.25-22.18c0-12.85 9.99-22.18 22.25-22.18s22.25 9.32 22.25 22.18m-9.74 0c0-7.98-5.79-13.44-12.51-13.44s-12.51 5.46-12.51 13.44c0 7.9 5.79 13.44 12.51 13.44s12.51-5.55 12.51-13.44"/><path fill="#4285f4" d="M209.75 26.34v39.82c0 16.38-9.66 23.07-21.08 23.07-10.75 0-17.22-7.19-19.66-13.07l8.48-3.53c1.51 3.61 5.21 7.87 11.17 7.87 7.31 0 11.84-4.51 11.84-13v-3.19h-.34c-2.18 2.69-6.38 5.04-11.68 5.04-11.09 0-21.25-9.66-21.25-22.09 0-12.52 10.16-22.26 21.25-22.26 5.29 0 9.49 2.35 11.68 4.96h.34v-3.61h9.25zm-8.56 20.92c0-7.81-5.21-13.52-11.84-13.52-6.72 0-12.35 5.71-12.35 13.52 0 7.73 5.63 13.36 12.35 13.36 6.63 0 11.84-5.63 11.84-13.36"/><path fill="#34a853" d="M225 3v65h-9.5V3z"/><path fill="#ea4335" d="m262.02 54.48 7.56 5.04c-2.44 3.61-8.32 9.83-18.48 9.83-12.6 0-22.01-9.74-22.01-22.18 0-13.19 9.49-22.18 20.92-22.18 11.51 0 17.14 9.16 18.98 14.11l1.01 2.52-29.65 12.28c2.27 4.45 5.8 6.72 10.75 6.72 4.96 0 8.4-2.44 10.92-6.14m-23.27-7.98 19.82-8.23c-1.09-2.77-4.37-4.7-8.23-4.7-4.95 0-11.84 4.37-11.59 12.93"/><path fill="#4285f4" d="M35.29 41.41V32H67c.31 1.64.47 3.58.47 5.68 0 7.06-1.93 15.79-8.15 22.01-6.05 6.3-13.78 9.66-24.02 9.66C16.32 69.35.36 53.89.36 34.91S16.32.47 35.3.47c10.5 0 17.98 4.12 23.6 9.49l-6.64 6.64c-4.03-3.78-9.49-6.72-16.97-6.72-13.86 0-24.7 11.17-24.7 25.03s10.84 25.03 24.7 25.03c8.99 0 14.11-3.61 17.39-6.89 2.66-2.66 4.41-6.46 5.1-11.65z"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/ibm.svg b/assets/company-logos/ibm.svg
new file mode 100644
index 0000000..9d8e4e0
--- /dev/null
+++ b/assets/company-logos/ibm.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="1000" height="401.149" viewBox="0 0 1000 401.149"><path fill="#1f70c1" d="M0 373.217h194.433v27.833H0zm0-53.387h194.433v27.931H0zm55.468-53.289h83.399v27.932H55.468zm0-53.288h83.399v27.932H55.468zm0-53.289h83.399v27.932H55.468zm0-53.387h83.399v27.932H55.468zM0 53.288h194.433V81.22H0zM0 0h194.433v27.932H0z" style="fill:#fd1813;fill-opacity:1"/><path fill="#1f70c1" d="M222.167 347.761h299.029a99.2 99.2 0 0 0 11.094-27.932H222.167zM497.92 213.253H277.734v27.932h243.463c-6.34-10.698-14.165-20.107-23.277-27.932m-220.186-53.289v27.932H497.92c9.311-7.825 17.135-17.235 23.277-27.932zM521.197 53.288h-299.03V81.22H532.29c-2.575-9.905-6.339-19.314-11.093-27.932M429.279 0H222.167v27.932h278.526A105.15 105.15 0 0 0 429.279 0M277.734 106.577h83.3v27.932h-83.3zm166.699 27.932h87.163c2.476-8.914 3.764-18.324 3.764-27.932h-90.927zM277.734 266.541h83.3v27.932h-83.3zm166.699 0v27.932h90.927c0-9.608-1.288-19.017-3.764-27.932zM222.167 400.852l207.112.297c27.734 0 52.793-10.697 71.513-27.932H222.167z" style="fill:#0fa14c;fill-opacity:1"/><path d="M555.567 373.217h138.866v27.833H555.567zm0-53.387h138.866v27.931H555.567zm55.467-53.289h83.399v27.932h-83.399zm0-53.288h83.399v27.932h-83.399zM733.063 53.288H555.567V81.22H742.67zM714.639 0H555.567v27.932h168.68zm146.395 373.217H1000v27.833H861.034zm0-53.387H1000v27.931H861.034zm0-53.289h83.399v27.932h-83.399zm0-53.288h83.399v27.932h-83.399zm0-25.357h83.399v-27.932H785.559l-7.825 22.584-7.825-22.584H611.034v27.932h83.399v-25.654l8.816 25.654h148.97l8.815-25.654zm83.399-81.319H803.982l-9.608 27.932h150.059zM840.927 0l-9.608 27.932H1000V0zm-63.193 400.852 9.607-27.635h-19.215zm-18.423-53.091h36.846l9.905-27.931h-56.557zm-18.721-53.288h74.287l9.806-27.932h-93.899zm-18.621-53.288h111.628l9.509-27.932H712.361zM611.034 134.509h150.059l-9.607-27.932H611.034zM812.896 81.22H1000V53.288H822.405z" style="fill:#1f70c1;fill-opacity:1"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/jetbrains.svg b/assets/company-logos/jetbrains.svg
new file mode 100644
index 0000000..6a27412
--- /dev/null
+++ b/assets/company-logos/jetbrains.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="298" height="64" fill="none" viewBox="0 0 298 64"><defs><linearGradient id="a" x1=".85" x2="62.62" y1="62.72" y2="1.81" gradientUnits="userSpaceOnUse"><stop stop-color="#ff9419"/><stop offset=".43" stop-color="#ff021d"/><stop offset=".99" stop-color="#e600ff"/></linearGradient></defs><path fill="#000" d="M86.484 40.586c0 .846-.179 1.593-.537 2.25a3.7 3.7 0 0 1-1.514 1.524c-.657.358-1.394.538-2.24.538H78v6.104h5.079c1.912 0 3.625-.428 5.148-1.285a9.36 9.36 0 0 0 3.585-3.545c.866-1.503 1.305-3.196 1.305-5.088V21.018h-6.633zm17.856-1.823h13.891v-5.606H104.34v-6.363h15.355v-5.776H97.877v29.974h22.246v-5.776H104.34zm17.865-11.8h8.882v24.02h6.633v-24.02h8.842v-5.945h-24.367v5.955zm47.022 9.002a8 8 0 0 0-1.673-.647 7.5 7.5 0 0 0 1.275-.488c1.096-.568 1.962-1.364 2.579-2.39s.936-2.2.936-3.535c0-1.524-.418-2.888-1.244-4.093-.827-1.195-1.992-2.131-3.486-2.808-1.494-.667-3.206-1.006-5.118-1.006h-13.315v29.974h13.574c2.011 0 3.804-.348 5.387-1.055 1.573-.707 2.798-1.683 3.675-2.948.866-1.255 1.304-2.689 1.304-4.302 0-1.484-.338-2.808-1.026-3.983a7.05 7.05 0 0 0-2.858-2.729zm-13.603-9.918h5.886c.816 0 1.533.15 2.161.438a3.35 3.35 0 0 1 1.464 1.255c.348.537.527 1.175.527 1.902s-.179 1.414-.527 1.981-.837.996-1.464 1.305c-.628.309-1.345.458-2.161.458h-5.886v-7.35zm10.138 18.134c-.378.567-.916 1.006-1.603 1.314-.697.309-1.484.458-2.39.458h-6.145v-7.687h6.145c.886 0 1.673.169 2.37.497.687.329 1.235.787 1.613 1.385.378.597.578 1.274.578 2.041s-.19 1.424-.568 1.992m29.596-5.308c1.663-.797 2.947-1.922 3.864-3.366s1.374-3.117 1.374-5.029-.448-3.525-1.344-4.959-2.171-2.54-3.814-3.326c-1.644-.787-3.546-1.175-5.717-1.175h-13.124v29.974h6.642V40.078h4.322l6.084 10.914h7.578l-6.851-11.72c.339-.12.677-.25.996-.399zm-2.151-6.124a3.6 3.6 0 0 1-1.583 1.444q-1.031.508-2.42.508h-5.975v-8.296h5.975c.926 0 1.732.17 2.42.498a3.64 3.64 0 0 1 1.583 1.434c.368.618.558 1.355.558 2.19 0 .837-.19 1.574-.558 2.202zm20.594-11.731-10.706 29.974h6.742l2.121-6.612h11.114l2.27 6.612h6.612L220.99 21.018zm-.339 18.343 3.445-10.576.409-1.922.408 1.922 3.685 10.576zm20.693 11.631h6.851V21.018h-6.851zm31.02-9.7-12.896-20.274h-6.463v29.974h6.055V30.717l12.826 20.275h6.533V21.018h-6.055zm31.528-3.355c-.647-1.245-1.564-2.29-2.729-3.137-1.165-.846-2.509-1.404-4.023-1.693l-5.098-1.045c-.797-.19-1.434-.518-1.902-.996-.469-.478-.708-1.076-.708-1.783 0-.647.17-1.205.518-1.683.339-.478.827-.846 1.444-1.115.618-.269 1.335-.398 2.151-.398.817 0 1.554.139 2.181.418s1.115.667 1.464 1.175.528 1.075.528 1.723h6.642c-.04-1.743-.528-3.287-1.444-4.621-.916-1.344-2.201-2.39-3.834-3.147s-3.505-1.135-5.597-1.135c-2.091 0-3.943.388-5.566 1.175s-2.898 1.872-3.804 3.266c-.906 1.395-1.364 2.978-1.364 4.76 0 1.444.288 2.749.876 3.904a7.9 7.9 0 0 0 2.479 2.898c1.076.767 2.311 1.304 3.725 1.603l5.397 1.115q1.327.317 2.101 1.156c.518.557.767 1.244.767 2.08a3.03 3.03 0 0 1-.567 1.803q-.569.792-1.584 1.225c-.677.289-1.474.428-2.39.428s-1.782-.159-2.529-.478q-1.12-.477-1.733-1.374c-.418-.587-.617-1.275-.617-2.041h-6.642c.029 1.872.527 3.515 1.513 4.949.976 1.424 2.32 2.54 4.033 3.336q2.57 1.195 5.886 1.195c2.21 0 4.202-.408 5.915-1.225 1.723-.816 3.057-1.942 4.023-3.376s1.444-3.057 1.444-4.87c0-1.483-.329-2.847-.976-4.102z"/><path fill="url(#a)" d="M20.34 3.66 3.66 20.34A12.5 12.5 0 0 0 0 29.18V59c0 2.76 2.24 5 5 5h29.82c3.32 0 6.49-1.32 8.84-3.66l16.68-16.68c2.34-2.34 3.66-5.52 3.66-8.84V5c0-2.76-2.24-5-5-5H29.18c-3.32 0-6.49 1.32-8.84 3.66"/><path fill="#000" d="M48 16H8v40h40z"/><path fill="#fff" d="M30 47H13v4h17z"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/nvidia.svg b/assets/company-logos/nvidia.svg
new file mode 100644
index 0000000..37534dd
--- /dev/null
+++ b/assets/company-logos/nvidia.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="656" height="120" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 164 30"><path d="M160.352 24.069v-.449h.288c.157 0 .371.012.371.204 0 .208-.11.245-.296.245zm0 .315h.192l.447.784h.49l-.494-.816c.255-.019.465-.14.465-.484 0-.427-.295-.565-.793-.565h-.721v1.865h.414zm2.098-.146c0-1.095-.851-1.73-1.8-1.73-.955 0-1.805.635-1.805 1.73s.85 1.733 1.805 1.733c.948 0 1.8-.638 1.8-1.733m-.52 0c0 .798-.587 1.334-1.28 1.334v-.006c-.713.006-1.289-.53-1.289-1.328 0-.797.577-1.331 1.289-1.331.694 0 1.28.534 1.28 1.331M96.374 5.707l.002 19.66h5.552V5.707zm-43.677-.026v19.686H58.3V10.086l4.37.014c1.437 0 2.43.345 3.123 1.084.879.936 1.237 2.444 1.237 5.205v8.978h5.427V14.49c0-7.763-4.948-8.81-9.789-8.81h-9.97zm52.617.027v19.659h9.006c4.798 0 6.364-.798 8.057-2.587 1.198-1.256 1.971-4.014 1.971-7.027 0-2.763-.655-5.228-1.797-6.763-2.057-2.745-5.02-3.282-9.445-3.282zm5.508 4.28h2.387c3.463 0 5.703 1.556 5.703 5.591 0 4.037-2.24 5.592-5.703 5.592h-2.387zm-22.453-4.28-4.634 15.58-4.44-15.579-5.993-.001 6.34 19.659h8.003l6.391-19.659zm38.563 19.659h5.553V5.709l-5.555-.001zm15.564-19.652-7.753 19.645h5.475l1.227-3.472h9.175l1.161 3.472h5.944l-7.812-19.646zM146.1 9.3l3.364 9.204h-6.833l3.47-9.204z" style="fill:#f5f4f0"/><path d="M16.889 8.985V6.28q.393-.03.798-.042c7.4-.232 12.255 6.359 12.255 6.359s-5.244 7.282-10.866 7.282a6.8 6.8 0 0 1-2.187-.35v-8.204c2.88.348 3.46 1.62 5.192 4.508l3.852-3.248s-2.812-3.688-7.552-3.688c-.515 0-1.008.036-1.492.088m0-8.938V4.09q.398-.032.798-.048c10.29-.346 16.995 8.44 16.995 8.44s-7.7 9.364-15.723 9.364c-.735 0-1.424-.068-2.07-.183v2.498c.553.07 1.126.112 1.724.112 7.465 0 12.864-3.812 18.092-8.325.867.694 4.416 2.383 5.145 3.123-4.971 4.16-16.555 7.515-23.123 7.515a19 19 0 0 1-1.838-.096V30h28.375V.047zm0 19.482v2.133c-6.905-1.23-8.822-8.408-8.822-8.408s3.316-3.674 8.822-4.269v2.34l-.011-.001c-2.89-.347-5.147 2.353-5.147 2.353s1.265 4.544 5.158 5.852M4.625 12.943s4.092-6.04 12.264-6.663V4.088C7.838 4.815 0 12.48 0 12.48s4.439 12.833 16.889 14.008V24.16C7.753 23.011 4.625 12.943 4.625 12.943" style="fill:#76b900"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/salesforce.svg b/assets/company-logos/salesforce.svg
new file mode 100644
index 0000000..558c195
--- /dev/null
+++ b/assets/company-logos/salesforce.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 273 191"><title>Salesforce.com logo</title><desc>A cloud computing company based in San Francisco, California, United States</desc><defs><path id="a" d="M.06.5h272v190H.06z"/></defs><g fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><path fill="#00a1e0" d="M113 21.3c8.78-9.14 21-14.8 34.5-14.8 18 0 33.6 10 42 24.9a58 58 0 0 1 23.7-5.05c32.4 0 58.7 26.5 58.7 59.2s-26.3 59.2-58.7 59.2c-3.96 0-7.82-.398-11.6-1.15-7.35 13.1-21.4 22-37.4 22a42.7 42.7 0 0 1-18.8-4.32c-7.45 17.5-24.8 29.8-45 29.8-21.1 0-39-13.3-45.9-32a45 45 0 0 1-9.34.972c-25.1 0-45.4-20.6-45.4-45.9 0-17 9.14-31.8 22.7-39.8a52.6 52.6 0 0 1-4.35-21c0-29.2 23.7-52.8 52.9-52.8 17.1 0 32.4 8.15 42 20.8" mask="url(#b)"/><path fill="#fffffe" d="M39.4 99.3c-.171.446.061.539.116.618.511.37 1.03.638 1.55.939 2.78 1.47 5.4 1.9 8.14 1.9 5.58 0 9.05-2.97 9.05-7.75v-.094c0-4.42-3.92-6.03-7.58-7.18l-.479-.155c-2.77-.898-5.16-1.68-5.16-3.5v-.093c0-1.56 1.4-2.71 3.56-2.71 2.4 0 5.26.799 7.09 1.81 0 0 .542.35.739-.173.107-.283 1.04-2.78 1.14-3.06.106-.293-.08-.514-.271-.628-2.1-1.28-5-2.15-8-2.15l-.557.002c-5.11 0-8.68 3.09-8.68 7.51v.095c0 4.66 3.94 6.18 7.62 7.23l.592.184c2.68.824 5 1.54 5 3.42v.094c0 1.73-1.51 3.02-3.93 3.02-.941 0-3.94-.016-7.19-2.07-.393-.229-.617-.394-.92-.579-.16-.097-.56-.272-.734.252l-1.1 3.06m81.7 0c-.171.446.061.539.118.618.509.37 1.03.638 1.55.939 2.78 1.47 5.4 1.9 8.14 1.9 5.58 0 9.05-2.97 9.05-7.75v-.094c0-4.42-3.91-6.03-7.58-7.18l-.479-.155c-2.77-.898-5.16-1.68-5.16-3.5v-.093c0-1.56 1.4-2.71 3.56-2.71 2.4 0 5.25.799 7.09 1.81 0 0 .542.35.74-.173.106-.283 1.04-2.78 1.13-3.06.107-.293-.08-.514-.27-.628-2.1-1.28-5-2.15-8-2.15l-.558.002c-5.11 0-8.68 3.09-8.68 7.51v.095c0 4.66 3.94 6.18 7.62 7.23l.591.184c2.69.824 5 1.54 5 3.42v.094c0 1.73-1.51 3.02-3.93 3.02-.943 0-3.95-.016-7.19-2.07-.393-.229-.623-.387-.921-.579-.101-.064-.572-.248-.733.252l-1.1 3.06m55.8-9.36c0 2.7-.504 4.83-1.49 6.34-.984 1.49-2.47 2.22-4.54 2.22s-3.55-.724-4.52-2.21c-.977-1.5-1.47-3.64-1.47-6.34s.496-4.82 1.47-6.31c.968-1.48 2.44-2.19 4.52-2.19s3.56.717 4.54 2.19c.992 1.49 1.49 3.61 1.49 6.31m4.66-5.01c-.459-1.55-1.17-2.91-2.12-4.05a10.15 10.15 0 0 0-3.58-2.72c-1.42-.665-3.1-1-5-1s-3.57.337-5 1c-1.42.664-2.63 1.58-3.58 2.72-.948 1.14-1.66 2.5-2.12 4.05-.455 1.54-.686 3.22-.686 5.01s.231 3.47.686 5.01c.457 1.55 1.17 2.91 2.12 4.05s2.16 2.05 3.58 2.7c1.43.648 3.11.978 5 .978s3.57-.33 4.99-.978 2.63-1.56 3.58-2.7c.949-1.14 1.66-2.5 2.12-4.05.454-1.54.685-3.22.685-5.01 0-1.78-.231-3.47-.685-5.01m38.3 12.8c-.153-.453-.595-.282-.595-.282-.677.259-1.4.499-2.17.619-.776.122-1.64.183-2.55.183-2.25 0-4.05-.671-5.33-2-1.29-1.33-2.01-3.47-2-6.37.007-2.64.645-4.62 1.79-6.14 1.13-1.5 2.87-2.28 5.17-2.28 1.92 0 3.39.223 4.93.705 0 0 .365.159.54-.322.409-1.13.711-1.94 1.15-3.18.124-.355-.18-.505-.291-.548-.604-.236-2.03-.623-3.11-.786-1.01-.154-2.18-.234-3.5-.234-1.96 0-3.7.335-5.19.999s-2.75 1.58-3.75 2.72-1.76 2.5-2.27 4.05c-.505 1.54-.76 3.23-.76 5.02 0 3.86 1.04 6.99 3.1 9.28 2.06 2.3 5.16 3.46 9.2 3.46 2.39 0 4.84-.483 6.6-1.18 0 0 .336-.162.19-.554l-1.15-3.16m8.15-10.4c.223-1.5.634-2.75 1.28-3.72.967-1.48 2.44-2.29 4.51-2.29s3.44.814 4.42 2.29c.65.975.934 2.27 1.04 3.72l-11.3-.002zm15.7-3.3c-.397-1.49-1.38-3-2.02-3.69-1.02-1.09-2.01-1.86-3-2.28a11.5 11.5 0 0 0-4.52-.917c-1.97 0-3.76.333-5.21 1.01-1.45.682-2.67 1.61-3.63 2.77-.959 1.16-1.68 2.53-2.14 4.1-.46 1.55-.692 3.25-.692 5.03 0 1.82.241 3.51.715 5.04.479 1.54 1.25 2.89 2.29 4.01 1.04 1.13 2.37 2.01 3.97 2.63 1.59.615 3.52.934 5.73.927 4.56-.015 6.96-1.03 7.94-1.58.175-.098.34-.267.134-.754l-1.03-2.89c-.158-.431-.594-.275-.594-.275-1.13.422-2.73 1.18-6.48 1.17-2.45-.004-4.26-.727-5.4-1.86-1.16-1.16-1.74-2.85-1.83-5.25l15.8.012s.416-.004.459-.41c.017-.168.541-3.24-.471-6.79zm-142 3.3c.223-1.5.635-2.75 1.28-3.72.968-1.48 2.44-2.29 4.51-2.29s3.44.814 4.42 2.29c.649.975.933 2.27 1.04 3.72l-11.3-.002zm15.7-3.3c-.396-1.49-1.38-3-2.02-3.69-1.02-1.09-2.01-1.86-3-2.28a11.5 11.5 0 0 0-4.52-.917c-1.97 0-3.76.333-5.21 1.01-1.45.682-2.67 1.61-3.63 2.77-.957 1.16-1.68 2.53-2.14 4.1-.459 1.55-.69 3.25-.69 5.03 0 1.82.239 3.51.716 5.04.478 1.54 1.25 2.89 2.28 4.01 1.04 1.13 2.37 2.01 3.97 2.63 1.59.615 3.51.934 5.73.927 4.56-.015 6.96-1.03 7.94-1.58.174-.098.34-.267.133-.754l-1.03-2.89c-.159-.431-.595-.275-.595-.275-1.13.422-2.73 1.18-6.48 1.17-2.44-.004-4.26-.727-5.4-1.86-1.16-1.16-1.74-2.85-1.83-5.25l15.8.012s.416-.004.459-.41c.017-.168.541-3.24-.472-6.79zm-49.8 13.6c-.619-.494-.705-.615-.91-.936-.313-.483-.473-1.17-.473-2.05 0-1.38.46-2.38 1.41-3.05-.01.002 1.36-1.18 4.58-1.14a32 32 0 0 1 4.28.365v7.17h.002s-2 .431-4.26.567c-3.21.193-4.63-.924-4.62-.921zm6.28-11.1c-.64-.047-1.47-.07-2.46-.07-1.35 0-2.66.168-3.88.498-1.23.332-2.34.846-3.29 1.53a7.6 7.6 0 0 0-2.29 2.6c-.559 1.04-.844 2.26-.844 3.64 0 1.4.243 2.61.723 3.6a6.54 6.54 0 0 0 2.06 2.47c.877.638 1.96 1.11 3.21 1.39 1.24.283 2.64.426 4.18.426 1.62 0 3.23-.136 4.79-.399a95 95 0 0 0 3.97-.772c.526-.121 1.11-.28 1.11-.28.39-.099.36-.516.36-.516l-.009-14.4c0-3.16-.844-5.51-2.51-6.96-1.66-1.45-4.09-2.18-7.24-2.18-1.18 0-3.09.16-4.23.389 0 0-3.44.668-4.86 1.78 0 0-.312.192-.142.627l1.12 3c.139.389.518.256.518.256s.119-.047.259-.13c3.03-1.65 6.87-1.6 6.87-1.6 1.7 0 3.02.345 3.9 1.02.861.661 1.3 1.66 1.3 3.76v.667c-1.35-.196-2.6-.309-2.6-.309zm127-8.13a.43.43 0 0 0-.237-.568c-.269-.102-1.61-.385-2.64-.449-1.98-.124-3.08.21-4.07.654-.978.441-2.06 1.15-2.66 1.97l-.002-1.92c0-.264-.187-.477-.453-.477h-4.04c-.262 0-.452.213-.452.477v23.5a.48.48 0 0 0 .479.479h4.14a.48.48 0 0 0 .478-.479v-11.8c0-1.58.174-3.15.521-4.14.342-.979.807-1.76 1.38-2.32a4.8 4.8 0 0 1 1.95-1.17 7.7 7.7 0 0 1 2.12-.298c.825 0 1.73.212 1.73.212.304.034.473-.152.576-.426.271-.721 1.04-2.88 1.19-3.31"/><path fill="#fffffe" d="M162.201 67.548a13 13 0 0 0-1.559-.37 12 12 0 0 0-2.144-.166c-2.853 0-5.102.806-6.681 2.398-1.568 1.58-2.635 3.987-3.17 7.154l-.193 1.069h-3.581s-.437-.018-.529.459l-.588 3.28c-.041.314.094.51.514.508h3.486l-3.537 19.743c-.277 1.59-.594 2.898-.945 3.889-.346.978-.684 1.711-1.1 2.243-.403.515-.785.894-1.444 1.115-.544.183-1.17.267-1.856.267-.382 0-.89-.064-1.265-.139-.375-.074-.57-.158-.851-.276 0 0-.409-.156-.57.254-.131.335-1.06 2.89-1.17 3.206-.112.312.045.558.243.629.464.166.809.272 1.441.421.878.207 1.618.22 2.311.22 1.452 0 2.775-.204 3.872-.6 1.104-.399 2.065-1.094 2.915-2.035.919-1.015 1.497-2.078 2.05-3.528.547-1.437 1.013-3.221 1.386-5.3l3.554-20.109h5.196s.438.016.529-.459l.588-3.28c.041-.314-.093-.51-.515-.508h-5.043c.025-.114.254-1.888.833-3.558.247-.713.712-1.288 1.106-1.683a3.3 3.3 0 0 1 1.321-.822 5.5 5.5 0 0 1 1.693-.244c.475 0 .941.057 1.296.131.489.104.679.159.807.197.514.157.583.005.684-.244l1.206-3.312c.124-.356-.178-.506-.29-.55m-70.474 34.117c0 .264-.188.479-.452.479h-4.183c-.265 0-.453-.215-.453-.479V67.997c0-.263.188-.476.453-.476h4.183c.264 0 .452.213.452.476z"/></g></svg>
\ No newline at end of file
diff --git a/assets/company-logos/sentry.svg b/assets/company-logos/sentry.svg
new file mode 100644
index 0000000..dc9ea20
--- /dev/null
+++ b/assets/company-logos/sentry.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="400" height="119" aria-hidden="true" viewBox="0 0 222 66"><path fill="#f5f4f0" d="M40 13.26a4.67 4.67 0 0 0-8 0l-6.58 11.27a32.21 32.21 0 0 1 17.75 26.66h-4.62a27.68 27.68 0 0 0-15.46-22.72L17 39a15.92 15.92 0 0 1 9.23 12.17H15.62a.76.76 0 0 1-.62-1.11l2.94-5a10.7 10.7 0 0 0-3.36-1.9l-2.91 5a4.54 4.54 0 0 0 1.69 6.24 4.66 4.66 0 0 0 2.26.6h14.53a19.4 19.4 0 0 0-8-17.31l2.31-4A23.87 23.87 0 0 1 34.76 55h12.31a35.88 35.88 0 0 0-16.41-31.8l4.67-8a.77.77 0 0 1 1.05-.27c.53.29 20.29 34.77 20.66 35.17a.76.76 0 0 1-.68 1.13H51.6q.09 1.91 0 3.81h4.78A4.59 4.59 0 0 0 61 50.43a4.5 4.5 0 0 0-.62-2.28Zm95.32 26.02-14.76-19.06h-3.68v25.55h3.73V26.19l15.18 19.58h3.26V20.22h-3.73Zm-37.17-4.74h13.23v-3.32H98.14v-7.69h14.93v-3.32H94.34v25.56h18.92v-3.32H98.14ZM82.59 31.3C77.44 30.06 76 29.08 76 26.7c0-2.14 1.89-3.59 4.71-3.59a12.06 12.06 0 0 1 7.07 2.55l2-2.83a14.1 14.1 0 0 0-9-3c-5.06 0-8.59 3-8.59 7.27 0 4.6 3 6.19 8.46 7.52 4.86 1.12 6.35 2.16 6.35 4.49s-2 3.77-5.09 3.77a12.34 12.34 0 0 1-8.3-3.26l-2.25 2.69a15.94 15.94 0 0 0 10.42 3.85c5.48 0 9-2.95 9-7.51-.03-3.86-2.31-5.93-8.19-7.35M206.7 20.22l-7.69 12-7.64-12h-4.46L197 35.67v10.11h3.84V35.55L211 20.22Zm-64.63 3.46h8.37v22.1h3.84v-22.1h8.37v-3.46h-20.57Zm38.34 12.12c3.86-1.07 6-3.77 6-7.63 0-4.91-3.59-8-9.38-8h-11.36v25.59h3.8v-9.18h6.45l6.48 9.2h4.44l-7-9.82Zm-10.95-2.5v-9.7h7.17c3.74 0 5.88 1.77 5.88 4.84s-2.29 4.86-5.84 4.86Z"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/tencent.svg b/assets/company-logos/tencent.svg
new file mode 100644
index 0000000..8249f59
--- /dev/null
+++ b/assets/company-logos/tencent.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="137" viewBox="-0.792 -0.792 208.741 27.991"><path d="M.938 0 0 3.656h8.688L5.5 16.406l-2.344 9.407h5.688l2.344-9.407 3.187-12.75h8.656L23.938 0zm173.625.281c-.463 1.9-.888 3.383-1.313 4.657h-2.125l.688-.438c1.185-.769 1.768-1.164 1.78-1.219-.632-1.428-1.167-2.415-1.655-2.906-.014-.089-.917.463-2.657 1.594l-.594.375c.013.027.084.134.22.312.612.796 1.13 1.546 1.53 2.282h-1.75l-.562 2.625h3.938l-.282.53-.281.532h-4.25l-.531 2.625h2.937a14.8 14.8 0 0 1-4.469 3.688l2.75-13.125h-7.593L157.5 15.375a26 26 0 0 1-.25 1.031c-.661 2.624-1.645 4.978-2.969 7.063a11.7 11.7 0 0 1 2 2.281c1.591-2.27 2.841-5.176 3.781-8.75h1.657l-.938 4.438c-.074.607-.691.88-1.812.78.234 1.087.334 2.175.25 3.313 2.706-.017 4.072-.627 4.125-1.875l1.531-7.25.094-.5q.254.253.468.5c.365.43.65.853.844 1.282.415-.205.908-.52 1.469-1l-1 3.25h9.094l-.157.843c-.36 1.755-.866 2.608-1.5 2.5l.5-2.5h-9.75l-.562 2.594h7.937c.454.979.648 1.981.563 3.031 2.532-.082 4.084-.472 4.687-1.187.19-.209.388-.572.594-1.188.414-1.238.899-3.05 1.5-5.437l.344-1.375h-3.375l.187-.813.25-1.25c.6.525 1.133.934 1.594 1.25q.512.352.906.532c.145-.182.294-.36.438-.532a28 28 0 0 1 2.469-2.656q-3.059-.869-4.469-2.5h4.469L183 8.625h-8.031a4.7 4.7 0 0 0 .562-1.062h6.625l.531-2.625h-2.375c1.038-1.014 1.95-1.848 2.782-2.5l-2.781-1.72a39 39 0 0 1-2.907 3.313l1.688 1.032h-2.406l.343-1.094.438-1.094a36 36 0 0 1 .875-2.062c.02-.182-.099-.273-.469-.313q-1.05-.164-3.312-.219M191.375.5c-.847.281-2.165.795-4 1.563A31 31 0 0 1 188.656 7c.649-.246 2.056-.73 4.188-1.5-.889-3.2-1.398-4.874-1.469-5m3.094 1.406-.656 3.032h2.5l-1.188 5.656H192l-.656 3.031h3.156l-.594 2.781L192 25.438h3.5l1.875-9.032.594-2.781h2.5l-.5 2.281c-.036.169-.063.334-.094.5-.64 3.352-.311 5.973.969 7.844.78 1.109 1.721 1.578 2.844 1.406 1.162.06 2.326-1.816 3.468-5.625a134 134 0 0 0-2.843-1.25c-.568 1.745-.92 2.312-1.063 1.688-.224-.89-.177-2.244.125-4.063.098-.587.223-1.218.375-1.906l2.656-12.594zm-51.906.313-1.188 4.812h-4.562l-.875 3.563h16.687zM162.688 4.5h1.656l-.781 3.719h-1.625zm37.093.438h2.625l-1.187 5.656h-2.625zM30.47 5.968c-3.468 0-6.475.907-9.032 2.72-2.542 1.838-4.193 4.247-4.937 7.218-.044.174-.09.33-.125.5-.571 2.759-.065 5.009 1.531 6.719 1.702 1.8 4.327 2.688 7.875 2.688 6.049 0 10.685-3.69 11.157-4.094l-2.626-.594c-.617.143-3.98 1.116-7.343.813-4.244-.382-5.207-3.49-4.969-5.032h17.031l.157-.5.124-.531c.773-3.097.364-5.534-1.218-7.281-1.595-1.763-4.123-2.625-7.625-2.625m50.469 0c-3.65 0-6.95.947-9.938 2.876-2.994 1.93-4.83 4.321-5.531 7.125-.039.154-.095.286-.125.437-.524 2.603.17 4.804 2.062 6.594 2.008 1.878 4.849 2.813 8.531 2.813 2.232 0 4.573-.463 7.032-1.375l1.437-5.72c-1.635 1.26-3.006 2.096-4.094 2.532a9.7 9.7 0 0 1-3.656.688c-2.069 0-3.634-.555-4.687-1.688-.933-1.02-1.266-2.315-1-3.844.03-.168.081-.326.125-.5.431-1.722 1.469-3.17 3.125-4.312 1.652-1.143 3.524-1.719 5.594-1.719 1.318 0 2.386.224 3.25.688.88.448 1.802 1.29 2.75 2.5L87.25 7.28c-1.864-.886-3.966-1.312-6.312-1.312m20.593 0c-3.469 0-6.476.907-9.031 2.72-2.544 1.838-4.195 4.247-4.937 7.218-.044.174-.09.33-.126.5-.572 2.759-.067 5.009 1.532 6.719 1.699 1.8 4.327 2.688 7.875 2.688 6.046 0 10.686-3.69 11.156-4.094l-2.625-.594c-.617.143-3.98 1.116-7.344.813-4.242-.382-5.209-3.49-4.969-5.032h17.032l.156-.5.125-.531c.775-3.097.393-5.534-1.187-7.281-1.596-1.763-4.153-2.625-7.657-2.625M57.22 6.5c-2.41 0-4.767.745-7.063 2.25l-5.093-1.719-2.344 9.375-2.344 9.407h5.531l2.25-9.063c.03-.116.065-.239.094-.344.575-2.16 1.346-3.7 2.281-4.593 1.003-.95 2.395-1.407 4.219-1.407 1.677 0 2.764.341 3.219 1.063.473.705.463 1.992 0 3.844l-.25 1.093-2.375 9.407h5.53l2.345-9.407.53-2.125c.665-2.649.482-4.545-.562-5.719C61.984 7.188 59.99 6.5 57.22 6.5m70.594 0c-2.412 0-4.764.745-7.063 2.25l-5.094-1.719-2.344 9.375-2.343 9.407h5.531l2.25-9.063c.03-.116.064-.239.094-.344.577-2.16 1.343-3.7 2.281-4.593 1.004-.95 2.392-1.407 4.219-1.407 1.676 0 2.762.341 3.218 1.063.473.705.464 1.992 0 3.844l-.28 1.093-2.345 9.407h5.532l2.343-9.407.532-2.125c.663-2.649.48-4.545-.563-5.719-1.202-1.374-3.201-2.062-5.969-2.062m56.156 1.969-.656 3.062h3.093l-1.031 4.875-.656 3.25c-.143.68-.686 1.389-1.625 2.157-.276.227-.446.373-.469.437.344 1.076.956 2.126 1.813 3.094.066.017.432-.305 1.062-.938.94-.996 3.325-3.192 7.188-6.593a43 43 0 0 0-.625-1.344l-.032-.063-.562-1.25-.625.531c-.3.264-.59.507-.844.72a42 42 0 0 1-1.531 1.28l.281-1.28 1.656-7.938zM29.594 9.875c2.932 0 4.5 1.153 4.687 3.469H23.25a6.2 6.2 0 0 1 1.125-1.438c.446-.422.95-.799 1.5-1.094a7.3 7.3 0 0 1 1.781-.687 7.5 7.5 0 0 1 1.938-.25m71.062 0c2.933 0 4.503 1.153 4.688 3.469H94.313a6.4 6.4 0 0 1 1.124-1.438 7.3 7.3 0 0 1 1.5-1.094 7.4 7.4 0 0 1 1.782-.687 7.5 7.5 0 0 1 1.937-.25m60.719.938H163l-.719 3.468h-1.625zm11.969.437h.969c.128.371.307.741.53 1.094h-2.374l.437-.563zm-33.063.156-1.25 5-2.375 9.407h5.5l2.375-9.407 1.25-5zm29.688 3.406h3.906l-.312 1.594-.22.907h-2.687c.1-.335.19-.635.281-.907.104-.312.188-.578.282-.812-.131-.075-.211-.125-.281-.125h-1.625z" style="fill:#006cb6;fill-opacity:1;fill-rule:nonzero;stroke:none"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/vercel.svg b/assets/company-logos/vercel.svg
new file mode 100644
index 0000000..db6c816
--- /dev/null
+++ b/assets/company-logos/vercel.svg
@@ -0,0 +1 @@
+<svg fill="#f5f4f0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 284 65"><path d="M141.68 16.25c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99m-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5zm117.14-14.5c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99m-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5zm-39.03 3.5c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10m82.48-29v46h-9v-46zM37.59.25l36.95 64H.64zm92.38 5-27.71 48-27.71-48h10.39l17.32 30 17.32-30zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10v14.8h-9v-34h9v9.2c0-5.08 5.91-9.2 13.2-9.2"/></svg>
\ No newline at end of file
diff --git a/assets/company-logos/whop.svg b/assets/company-logos/whop.svg
new file mode 100644
index 0000000..e4ed7ce
--- /dev/null
+++ b/assets/company-logos/whop.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="2367" height="632" fill="none" viewBox="0 0 2367 632"><path fill="#f5f4f0" d="M1154.02 497.979h69.14l78.33-209.997h.87l77.89 209.997h70.02l77.02-304.975h-68.27l-52.07 210.868h-.88l-74.39-210.868h-59.07l-74.4 210.868h-.87l-51.64-210.868H1077zm392.88 0h64.77V374.682c0-33.547 25.38-56.638 63.01-56.638s60.39 21.348 60.39 56.638v123.297h64.76V365.968c0-62.301-44.63-105.434-111.15-105.434-30.63 0-56.88 10.021-76.14 28.319h-.87V186h-64.77zm406.91 6.535c76.58 0 130.84-53.153 130.84-121.99s-54.26-121.99-130.84-121.99c-76.14 0-130.84 53.153-130.84 121.99s54.7 121.99 130.84 121.99m0-56.638c-40.26 0-66.08-27.884-66.08-65.352s25.82-65.351 66.08-65.351 66.08 27.883 66.08 65.351-25.82 65.352-66.08 65.352M2108.82 584h64.76V480.116h.88c14.88 14.377 43.76 24.398 72.64 24.398 68.7 0 119.9-52.281 119.9-121.99 0-69.273-51.64-121.99-120.34-121.99-29.76 0-56.89 8.714-74.83 27.012h-.87V267.07h-62.14zm128.21-136.124c-35.88 0-63.45-23.091-63.45-50.974v-29.191c0-27.012 28.88-50.103 63.02-50.103 38.94 0 65.64 26.141 65.64 64.916s-27.14 65.352-65.21 65.352"/><path fill="#ff6243" fill-rule="evenodd" d="m276.62 160.782-138.31 138.31L.266 160.515c67.27-67.269 188.011-88.075 276.354.267m27.662 304.282 290.451-290.451c-70.904-70.904-179.331-97.29-283.565 6.945L165.971 326.754zm317.845 14.096 304.548-304.547c-64.161-64.161-184.032-92.588-290.186 13.566L331.941 492.726c69.155 69.155 189.201 87.42 290.186-13.566" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/assets/fonts/ibm-plex-sans-latin-400-normal.woff2 b/assets/fonts/ibm-plex-sans-latin-400-normal.woff2
new file mode 100644
index 0000000..f0ee65d
Binary files /dev/null and b/assets/fonts/ibm-plex-sans-latin-400-normal.woff2 differ
diff --git a/assets/fonts/ibm-plex-sans-latin-500-normal.woff2 b/assets/fonts/ibm-plex-sans-latin-500-normal.woff2
new file mode 100644
index 0000000..6d5527e
Binary files /dev/null and b/assets/fonts/ibm-plex-sans-latin-500-normal.woff2 differ
diff --git a/assets/fonts/iosevka-latin-400-italic.woff2 b/assets/fonts/iosevka-latin-400-italic.woff2
new file mode 100644
index 0000000..5643bc1
Binary files /dev/null and b/assets/fonts/iosevka-latin-400-italic.woff2 differ
diff --git a/assets/fonts/iosevka-latin-400-normal.woff2 b/assets/fonts/iosevka-latin-400-normal.woff2
new file mode 100644
index 0000000..6c9d06c
Binary files /dev/null and b/assets/fonts/iosevka-latin-400-normal.woff2 differ
diff --git a/assets/fonts/iosevka-latin-500-normal.woff2 b/assets/fonts/iosevka-latin-500-normal.woff2
new file mode 100644
index 0000000..e835d8d
Binary files /dev/null and b/assets/fonts/iosevka-latin-500-normal.woff2 differ
diff --git a/assets/fonts/iosevka-latin-600-normal.woff2 b/assets/fonts/iosevka-latin-600-normal.woff2
new file mode 100644
index 0000000..c399ad2
Binary files /dev/null and b/assets/fonts/iosevka-latin-600-normal.woff2 differ
diff --git a/example-bom.csv b/example-bom.csv
new file mode 100644
index 0000000..a20754b
--- /dev/null
+++ b/example-bom.csv
@@ -0,0 +1,6 @@
+Part Number,Description,Manufacturer,Quantity,Unit Price
+GRM188R71H104KA93D,0603 0.1µF Capacitor,Murata,10000,0.0180
+DIN 912,"M3×10 Hex Cap Screw, A2 SS",Bossard,5000,0.0450
+UL1007,"22 AWG Hookup Wire, 3m",Alpha Wire,1000,1.2000
+STM32F103C8T6,STM32F103C8T6 MCU LQFP-48,STMicroelectronics,500,3.8500
+RC0805FR-0710KL,"0805 10kΩ Resistor, 1%",Yageo,20000,0.0120
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..3e70b10
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+  "nodes": {
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1782959384,
+        "narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..ca0069a
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,154 @@
+{
+  description = "Spread — dev shell: Go, air live-reload, and a local Postgres";
+
+  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+
+  outputs = { self, nixpkgs }:
+    let
+      systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
+      forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
+    in
+    {
+      packages = forAll (pkgs: {
+        default = pkgs.buildGoModule {
+          pname = "spread";
+          version = "0.1.0";
+          src = ./.;
+          vendorHash = "sha256-YaOsSrH0pG+H+9ntjcyRjUgA6l0wgcg7PksrerMfyGg=";
+          subPackages = [ "." ];
+          ldflags = [ "-s" "-w" ];
+        };
+      });
+
+      # Import into a NixOS host and set services.spread.{domain,acmeEmail}.
+      nixosModules.default = { config, lib, pkgs, ... }:
+        let cfg = config.services.spread;
+        in {
+          options.services.spread = {
+            enable = lib.mkEnableOption "Spread BOM re-quoting app";
+            package = lib.mkOption {
+              type = lib.types.package;
+              default = self.packages.${pkgs.system}.default;
+              description = "The spread package to run.";
+            };
+            domain = lib.mkOption {
+              type = lib.types.str;
+              example = "spread.example.com";
+              description = "Public hostname served over HTTPS.";
+            };
+            acmeEmail = lib.mkOption {
+              type = lib.types.str;
+              description = "Contact email for Let's Encrypt.";
+            };
+            port = lib.mkOption {
+              type = lib.types.port;
+              default = 8137;
+            };
+            mailBackend = lib.mkOption {
+              type = lib.types.enum [ "log" "smtp" "sendgrid" ];
+              default = "log";
+            };
+            environmentFile = lib.mkOption {
+              type = lib.types.nullOr lib.types.path;
+              default = null;
+              description = "Secrets file (SMTP_*, SENDGRID_API_KEY, MAIL_FROM); kept out of the Nix store.";
+            };
+          };
+
+          config = lib.mkIf cfg.enable {
+            users.users.spread = { isSystemUser = true; group = "spread"; };
+            users.groups.spread = { };
+
+            services.postgresql = {
+              enable = true;
+              ensureDatabases = [ "spread" ];
+              ensureUsers = [{ name = "spread"; ensureDBOwnership = true; }];
+            };
+
+            systemd.services.spread = {
+              description = "Spread app";
+              after = [ "network.target" "postgresql.service" ];
+              wants = [ "postgresql.service" ];
+              wantedBy = [ "multi-user.target" ];
+              environment = {
+                PORT = toString cfg.port;
+                DATABASE_URL = "postgresql:///spread?host=/run/postgresql";
+                SPREAD_BASE_URL = "https://${cfg.domain}";
+                SPREAD_SECURE_COOKIES = "1";
+                MAIL_BACKEND = cfg.mailBackend;
+              };
+              serviceConfig = {
+                ExecStart = "${cfg.package}/bin/spread";
+                User = "spread";
+                Group = "spread";
+                Restart = "on-failure";
+                NoNewPrivileges = true;
+                ProtectSystem = "strict";
+                ProtectHome = true;
+                PrivateTmp = true;
+              } // lib.optionalAttrs (cfg.environmentFile != null) {
+                EnvironmentFile = cfg.environmentFile;
+              };
+            };
+
+            security.acme = {
+              acceptTerms = true;
+              defaults.email = cfg.acmeEmail;
+            };
+
+            services.nginx = {
+              enable = true;
+              recommendedProxySettings = true;
+              recommendedTlsSettings = true;
+              virtualHosts.${cfg.domain} = {
+                enableACME = true;
+                forceSSL = true;
+                locations."/".proxyPass = "http://127.0.0.1:${toString cfg.port}";
+              };
+            };
+
+            networking.firewall.allowedTCPPorts = [ 80 443 ];
+          };
+        };
+
+      devShells = forAll (pkgs: {
+        default = pkgs.mkShell {
+          packages = [ pkgs.go pkgs.air pkgs.postgresql_17 ];
+
+          # A throwaway Postgres lives under the project dir so `air` can run the
+          # full app (accounts) without touching any real database.
+          shellHook = ''
+            # Keep the dev Postgres OUT of the project tree — a unix socket in
+            # $PWD breaks `nix build`/`nix develop` (Nix can't copy a socket).
+            export SPREAD_STATE="''${XDG_STATE_HOME:-$HOME/.local/state}/spread"
+            export PGDATA="$SPREAD_STATE/pgdata"
+            export PGHOST="$SPREAD_STATE/sock"
+            export PGUSER=postgres
+            export DATABASE_URL="postgres://postgres@/spread?host=$PGHOST"
+            # Dev-forgiving: serve the public demo even if Postgres isn't running.
+            export SPREAD_DB_OPTIONAL=1
+            # Invite / password-reset emails print to the server log in dev.
+            export MAIL_BACKEND=log
+            export SPREAD_BASE_URL="http://localhost:8137"
+
+            pg-start() {
+              mkdir -p "$PGHOST"
+              [ -d "$PGDATA" ] || initdb -D "$PGDATA" -U postgres --auth=trust >/dev/null
+              pg_ctl -D "$PGDATA" -o "-k $PGHOST -c listen_addresses=" -w start
+              createdb spread 2>/dev/null && echo "created database 'spread'" || true
+            }
+            pg-stop() { pg_ctl -D "$PGDATA" stop -m fast; }
+            spread-admin() {
+              SPREAD_ADMIN_PASSWORD="''${2:-changeme123}" go run . init-admin "''${1:-admin@spread.test}"
+            }
+
+            echo "spread dev shell"
+            echo "  air           — run with live reload (rebuilds on .go/.html/.js/.sql change)"
+            echo "  pg-start      — start the local dev Postgres (needed for /login /admin /app)"
+            echo "  pg-stop       — stop it"
+            echo "  spread-admin <email> <password>  — create/reset the admin user"
+          '';
+        };
+      });
+    };
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..11ce438
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,16 @@
+module spread
+
+go 1.25.0
+
+require (
+	github.com/jackc/pgx/v5 v5.10.0
+	golang.org/x/crypto v0.53.0
+)
+
+require (
+	github.com/jackc/pgpassfile v1.0.0 // indirect
+	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+	github.com/jackc/puddle/v2 v2.2.2 // indirect
+	golang.org/x/sync v0.21.0 // indirect
+	golang.org/x/text v0.38.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..3c1b81c
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,28 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
+github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..53f1d01
--- /dev/null
+++ b/index.html
@@ -0,0 +1,794 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Spread — pay nothing unless you save</title>
+<style>
+  /* self-hosted, subset — no external font CDN */
+  @font-face { font-family:'Iosevka'; font-style:normal; font-weight:400; font-display:swap; src:url('/assets/fonts/iosevka-latin-400-normal.woff2') format('woff2'); }
+  @font-face { font-family:'Iosevka'; font-style:normal; font-weight:500; font-display:swap; src:url('/assets/fonts/iosevka-latin-500-normal.woff2') format('woff2'); }
+  @font-face { font-family:'Iosevka'; font-style:normal; font-weight:600; font-display:swap; src:url('/assets/fonts/iosevka-latin-600-normal.woff2') format('woff2'); }
+  @font-face { font-family:'Iosevka'; font-style:italic; font-weight:400; font-display:swap; src:url('/assets/fonts/iosevka-latin-400-italic.woff2') format('woff2'); }
+  @font-face { font-family:'IBM Plex Sans'; font-style:normal; font-weight:400; font-display:swap; src:url('/assets/fonts/ibm-plex-sans-latin-400-normal.woff2') format('woff2'); }
+  @font-face { font-family:'IBM Plex Sans'; font-style:normal; font-weight:500; font-display:swap; src:url('/assets/fonts/ibm-plex-sans-latin-500-normal.woff2') format('woff2'); }
+
+  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+  :root {
+    --bg: #0a0a0c;
+    --surface: #131316;
+    --surface-raised: #18181c;
+    --surface-highlight: #1e1e22;
+    --border: #2a2a2e;
+    --border-strong: #3a3a40;
+    --text: #f5f4f0;
+    --text-secondary: #a8a6a0;
+    --text-tertiary: #6a6864;
+    --green: #5dd39e;
+    --green-dim: #1f3a2c;
+    --green-bg: #11201a;
+    --red: #ff8a7a;
+    --red-bg: #2a1410;
+    --serif: 'Iosevka', ui-monospace, monospace;
+    --sans: 'IBM Plex Sans', system-ui, sans-serif;
+    --mono: 'Iosevka', ui-monospace, monospace;
+  }
+
+  html, body { background: var(--bg); color: var(--text); }
+  body {
+    font-family: var(--sans);
+    min-height: 100vh;
+    -webkit-font-smoothing: antialiased;
+    line-height: 1.6;
+  }
+
+  a { color: inherit; text-decoration: none; }
+
+  /* NAV */
+  nav {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 1.25rem 2.5rem;
+    border-bottom: 0.5px solid var(--border);
+    position: sticky;
+    top: 0;
+    background: rgba(10, 10, 12, 0.85);
+    backdrop-filter: blur(10px);
+    z-index: 10;
+  }
+  .nav-logo {
+    display: flex;
+    align-items: baseline;
+    gap: 8px;
+    font-family: var(--serif);
+    font-size: 22px;
+    font-weight: 500;
+    letter-spacing: -0.5px;
+  }
+  .nav-logo .dot { color: var(--green); font-size: 14px; }
+  .nav-links { display: flex; align-items: center; gap: 2.5rem; }
+  .nav-links a {
+    font-size: 13px;
+    color: var(--text-secondary);
+    transition: color 0.15s;
+  }
+  .nav-links a:hover { color: var(--text); }
+  .nav-cta {
+    font-family: var(--sans);
+    font-size: 13px;
+    padding: 8px 18px;
+    background: var(--text);
+    color: var(--bg);
+    border: none;
+    border-radius: 100px;
+    cursor: pointer;
+    font-weight: 500;
+  }
+
+  /* HERO */
+  .hero {
+    max-width: 900px;
+    margin: 0 auto;
+    padding: 6rem 2.5rem 3rem;
+    text-align: center;
+  }
+  .hero-eyebrow {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    font-family: var(--mono);
+    font-size: 11px;
+    text-transform: uppercase;
+    letter-spacing: 2px;
+    color: var(--text-tertiary);
+    margin-bottom: 2rem;
+    padding: 5px 14px 5px 12px;
+    border: 0.5px solid var(--border);
+    border-radius: 100px;
+  }
+  .hero-eyebrow::before {
+    content: '';
+    width: 6px; height: 6px;
+    background: var(--green);
+    border-radius: 50%;
+    box-shadow: 0 0 10px var(--green);
+  }
+  h1 {
+    font-family: var(--serif);
+    font-size: 60px;
+    font-weight: 600;
+    line-height: 1.05;
+    letter-spacing: -2px;
+    margin-bottom: 1.5rem;
+    color: var(--text);
+  }
+  h1 .crossed {
+    position: relative;
+    color: var(--text-tertiary);
+  }
+  h1 .crossed::after {
+    content: '';
+    position: absolute;
+    left: -4px;
+    right: -4px;
+    top: 52%;
+    height: 3px;
+    background: var(--red);
+    transform: rotate(-3deg);
+    border-radius: 2px;
+  }
+  h1 em {
+    font-style: italic;
+    color: var(--green);
+  }
+  .hero-sub {
+    font-family: var(--serif);
+    font-size: 22px;
+    font-weight: 400;
+    color: var(--text-secondary);
+    line-height: 1.4;
+    max-width: 600px;
+    margin: 0 auto 2.5rem;
+    font-style: italic;
+    letter-spacing: -0.3px;
+  }
+  .hero-actions {
+    display: flex;
+    gap: 12px;
+    justify-content: center;
+    flex-wrap: wrap;
+    margin-bottom: 3rem;
+  }
+  .btn-primary {
+    font-family: var(--sans);
+    font-size: 14px;
+    padding: 13px 28px;
+    background: var(--green);
+    color: #061a12;
+    border: none;
+    border-radius: 100px;
+    cursor: pointer;
+    font-weight: 500;
+  }
+  .btn-secondary {
+    font-family: var(--sans);
+    font-size: 14px;
+    padding: 13px 28px;
+    background: transparent;
+    color: var(--text);
+    border: 0.5px solid var(--border-strong);
+    border-radius: 100px;
+    cursor: pointer;
+  }
+  .hero-trust {
+    font-family: var(--mono);
+    font-size: 11px;
+    text-transform: uppercase;
+    letter-spacing: 2px;
+    color: var(--text-tertiary);
+  }
+  .hero-trust .green { color: var(--green); }
+
+  /* SECTIONS */
+  .section {
+    max-width: 900px;
+    margin: 0 auto;
+    padding: 4rem 2.5rem;
+    border-top: 0.5px solid var(--border);
+  }
+  .section-label {
+    font-family: var(--mono);
+    font-size: 11px;
+    text-transform: uppercase;
+    letter-spacing: 2px;
+    color: var(--text-tertiary);
+    margin-bottom: 0.75rem;
+    text-align: center;
+  }
+  .section-headline {
+    font-family: var(--serif);
+    font-size: 34px;
+    font-weight: 600;
+    letter-spacing: -0.5px;
+    margin-bottom: 3rem;
+    text-align: center;
+    line-height: 1.15;
+  }
+  .section-headline em {
+    font-style: italic;
+    color: var(--green);
+  }
+
+  /* STEPS */
+  .steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
+  .step {
+    background: var(--surface-raised);
+    border: 0.5px solid var(--border);
+    border-radius: 12px;
+    padding: 1.75rem;
+    position: relative;
+  }
+  .step-num {
+    font-family: var(--serif);
+    font-size: 40px;
+    font-weight: 600;
+    color: var(--green);
+    line-height: 1;
+    margin-bottom: 1rem;
+    letter-spacing: -1px;
+  }
+  .step h3 {
+    font-family: var(--sans);
+    font-size: 17px;
+    font-weight: 500;
+    color: var(--text);
+    margin-bottom: 8px;
+    letter-spacing: -0.2px;
+  }
+  .step p {
+    font-size: 14px;
+    color: var(--text-secondary);
+    line-height: 1.55;
+  }
+
+  /* UPLOAD / DROPZONE */
+  .dropzone {
+    border: 1px dashed var(--border-strong);
+    border-radius: 12px;
+    padding: 1.5rem;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 1rem;
+    margin-bottom: 14px;
+    background: var(--surface);
+    transition: border-color 0.15s, background 0.15s;
+    flex-wrap: wrap;
+  }
+  .dropzone.drag { border-color: var(--green); background: var(--green-bg); }
+  .dropzone .dz-copy { font-size: 13.5px; color: var(--text-secondary); }
+  .dropzone .dz-copy b { color: var(--text); font-weight: 500; }
+  .dropzone .dz-copy .hint { display: block; font-family: var(--mono); font-size: 11px; color: var(--text-tertiary); margin-top: 3px; }
+  .dropzone .dz-actions { display: flex; gap: 10px; flex-wrap: wrap; }
+  .dz-link {
+    font-family: var(--mono);
+    font-size: 11px;
+    color: var(--text-tertiary);
+    text-transform: uppercase;
+    letter-spacing: 1px;
+    align-self: center;
+    border-bottom: 0.5px solid var(--border-strong);
+    cursor: pointer;
+  }
+  .dz-link:hover { color: var(--text-secondary); }
+  .banner {
+    border-radius: 10px;
+    padding: 10px 14px;
+    margin-bottom: 14px;
+    font-size: 13px;
+    display: none;
+  }
+  .banner.error { display: block; background: var(--red-bg); color: var(--red); border: 0.5px solid #4a2018; }
+
+  /* SUPPLIER MARQUEE */
+  .marquee-wrap { max-width: 1100px; margin: 0 auto; padding: 0.5rem 0 3.5rem; }
+  .marquee-label {
+    font-family: var(--mono);
+    font-size: 11px;
+    text-transform: uppercase;
+    letter-spacing: 2px;
+    color: var(--text-tertiary);
+    text-align: center;
+    margin-bottom: 1.5rem;
+  }
+  .logo-marquee {
+    position: relative;
+    overflow: hidden;
+    -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
+            mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
+  }
+  .logo-track {
+    display: flex;
+    width: max-content;
+    animation: logo-scroll 46s linear infinite;
+  }
+  .logo-marquee:hover .logo-track { animation-play-state: paused; }
+  .logo-row { display: flex; align-items: center; }
+  .logo-card {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin: 0 7px;
+    height: 54px;
+    padding: 0 26px;
+    background: transparent;
+  }
+  .logo-card img {
+    height: 22px;
+    width: auto;
+    max-width: 128px;
+    object-fit: contain;
+    display: block;
+    /* full brand color on a transparent chip; the few black-ink marks
+       (Vercel, Automattic, Sentry, AWS, NVIDIA, Whop) are recolored to
+       near-white in their SVGs so nothing vanishes on the dark page */
+    opacity: 0.85;
+    transition: opacity 0.2s;
+  }
+  .logo-card:hover img { opacity: 1; }
+  @keyframes logo-scroll {
+    from { transform: translateX(0); }
+    to { transform: translateX(-50%); }
+  }
+  @media (prefers-reduced-motion: reduce) {
+    .logo-track { animation: none; flex-wrap: wrap; justify-content: center; }
+    .logo-marquee { -webkit-mask-image: none; mask-image: none; }
+    .logo-row[aria-hidden="true"] { display: none; }
+  }
+
+  /* BOM TABLE */
+  .bom-wrapper {
+    background: var(--surface-raised);
+    border: 0.5px solid var(--border);
+    border-radius: 12px;
+    overflow: hidden;
+    transition: opacity 0.2s;
+  }
+  .bom-wrapper.busy { opacity: 0.45; pointer-events: none; }
+  .bom-header {
+    padding: 1rem 1.4rem;
+    border-bottom: 0.5px solid var(--border);
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    background: var(--surface-highlight);
+  }
+  .bom-header .title {
+    font-family: var(--mono);
+    font-size: 12px;
+    color: var(--text-secondary);
+  }
+  .bom-header .title .dim { color: var(--text-tertiary); }
+  .bom-header .meta {
+    font-family: var(--mono);
+    font-size: 11px;
+    color: var(--text-tertiary);
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+  .bom-header .meta::before {
+    content: '';
+    width: 7px; height: 7px;
+    background: var(--green);
+    border-radius: 50%;
+    box-shadow: 0 0 8px var(--green);
+  }
+  table {
+    width: 100%;
+    border-collapse: collapse;
+    font-family: var(--mono);
+    font-size: 12.5px;
+  }
+  thead th {
+    text-align: left;
+    padding: 12px 14px;
+    font-family: var(--mono);
+    font-size: 10px;
+    text-transform: uppercase;
+    letter-spacing: 1.5px;
+    color: var(--text-tertiary);
+    font-weight: 500;
+    border-bottom: 0.5px solid var(--border);
+    background: var(--surface);
+  }
+  thead th.num { text-align: right; }
+  tbody td {
+    padding: 14px 14px;
+    border-bottom: 0.5px solid var(--border);
+    color: var(--text-secondary);
+  }
+  tbody td.part {
+    color: var(--text);
+    font-family: var(--sans);
+    font-size: 13.5px;
+  }
+  tbody td.part .pn {
+    display: block;
+    font-family: var(--mono);
+    font-size: 11px;
+    color: var(--text-tertiary);
+    margin-top: 2px;
+  }
+  tbody td.part .pn .est {
+    color: var(--text-tertiary);
+    opacity: 0.7;
+  }
+  tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
+  tbody td.market {
+    color: var(--text-tertiary);
+    text-decoration: line-through;
+    text-decoration-color: var(--text-tertiary);
+  }
+  tbody td.ours { color: var(--text); font-weight: 500; }
+  tbody td.save { color: var(--green); font-weight: 500; }
+  tbody td.save.zero { color: var(--text-tertiary); font-weight: 400; }
+  tbody tr:last-child td { border-bottom: none; }
+  .bom-totals {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    border-top: 0.5px solid var(--border);
+    background: var(--surface);
+  }
+  .bom-totals .cell {
+    padding: 1.25rem 1.4rem;
+    border-right: 0.5px solid var(--border);
+  }
+  .bom-totals .cell:last-child { border-right: none; }
+  .bom-totals .cell .label {
+    font-family: var(--mono);
+    font-size: 10px;
+    text-transform: uppercase;
+    letter-spacing: 1.5px;
+    color: var(--text-tertiary);
+    margin-bottom: 6px;
+  }
+  .bom-totals .cell .value {
+    font-family: var(--serif);
+    font-size: 24px;
+    font-weight: 500;
+    letter-spacing: -0.5px;
+    font-variant-numeric: tabular-nums;
+  }
+  .bom-totals .cell.savings .value { color: var(--green); }
+  .bom-totals .cell .sub {
+    font-family: var(--mono);
+    font-size: 11px;
+    color: var(--text-tertiary);
+    margin-top: 2px;
+  }
+
+  /* PRICING */
+  .pricing-card {
+    background: linear-gradient(180deg, var(--green-bg) 0%, var(--surface-raised) 80%);
+    border: 0.5px solid var(--green-dim);
+    border-radius: 16px;
+    padding: 3rem 2.5rem;
+    text-align: center;
+  }
+  .pricing-card .big {
+    font-family: var(--serif);
+    font-size: 84px;
+    font-weight: 600;
+    color: var(--green);
+    line-height: 1;
+    letter-spacing: -3px;
+    margin-bottom: 0.5rem;
+  }
+  .pricing-card .big-sub {
+    font-family: var(--serif);
+    font-size: 26px;
+    font-style: italic;
+    color: var(--text);
+    margin-bottom: 1.5rem;
+    letter-spacing: -0.5px;
+  }
+  .pricing-card .fine {
+    font-family: var(--sans);
+    font-size: 15px;
+    color: var(--text-secondary);
+    max-width: 500px;
+    margin: 0 auto;
+    line-height: 1.55;
+  }
+
+  /* PHILOSOPHY */
+  .philosophy {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 14px;
+  }
+  .principle {
+    background: var(--surface-raised);
+    border: 0.5px solid var(--border);
+    border-radius: 12px;
+    padding: 1.5rem;
+  }
+  .principle .mark {
+    font-family: var(--serif);
+    font-size: 18px;
+    font-weight: 500;
+    color: var(--green);
+    margin-bottom: 0.5rem;
+    letter-spacing: -0.3px;
+    font-style: italic;
+  }
+  .principle h4 {
+    font-family: var(--sans);
+    font-size: 15px;
+    font-weight: 500;
+    margin-bottom: 6px;
+  }
+  .principle p {
+    font-size: 13.5px;
+    color: var(--text-secondary);
+    line-height: 1.55;
+  }
+
+  /* FOOTER */
+  footer {
+    max-width: 900px;
+    margin: 3rem auto 0;
+    padding: 4rem 2.5rem;
+    border-top: 0.5px solid var(--border);
+    text-align: center;
+  }
+  footer h2 {
+    font-family: var(--serif);
+    font-size: 40px;
+    font-weight: 600;
+    letter-spacing: -1px;
+    margin-bottom: 1rem;
+    line-height: 1.1;
+  }
+  footer h2 em { font-style: italic; color: var(--green); }
+  footer p {
+    font-size: 16px;
+    color: var(--text-secondary);
+    margin-bottom: 2rem;
+    max-width: 480px;
+    margin-left: auto;
+    margin-right: auto;
+  }
+  .footer-meta {
+    margin-top: 4rem;
+    padding-top: 2rem;
+    border-top: 0.5px solid var(--border);
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    font-family: var(--mono);
+    font-size: 11px;
+    color: var(--text-tertiary);
+  }
+
+  @media (max-width: 760px) {
+    h1 { font-size: 34px; letter-spacing: -1px; }
+    .hero-sub { font-size: 18px; }
+    .section-headline { font-size: 26px; }
+    .steps, .philosophy { grid-template-columns: 1fr; }
+    .pricing-card .big { font-size: 60px; }
+    .bom-totals { grid-template-columns: 1fr; }
+    .bom-totals .cell { border-right: none; border-bottom: 0.5px solid var(--border); }
+    .bom-totals .cell:last-child { border-bottom: none; }
+    nav { padding: 1rem 1.25rem; }
+    .nav-links { display: none; }
+    .hero, .section, footer { padding-left: 1.25rem; padding-right: 1.25rem; }
+    footer h2 { font-size: 30px; }
+    .dropzone { flex-direction: column; align-items: flex-start; }
+    /* hide some columns on mobile */
+    table thead th:nth-child(3),
+    table tbody td:nth-child(3) { display: none; }
+  }
+</style>
+</head>
+<body>
+
+<nav>
+  <div class="nav-logo">Spread<span class="dot">●</span></div>
+  <div class="nav-links">
+    <a href="#how">How it works</a>
+    <a href="#example">Example</a>
+    <a href="#pricing">Pricing</a>
+  </div>
+  <button class="nav-cta" data-upload>Upload a BOM →</button>
+</nav>
+
+<section class="hero">
+  <div class="hero-eyebrow">For manufacturers · By a parts guy</div>
+  <h1>Not <span class="crossed">SaaS</span>.<br>Just <em>software</em>.</h1>
+  <p class="hero-sub">If you don't save money,<br>we don't earn any.</p>
+  <div class="hero-actions">
+    <button class="btn-primary" data-upload>Upload your BOM →</button>
+    <button class="btn-secondary" data-example>See an example</button>
+  </div>
+  <div class="hero-trust">
+    No subscription &nbsp;·&nbsp; No setup fee &nbsp;·&nbsp; <span class="green">20%</span> of savings — that's it
+  </div>
+</section>
+
+<div class="marquee-wrap">
+  <div class="marquee-label">Trusted by teams at</div>
+  <div class="logo-marquee" aria-label="Company logos">
+    <div class="logo-track">
+      <div class="logo-row">
+        <div class="logo-card"><img src="/assets/company-logos/jetbrains.svg" alt="JetBrains" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/docker.svg" alt="Docker" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/vercel.svg" alt="Vercel" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/sentry.svg" alt="Sentry" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/google.svg" alt="Google" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/nvidia.svg" alt="NVIDIA" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/aws.svg" alt="AWS" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/bytedance.svg" alt="ByteDance" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/tencent.svg" alt="Tencent" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/alibaba.svg" alt="Alibaba" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/salesforce.svg" alt="Salesforce" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/ibm.svg" alt="IBM" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/atlassian.svg" alt="Atlassian" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/whop.svg" alt="Whop" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/automattic.svg" alt="Automattic" loading="lazy"></div>
+      </div>
+      <div class="logo-row" aria-hidden="true">
+        <div class="logo-card"><img src="/assets/company-logos/jetbrains.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/docker.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/vercel.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/sentry.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/google.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/nvidia.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/aws.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/bytedance.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/tencent.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/alibaba.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/salesforce.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/ibm.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/atlassian.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/whop.svg" alt="" loading="lazy"></div>
+        <div class="logo-card"><img src="/assets/company-logos/automattic.svg" alt="" loading="lazy"></div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<section class="section" id="how">
+  <div class="section-label">How it works</div>
+  <h2 class="section-headline">Three steps. <em>That's the whole thing.</em></h2>
+  <div class="steps">
+    <div class="step">
+      <div class="step-num">01</div>
+      <h3>Upload your BOM</h3>
+      <p>Drop in a CSV or Excel file of your bill of materials. Any format your engineers already use.</p>
+    </div>
+    <div class="step">
+      <div class="step-num">02</div>
+      <h3>We re-quote every line</h3>
+      <p>Each part is cross-referenced against our supplier network. You see the market price, our price, and what you save — line by line.</p>
+    </div>
+    <div class="step">
+      <div class="step-num">03</div>
+      <h3>You only pay if you save</h3>
+      <p>If the total savings is zero, our invoice is zero. If you save, we keep 20% of what you saved. Nothing else.</p>
+    </div>
+  </div>
+</section>
+
+<section class="section" id="example">
+  <div class="section-label">Live example</div>
+  <h2 class="section-headline">Drop in a BOM. <em>See it re-quoted.</em></h2>
+
+  <div class="dropzone" id="dropzone">
+    <div class="dz-copy">
+      <b>Drop a BOM here</b> or choose a file to re-quote it live.
+      <span class="hint">.csv or .xlsx &nbsp;·&nbsp; columns: part number, description, qty, unit price</span>
+    </div>
+    <div class="dz-actions">
+      <button class="btn-primary" data-upload>Choose file →</button>
+      <button class="btn-secondary" data-example>Try the example</button>
+    </div>
+  </div>
+
+  <div class="banner error" id="error-banner"></div>
+
+  <div class="bom-wrapper" id="bom-wrapper">
+    <div class="bom-header">
+      <div class="title" id="bom-title">example-bom &nbsp;<span class="dim">· 5 parts</span></div>
+      <div class="meta" id="bom-meta">analysis complete</div>
+    </div>
+
+    <table>
+      <thead>
+        <tr>
+          <th>Part</th>
+          <th class="num">Qty</th>
+          <th class="num">Market</th>
+          <th class="num">Spread price</th>
+          <th class="num">You save</th>
+        </tr>
+      </thead>
+      <tbody id="bom-body">
+        <tr><td class="part" colspan="5" style="color:var(--text-tertiary)">Loading example…</td></tr>
+      </tbody>
+    </table>
+
+    <div class="bom-totals">
+      <div class="cell">
+        <div class="label">Total savings</div>
+        <div class="value" id="t-savings">—</div>
+        <div class="sub">vs current market</div>
+      </div>
+      <div class="cell">
+        <div class="label">Our cut (20%)</div>
+        <div class="value" id="t-cut">—</div>
+        <div class="sub">invoiced on delivery</div>
+      </div>
+      <div class="cell savings">
+        <div class="label">Your net savings</div>
+        <div class="value" id="t-net">—</div>
+        <div class="sub">cash in your pocket</div>
+      </div>
+    </div>
+  </div>
+
+  <div style="text-align:center;margin-top:14px">
+    <a class="dz-link" href="/example-bom.csv" download>Download the example BOM (.csv)</a>
+  </div>
+</section>
+
+<section class="section" id="pricing">
+  <div class="section-label">Pricing</div>
+  <h2 class="section-headline">One line. <em>No fine print.</em></h2>
+  <div class="pricing-card">
+    <div class="big">20%</div>
+    <div class="big-sub">of what you save. <span style="color: var(--text-tertiary);">Otherwise $0.</span></div>
+    <p class="fine">No monthly fee. No seat license. No setup cost. No usage tier. If the BOM you upload can't be sourced cheaper than what you're paying today, your invoice is zero and we move on. Every dollar Spread bills is a dollar you'd never have seen otherwise.</p>
+  </div>
+</section>
+
+<section class="section">
+  <div class="section-label">Why Spread</div>
+  <h2 class="section-headline">Aligned on one thing. <em>Your bottom line.</em></h2>
+  <div class="philosophy">
+    <div class="principle">
+      <div class="mark">No subscription</div>
+      <h4>We don't bill if you don't save</h4>
+      <p>Every other procurement tool charges a seat license whether it works or not. Ours only earns when you do.</p>
+    </div>
+    <div class="principle">
+      <div class="mark">Transparent margin</div>
+      <h4>You see what we keep</h4>
+      <p>Our cut is printed on every line. No hidden markup, no mystery quote, no broker games. Just numbers.</p>
+    </div>
+    <div class="principle">
+      <div class="mark">Real suppliers</div>
+      <h4>Quotes we can actually honor</h4>
+      <p>Every price in the system comes from suppliers we have accounts with. If we quote it, we can deliver it.</p>
+    </div>
+  </div>
+</section>
+
+<footer>
+  <h2>Spend ten minutes.<br>See what <em>your BOM</em> is hiding.</h2>
+  <p>Upload your bill of materials. We re-quote it against our supplier network on the spot — line by line, no commitment.</p>
+  <button class="btn-primary" style="font-size: 15px; padding: 14px 32px;" data-upload>Upload your BOM →</button>
+
+  <div class="footer-meta">
+    <span>Spread · made for makers</span>
+    <span>not SaaS · just software</span>
+  </div>
+</footer>
+
+<input type="file" id="file-input" accept=".csv,.tsv,.txt,.xlsx" hidden>
+<script src="app.js"></script>
+</body>
+</html>
diff --git a/internal/app/boms.go b/internal/app/boms.go
new file mode 100644
index 0000000..af19cad
--- /dev/null
+++ b/internal/app/boms.go
@@ -0,0 +1,144 @@
+package app
+
+import (
+	"context"
+
+	"spread/internal/spread"
+)
+
+// BOMSummary is one saved bill of materials, for listings.
+type BOMSummary struct {
+	ID              int64
+	Ref             string
+	SubscriberID    int64
+	SubscriberName  string
+	UploadedByEmail string
+	Filename        string
+	TotalSavings    float64
+	OurCut          float64
+	NetSavings      float64
+	Lines           int
+	CreatedAt       string
+}
+
+// BOMLine is one re-quoted line of a saved BOM.
+type BOMLine struct {
+	Line        int
+	PartNumber  string
+	Description string
+	Quantity    float64
+	UnitPrice   float64
+	SpreadPrice float64
+	LineSaving  float64
+	Matched     bool
+}
+
+// BOMDetail is a saved BOM plus its lines, for the detail view.
+type BOMDetail struct {
+	BOMSummary
+	Items []BOMLine
+}
+
+// saveBOM persists an analyzed report under a subscriber.
+func (a *App) saveBOM(ctx context.Context, subscriberID, uploadedBy int64, rep spread.Report, filename string) (int64, error) {
+	tx, err := a.pool.Begin(ctx)
+	if err != nil {
+		return 0, err
+	}
+	defer tx.Rollback(ctx)
+
+	var id int64
+	err = tx.QueryRow(ctx,
+		`INSERT INTO boms (subscriber_id, uploaded_by, ref, filename, total_savings, our_cut, net_savings)
+		 VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
+		subscriberID, uploadedBy, rep.BOMRef, filename,
+		rep.Totals.Savings, rep.Totals.OurCut, rep.Totals.NetSavings,
+	).Scan(&id)
+	if err != nil {
+		return 0, err
+	}
+
+	for _, l := range rep.Lines {
+		if _, err := tx.Exec(ctx,
+			`INSERT INTO bom_lines
+			   (bom_id, line, part_number, description, quantity, unit_price, spread_price, line_saving, matched)
+			 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
+			id, l.Line, l.PartNumber, l.Description, l.Quantity, l.UnitPrice, l.SpreadPrice, l.LineSaving, l.Matched,
+		); err != nil {
+			return 0, err
+		}
+	}
+	return id, tx.Commit(ctx)
+}
+
+const bomSelect = `
+	SELECT b.id, b.ref, b.subscriber_id, s.name, coalesce(u.email, ''), b.filename,
+	       b.total_savings, b.our_cut, b.net_savings,
+	       (SELECT count(*) FROM bom_lines l WHERE l.bom_id = b.id),
+	       to_char(b.created_at, 'YYYY-MM-DD HH24:MI')
+	  FROM boms b
+	  JOIN subscribers s ON s.id = b.subscriber_id
+	  LEFT JOIN users u ON u.id = b.uploaded_by`
+
+func scanSummary(row interface {
+	Scan(...any) error
+}) (BOMSummary, error) {
+	var b BOMSummary
+	err := row.Scan(&b.ID, &b.Ref, &b.SubscriberID, &b.SubscriberName, &b.UploadedByEmail,
+		&b.Filename, &b.TotalSavings, &b.OurCut, &b.NetSavings, &b.Lines, &b.CreatedAt)
+	return b, err
+}
+
+// listBOMs returns saved BOMs, newest first. A nil subscriberID lists every
+// subscriber's BOMs (admin review); otherwise only that subscriber's.
+func (a *App) listBOMs(ctx context.Context, subscriberID *int64) ([]BOMSummary, error) {
+	q := bomSelect
+	args := []any{}
+	if subscriberID != nil {
+		q += ` WHERE b.subscriber_id = $1`
+		args = append(args, *subscriberID)
+	}
+	q += ` ORDER BY b.created_at DESC, b.id DESC`
+
+	rows, err := a.pool.Query(ctx, q, args...)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var out []BOMSummary
+	for rows.Next() {
+		b, err := scanSummary(rows)
+		if err != nil {
+			return nil, err
+		}
+		out = append(out, b)
+	}
+	return out, rows.Err()
+}
+
+func (a *App) getBOM(ctx context.Context, id int64) (BOMDetail, error) {
+	var d BOMDetail
+	sum, err := scanSummary(a.pool.QueryRow(ctx, bomSelect+` WHERE b.id = $1`, id))
+	if err != nil {
+		return d, err
+	}
+	d.BOMSummary = sum
+
+	rows, err := a.pool.Query(ctx,
+		`SELECT line, part_number, description, quantity, unit_price, spread_price, line_saving, matched
+		   FROM bom_lines WHERE bom_id = $1 ORDER BY line`, id)
+	if err != nil {
+		return d, err
+	}
+	defer rows.Close()
+	for rows.Next() {
+		var l BOMLine
+		if err := rows.Scan(&l.Line, &l.PartNumber, &l.Description, &l.Quantity,
+			&l.UnitPrice, &l.SpreadPrice, &l.LineSaving, &l.Matched); err != nil {
+			return d, err
+		}
+		d.Items = append(d.Items, l)
+	}
+	return d, rows.Err()
+}
diff --git a/internal/app/catalog.go b/internal/app/catalog.go
new file mode 100644
index 0000000..52f52b8
--- /dev/null
+++ b/internal/app/catalog.go
@@ -0,0 +1,115 @@
+package app
+
+import (
+	"context"
+	"time"
+
+	"spread/internal/spread"
+)
+
+// CatalogEntry is one admin-managed supplier price.
+type CatalogEntry struct {
+	PartNumber  string
+	Description string
+	SpreadPrice float64
+	UpdatedAt   time.Time
+}
+
+func (a *App) listCatalog(ctx context.Context) ([]CatalogEntry, error) {
+	rows, err := a.pool.Query(ctx,
+		`SELECT part_number, description, spread_price, updated_at
+		   FROM catalog ORDER BY part_number`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var out []CatalogEntry
+	for rows.Next() {
+		var e CatalogEntry
+		if err := rows.Scan(&e.PartNumber, &e.Description, &e.SpreadPrice, &e.UpdatedAt); err != nil {
+			return nil, err
+		}
+		out = append(out, e)
+	}
+	return out, rows.Err()
+}
+
+func (a *App) getCatalogEntry(ctx context.Context, pn string) (CatalogEntry, error) {
+	var e CatalogEntry
+	err := a.pool.QueryRow(ctx,
+		`SELECT part_number, description, spread_price, updated_at
+		   FROM catalog WHERE part_number = $1`, spread.NormalizePN(pn),
+	).Scan(&e.PartNumber, &e.Description, &e.SpreadPrice, &e.UpdatedAt)
+	return e, err
+}
+
+func (a *App) upsertCatalog(ctx context.Context, pn, desc string, price float64) error {
+	_, err := a.pool.Exec(ctx,
+		`INSERT INTO catalog (part_number, description, spread_price, updated_at)
+		 VALUES ($1, $2, $3, now())
+		 ON CONFLICT (part_number)
+		 DO UPDATE SET description = EXCLUDED.description,
+		               spread_price = EXCLUDED.spread_price,
+		               updated_at = now()`,
+		spread.NormalizePN(pn), desc, price)
+	return err
+}
+
+func (a *App) deleteCatalog(ctx context.Context, pn string) error {
+	_, err := a.pool.Exec(ctx, `DELETE FROM catalog WHERE part_number = $1`, spread.NormalizePN(pn))
+	return err
+}
+
+func (a *App) catalogCount(ctx context.Context) (int, error) {
+	var n int
+	err := a.pool.QueryRow(ctx, `SELECT count(*) FROM catalog`).Scan(&n)
+	return n, err
+}
+
+// catalogSource loads the whole catalog into memory as a spread.Catalog so a
+// BOM can be re-quoted with a single query instead of one per line.
+func (a *App) catalogSource(ctx context.Context) (spread.Catalog, error) {
+	rows, err := a.pool.Query(ctx, `SELECT part_number, spread_price FROM catalog`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	m := mapCatalog{}
+	for rows.Next() {
+		var pn string
+		var price float64
+		if err := rows.Scan(&pn, &price); err != nil {
+			return nil, err
+		}
+		m[pn] = price
+	}
+	return m, rows.Err()
+}
+
+type mapCatalog map[string]float64
+
+func (m mapCatalog) Lookup(pn string) (float64, bool) {
+	p, ok := m[spread.NormalizePN(pn)]
+	return p, ok
+}
+
+// demoCatalog mirrors the five parts on the landing page so a fresh admin can
+// seed a working catalog with one click.
+var demoCatalog = []CatalogEntry{
+	{"GRM188R71H104KA93D", "0603 0.1µF Capacitor", 0.0142, time.Time{}},
+	{"DIN 912", "M3×10 Hex Cap Screw, A2 SS", 0.0335, time.Time{}},
+	{"UL1007", "22 AWG Hookup Wire, 3m", 0.9450, time.Time{}},
+	{"STM32F103C8T6", "STM32F103C8T6 MCU LQFP-48", 2.9200, time.Time{}},
+	{"RC0805FR-0710KL", "0805 10kΩ Resistor, 1%", 0.0086, time.Time{}},
+}
+
+func (a *App) seedDemoCatalog(ctx context.Context) error {
+	for _, e := range demoCatalog {
+		if err := a.upsertCatalog(ctx, e.PartNumber, e.Description, e.SpreadPrice); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/internal/app/csrf.go b/internal/app/csrf.go
new file mode 100644
index 0000000..22f2b80
--- /dev/null
+++ b/internal/app/csrf.go
@@ -0,0 +1,48 @@
+package app
+
+import (
+	"context"
+	"crypto/subtle"
+	"net/http"
+)
+
+const csrfCookie = "spread_csrf"
+
+// csrf implements double-submit-cookie CSRF protection: a random token is kept
+// in an httponly cookie and echoed into every form; unsafe requests must send a
+// matching csrf_token field. Combined with the SameSite=Lax session/csrf
+// cookies, this blocks cross-site state changes.
+func (a *App) csrf(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		tok := ""
+		if c, err := r.Cookie(csrfCookie); err == nil {
+			tok = c.Value
+		}
+		if tok == "" {
+			tok = randomToken()
+			http.SetCookie(w, &http.Cookie{
+				Name:     csrfCookie,
+				Value:    tok,
+				Path:     "/",
+				HttpOnly: true,
+				Secure:   a.secureCookie,
+				SameSite: http.SameSiteLaxMode,
+			})
+		}
+
+		switch r.Method {
+		case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
+			if subtle.ConstantTimeCompare([]byte(r.PostFormValue("csrf_token")), []byte(tok)) != 1 {
+				http.Error(w, "invalid or missing CSRF token", http.StatusForbidden)
+				return
+			}
+		}
+
+		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), csrfKey, tok)))
+	})
+}
+
+func csrfToken(r *http.Request) string {
+	t, _ := r.Context().Value(csrfKey).(string)
+	return t
+}
diff --git a/internal/app/db.go b/internal/app/db.go
new file mode 100644
index 0000000..a51aed9
--- /dev/null
+++ b/internal/app/db.go
@@ -0,0 +1,62 @@
+// Package app is the self-hosted, Postgres-backed side of Spread: subscribers,
+// users, sessions, the admin CMS, and the customer dashboard. It is imported
+// only by the standalone binary (main.go), never by the Vercel function, so
+// the public demo stays dependency-light.
+package app
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+
+	"github.com/jackc/pgx/v5/pgxpool"
+)
+
+//go:embed schema.sql
+var schemaSQL string
+
+// App holds the database pool and shared configuration for all handlers.
+type App struct {
+	pool         *pgxpool.Pool
+	secureCookie bool
+	baseURL      string
+	mailer       Mailer
+}
+
+// Config configures the application. Only DSN is required; the rest have sane
+// defaults so the CLI helpers can open with a bare Config.
+type Config struct {
+	DSN          string
+	SecureCookie bool
+	BaseURL      string
+	Mailer       Mailer
+}
+
+// Open connects to Postgres, verifies the connection, and applies the schema.
+func Open(ctx context.Context, cfg Config) (*App, error) {
+	pool, err := pgxpool.New(ctx, cfg.DSN)
+	if err != nil {
+		return nil, fmt.Errorf("connect: %w", err)
+	}
+	if err := pool.Ping(ctx); err != nil {
+		pool.Close()
+		return nil, fmt.Errorf("ping: %w", err)
+	}
+	if _, err := pool.Exec(ctx, schemaSQL); err != nil {
+		pool.Close()
+		return nil, fmt.Errorf("apply schema: %w", err)
+	}
+	if cfg.Mailer == nil {
+		cfg.Mailer = LogMailer{}
+	}
+	if cfg.BaseURL == "" {
+		cfg.BaseURL = "http://localhost:8137"
+	}
+	return &App{pool: pool, secureCookie: cfg.SecureCookie, baseURL: cfg.BaseURL, mailer: cfg.Mailer}, nil
+}
+
+func (a *App) Close() {
+	if a.pool != nil {
+		a.pool.Close()
+	}
+}
diff --git a/internal/app/handlers.go b/internal/app/handlers.go
new file mode 100644
index 0000000..bf7bae9
--- /dev/null
+++ b/internal/app/handlers.go
@@ -0,0 +1,88 @@
+package app
+
+import (
+	"net/http"
+)
+
+type pageData struct {
+	Title  string
+	Email  string
+	Error  string
+	Notice string
+	CSRF   string
+	Token  string
+	User   User
+	Sub    Subscriber
+	Counts struct{ Subs, Users, Catalog, Boms int }
+
+	Catalog []CatalogEntry
+	Edit    *CatalogEntry
+	Subs    []SubscriberWithUsers
+
+	BOMs    []BOMSummary
+	BOM     *BOMDetail
+	BackURL string
+}
+
+func (a *App) render(w http.ResponseWriter, r *http.Request, status int, name string, data pageData) {
+	data.CSRF = csrfToken(r)
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.WriteHeader(status)
+	_ = tmpl.ExecuteTemplate(w, name, data)
+}
+
+func (a *App) handleLoginForm(w http.ResponseWriter, r *http.Request) {
+	a.render(w, r, http.StatusOK, "login", pageData{Title: "Sign in"})
+}
+
+func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
+	email := r.PostFormValue("email")
+	password := r.PostFormValue("password")
+
+	u, err := a.authenticate(r.Context(), email, password)
+	if err != nil {
+		a.render(w, r, http.StatusUnauthorized, "login", pageData{
+			Title: "Sign in", Email: email, Error: "Invalid email or password.",
+		})
+		return
+	}
+
+	raw, err := a.createSession(r.Context(), u.ID)
+	if err != nil {
+		http.Error(w, "could not start session", http.StatusInternalServerError)
+		return
+	}
+	a.setSessionCookie(w, raw)
+
+	sub, _ := a.subscriber(r.Context(), u.SubscriberID)
+	dest := "/app"
+	if sub.IsInternal {
+		dest = "/admin"
+	}
+	http.Redirect(w, r, dest, http.StatusSeeOther)
+}
+
+func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
+	if c, err := r.Cookie(cookieName); err == nil && c.Value != "" {
+		a.destroySession(r.Context(), c.Value)
+	}
+	a.clearSessionCookie(w)
+	http.Redirect(w, r, "/login", http.StatusSeeOther)
+}
+
+func (a *App) handleApp(w http.ResponseWriter, r *http.Request) {
+	u, s, _ := current(r)
+	boms, err := a.listBOMs(r.Context(), &s.ID)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	a.render(w, r, http.StatusOK, "app", pageData{
+		Title:  "Dashboard",
+		User:   u,
+		Sub:    s,
+		BOMs:   boms,
+		Notice: r.URL.Query().Get("notice"),
+		Error:  r.URL.Query().Get("err"),
+	})
+}
diff --git a/internal/app/handlers_admin.go b/internal/app/handlers_admin.go
new file mode 100644
index 0000000..3c95b7c
--- /dev/null
+++ b/internal/app/handlers_admin.go
@@ -0,0 +1,132 @@
+package app
+
+import (
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+)
+
+func (a *App) redirect(w http.ResponseWriter, r *http.Request, path string) {
+	http.Redirect(w, r, path, http.StatusSeeOther)
+}
+
+// flash builds a redirect target with a notice or error query param.
+func flash(path, key, msg string) string {
+	return path + "?" + key + "=" + url.QueryEscape(msg)
+}
+
+func (a *App) handleAdmin(w http.ResponseWriter, r *http.Request) {
+	u, s, _ := current(r)
+	d := pageData{Title: "Overview", User: u, Sub: s}
+	d.Counts.Subs, d.Counts.Users, d.Counts.Catalog, d.Counts.Boms = a.counts(r.Context())
+	a.render(w, r, http.StatusOK, "admin", d)
+}
+
+// --- catalog --------------------------------------------------------------
+
+func (a *App) handleCatalog(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	entries, err := a.listCatalog(ctx)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	d := pageData{
+		Title:   "Catalog",
+		Catalog: entries,
+		Notice:  r.URL.Query().Get("notice"),
+		Error:   r.URL.Query().Get("err"),
+	}
+	if pn := r.URL.Query().Get("edit"); pn != "" {
+		if e, err := a.getCatalogEntry(ctx, pn); err == nil {
+			d.Edit = &e
+		}
+	}
+	a.render(w, r, http.StatusOK, "catalog", d)
+}
+
+func (a *App) handleCatalogSave(w http.ResponseWriter, r *http.Request) {
+	pn := strings.TrimSpace(r.PostFormValue("part_number"))
+	desc := strings.TrimSpace(r.PostFormValue("description"))
+	price, perr := strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("spread_price")), 64)
+	if pn == "" || perr != nil || price <= 0 {
+		a.redirect(w, r, flash("/admin/catalog", "err", "Part number and a positive spread price are required."))
+		return
+	}
+	if err := a.upsertCatalog(r.Context(), pn, desc, price); err != nil {
+		a.redirect(w, r, flash("/admin/catalog", "err", err.Error()))
+		return
+	}
+	a.redirect(w, r, flash("/admin/catalog", "notice", "Saved "+pn+"."))
+}
+
+func (a *App) handleCatalogDelete(w http.ResponseWriter, r *http.Request) {
+	pn := strings.TrimSpace(r.PostFormValue("part_number"))
+	if pn != "" {
+		_ = a.deleteCatalog(r.Context(), pn)
+	}
+	a.redirect(w, r, flash("/admin/catalog", "notice", "Deleted "+pn+"."))
+}
+
+func (a *App) handleCatalogSeed(w http.ResponseWriter, r *http.Request) {
+	if err := a.seedDemoCatalog(r.Context()); err != nil {
+		a.redirect(w, r, flash("/admin/catalog", "err", err.Error()))
+		return
+	}
+	a.redirect(w, r, flash("/admin/catalog", "notice", "Seeded the demo catalog."))
+}
+
+// --- subscribers & users --------------------------------------------------
+
+func (a *App) handleSubscribers(w http.ResponseWriter, r *http.Request) {
+	subs, err := a.listSubscribers(r.Context())
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	a.render(w, r, http.StatusOK, "subscribers", pageData{
+		Title:  "Subscribers",
+		Subs:   subs,
+		Notice: r.URL.Query().Get("notice"),
+		Error:  r.URL.Query().Get("err"),
+	})
+}
+
+func (a *App) handleSubscriberCreate(w http.ResponseWriter, r *http.Request) {
+	name := strings.TrimSpace(r.PostFormValue("name"))
+	if name == "" {
+		a.redirect(w, r, flash("/admin/subscribers", "err", "Subscriber name is required."))
+		return
+	}
+	if _, err := a.createSubscriber(r.Context(), name); err != nil {
+		a.redirect(w, r, flash("/admin/subscribers", "err", err.Error()))
+		return
+	}
+	a.redirect(w, r, flash("/admin/subscribers", "notice", "Created subscriber "+name+"."))
+}
+
+func (a *App) handleUserCreate(w http.ResponseWriter, r *http.Request) {
+	subID, _ := strconv.ParseInt(r.PostFormValue("subscriber_id"), 10, 64)
+	email := strings.TrimSpace(r.PostFormValue("email"))
+	name := strings.TrimSpace(r.PostFormValue("name"))
+	password := r.PostFormValue("password")
+	if subID == 0 || email == "" {
+		a.redirect(w, r, flash("/admin/subscribers", "err", "Subscriber and email are required."))
+		return
+	}
+	uid, err := a.createUser(r.Context(), subID, email, name, password)
+	if err != nil {
+		a.redirect(w, r, flash("/admin/subscribers", "err", err.Error()))
+		return
+	}
+	if password == "" {
+		if err := a.sendInvite(r.Context(), uid, email); err != nil {
+			a.redirect(w, r, flash("/admin/subscribers", "err", "Created "+email+", but the invite email failed: "+err.Error()))
+			return
+		}
+		a.redirect(w, r, flash("/admin/subscribers", "notice", "Created "+email+" and emailed an invite to set a password."))
+		return
+	}
+	a.redirect(w, r, flash("/admin/subscribers", "notice", "Created user "+email+"."))
+}
diff --git a/internal/app/handlers_auth.go b/internal/app/handlers_auth.go
new file mode 100644
index 0000000..1fc7d6e
--- /dev/null
+++ b/internal/app/handlers_auth.go
@@ -0,0 +1,126 @@
+package app
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/jackc/pgx/v5"
+)
+
+// --- email bodies ---------------------------------------------------------
+
+func (a *App) tokenLink(raw string) string {
+	return a.baseURL + "/set-password?token=" + url.QueryEscape(raw)
+}
+
+func mailText(intro, link string) string {
+	return fmt.Sprintf("%s\n\nOpen this link to set your password:\n%s\n\nIf you weren't expecting this, you can ignore it.\n", intro, link)
+}
+
+func mailHTML(intro, cta, link string) string {
+	return fmt.Sprintf(
+		`<p>%s</p><p><a href="%s">%s</a></p><p style="color:#888;font-size:12px">If you weren't expecting this, you can ignore it.</p>`,
+		intro, link, cta)
+}
+
+func (a *App) sendInvite(ctx context.Context, userID int64, email string) error {
+	raw, err := a.issueToken(ctx, userID, "invite", inviteTTL)
+	if err != nil {
+		return err
+	}
+	link := a.tokenLink(raw)
+	return a.mailer.Send([]string{email}, "You're invited to Spread",
+		mailHTML("You've been invited to Spread. Set a password to get started.", "Set your password", link),
+		mailText("You've been invited to Spread. Set a password to get started.", link))
+}
+
+func (a *App) sendReset(ctx context.Context, userID int64, email string) error {
+	raw, err := a.issueToken(ctx, userID, "reset", resetTTL)
+	if err != nil {
+		return err
+	}
+	link := a.tokenLink(raw)
+	return a.mailer.Send([]string{email}, "Reset your Spread password",
+		mailHTML("Someone asked to reset your Spread password.", "Choose a new password", link),
+		mailText("Someone asked to reset your Spread password.", link))
+}
+
+// --- forgot password ------------------------------------------------------
+
+func (a *App) handleForgotForm(w http.ResponseWriter, r *http.Request) {
+	a.render(w, r, http.StatusOK, "forgot", pageData{Title: "Reset password"})
+}
+
+func (a *App) handleForgot(w http.ResponseWriter, r *http.Request) {
+	email := normEmail(r.PostFormValue("email"))
+
+	var uid int64
+	err := a.pool.QueryRow(r.Context(), `SELECT id FROM users WHERE email = $1`, email).Scan(&uid)
+	if err == nil {
+		if err := a.sendReset(r.Context(), uid, email); err != nil {
+			http.Error(w, "could not send reset email", http.StatusInternalServerError)
+			return
+		}
+	} else if !errors.Is(err, pgx.ErrNoRows) {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	// Always the same response, so we don't reveal which emails have accounts.
+	a.render(w, r, http.StatusOK, "forgot", pageData{
+		Title:  "Reset password",
+		Notice: "If that email has an account, a reset link is on its way.",
+	})
+}
+
+// --- set / reset password (shared by invites and resets) ------------------
+
+func (a *App) handleSetPasswordForm(w http.ResponseWriter, r *http.Request) {
+	raw := r.URL.Query().Get("token")
+	if err := a.peekToken(r.Context(), raw); err != nil {
+		a.render(w, r, http.StatusBadRequest, "tokenerror", pageData{Title: "Link expired"})
+		return
+	}
+	a.render(w, r, http.StatusOK, "setpassword", pageData{Title: "Set your password", Token: raw})
+}
+
+func (a *App) handleSetPassword(w http.ResponseWriter, r *http.Request) {
+	raw := r.PostFormValue("token")
+	password := r.PostFormValue("password")
+	confirm := r.PostFormValue("confirm")
+
+	if len(password) < 8 || password != confirm {
+		msg := "Password must be at least 8 characters."
+		if password != confirm {
+			msg = "Those passwords don't match."
+		}
+		a.render(w, r, http.StatusBadRequest, "setpassword", pageData{Title: "Set your password", Token: raw, Error: msg})
+		return
+	}
+
+	userID, err := a.consumeToken(r.Context(), raw)
+	if err != nil {
+		a.render(w, r, http.StatusBadRequest, "tokenerror", pageData{Title: "Link expired"})
+		return
+	}
+	if err := a.setPassword(r.Context(), userID, password); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// Log them straight in.
+	sessRaw, err := a.createSession(r.Context(), userID)
+	if err != nil {
+		http.Redirect(w, r, "/login", http.StatusSeeOther)
+		return
+	}
+	a.setSessionCookie(w, sessRaw)
+
+	dest := "/app"
+	if sub, err := a.subscriberForUser(r.Context(), userID); err == nil && sub.IsInternal {
+		dest = "/admin"
+	}
+	http.Redirect(w, r, dest, http.StatusSeeOther)
+}
diff --git a/internal/app/handlers_bom.go b/internal/app/handlers_bom.go
new file mode 100644
index 0000000..3198230
--- /dev/null
+++ b/internal/app/handlers_bom.go
@@ -0,0 +1,90 @@
+package app
+
+import (
+	"io"
+	"net/http"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"spread/internal/spread"
+)
+
+const maxUpload = 8 << 20
+
+// handleAppAnalyze accepts a customer's uploaded BOM, re-quotes it against the
+// live DB catalog, persists it under their subscriber, and shows the result.
+func (a *App) handleAppAnalyze(w http.ResponseWriter, r *http.Request) {
+	u, s, _ := current(r)
+	ctx := r.Context()
+
+	if err := r.ParseMultipartForm(maxUpload); err != nil {
+		a.redirect(w, r, flash("/app", "err", "Could not read the upload."))
+		return
+	}
+	f, hdr, err := r.FormFile("bom")
+	if err != nil {
+		a.redirect(w, r, flash("/app", "err", "Choose a BOM file to upload."))
+		return
+	}
+	defer f.Close()
+	data, _ := io.ReadAll(io.LimitReader(f, maxUpload))
+
+	items, err := spread.ParseBOM(hdr.Filename, data)
+	if err != nil {
+		a.redirect(w, r, flash("/app", "err", err.Error()))
+		return
+	}
+
+	cat, err := a.catalogSource(ctx)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	ref := strings.TrimSpace(strings.TrimSuffix(filepath.Base(hdr.Filename), filepath.Ext(hdr.Filename)))
+	if ref == "" {
+		ref = "BOM"
+	}
+	rep := spread.AnalyzeWith(ref, items, cat)
+
+	id, err := a.saveBOM(ctx, s.ID, u.ID, rep, hdr.Filename)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	a.redirect(w, r, "/app/bom/"+strconv.FormatInt(id, 10))
+}
+
+// handleBOM shows one saved BOM. Customers see only their own; the internal
+// subscriber can view any (admin BOM review).
+func (a *App) handleBOM(w http.ResponseWriter, r *http.Request) {
+	u, s, _ := current(r)
+	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
+
+	d, err := a.getBOM(r.Context(), id)
+	if err != nil {
+		http.Error(w, "BOM not found", http.StatusNotFound)
+		return
+	}
+	if !s.IsInternal && d.SubscriberID != s.ID {
+		http.Redirect(w, r, "/app", http.StatusSeeOther)
+		return
+	}
+
+	back := "/app"
+	if s.IsInternal {
+		back = "/admin/boms"
+	}
+	a.render(w, r, http.StatusOK, "bom", pageData{Title: d.Ref, User: u, Sub: s, BOM: &d, BackURL: back})
+}
+
+// handleBOMs is the admin BOM-review list across all subscribers.
+func (a *App) handleBOMs(w http.ResponseWriter, r *http.Request) {
+	boms, err := a.listBOMs(r.Context(), nil)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	a.render(w, r, http.StatusOK, "boms", pageData{Title: "BOMs", BOMs: boms})
+}
diff --git a/internal/app/mail.go b/internal/app/mail.go
new file mode 100644
index 0000000..51c957a
--- /dev/null
+++ b/internal/app/mail.go
@@ -0,0 +1,116 @@
+package app
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/smtp"
+	"os"
+	"strings"
+)
+
+// Mailer sends transactional email. Kept deliberately small so backends are
+// swappable and the whole thing can be repointed or removed easily.
+type Mailer interface {
+	Send(to []string, subject, htmlBody, textBody string) error
+}
+
+// NewMailerFromEnv picks a backend from MAIL_BACKEND (log|smtp|sendgrid),
+// defaulting to log so development needs no email infrastructure.
+func NewMailerFromEnv() Mailer {
+	from := envOr("MAIL_FROM", "Spread <no-reply@spread.local>")
+	switch strings.ToLower(os.Getenv("MAIL_BACKEND")) {
+	case "smtp":
+		return &SMTPMailer{
+			Host: os.Getenv("SMTP_HOST"),
+			Port: envOr("SMTP_PORT", "587"),
+			User: os.Getenv("SMTP_USER"),
+			Pass: os.Getenv("SMTP_PASS"),
+			From: from,
+		}
+	case "sendgrid":
+		return &SendgridMailer{Key: os.Getenv("SENDGRID_API_KEY"), From: from}
+	default:
+		return LogMailer{}
+	}
+}
+
+func envOr(key, def string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return def
+}
+
+// LogMailer writes messages to the server log — the default in development.
+type LogMailer struct{}
+
+func (LogMailer) Send(to []string, subject, htmlBody, textBody string) error {
+	log.Printf("[mail:log] to=%s subject=%q\n%s", strings.Join(to, ", "), subject, textBody)
+	return nil
+}
+
+// SMTPMailer sends via any SMTP relay using only the standard library.
+type SMTPMailer struct {
+	Host, Port, User, Pass, From string
+}
+
+func (m *SMTPMailer) Send(to []string, subject, htmlBody, textBody string) error {
+	const boundary = "spreadalt"
+	var b strings.Builder
+	fmt.Fprintf(&b, "From: %s\r\n", m.From)
+	fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", "))
+	fmt.Fprintf(&b, "Subject: %s\r\n", subject)
+	b.WriteString("MIME-Version: 1.0\r\n")
+	fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%s\r\n\r\n", boundary)
+	fmt.Fprintf(&b, "--%s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n\r\n", boundary, textBody)
+	fmt.Fprintf(&b, "--%s\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n\r\n", boundary, htmlBody)
+	fmt.Fprintf(&b, "--%s--\r\n", boundary)
+
+	var auth smtp.Auth
+	if m.User != "" {
+		auth = smtp.PlainAuth("", m.User, m.Pass, m.Host)
+	}
+	return smtp.SendMail(m.Host+":"+m.Port, auth, m.From, to, []byte(b.String()))
+}
+
+// SendgridMailer posts to SendGrid's v3 API over plain net/http (no SDK).
+type SendgridMailer struct {
+	Key, From string
+}
+
+func (m *SendgridMailer) Send(to []string, subject, htmlBody, textBody string) error {
+	recips := make([]map[string]string, len(to))
+	for i, addr := range to {
+		recips[i] = map[string]string{"email": addr}
+	}
+	payload := map[string]any{
+		"personalizations": []any{map[string]any{"to": recips}},
+		"from":             map[string]string{"email": m.From},
+		"subject":          subject,
+		"content": []any{
+			map[string]string{"type": "text/plain", "value": textBody},
+			map[string]string{"type": "text/html", "value": htmlBody},
+		},
+	}
+	body, _ := json.Marshal(payload)
+	req, err := http.NewRequest(http.MethodPost, "https://api.sendgrid.com/v3/mail/send", bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Authorization", "Bearer "+m.Key)
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 300 {
+		msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+		return fmt.Errorf("sendgrid: %s: %s", resp.Status, msg)
+	}
+	return nil
+}
diff --git a/internal/app/routes.go b/internal/app/routes.go
new file mode 100644
index 0000000..05c0276
--- /dev/null
+++ b/internal/app/routes.go
@@ -0,0 +1,41 @@
+package app
+
+import "net/http"
+
+// Wrapping helpers: every app route gets CSRF protection; auth/internal add
+// session loading and the appropriate access guard.
+func (a *App) pub(h http.HandlerFunc) http.Handler {
+	return a.csrf(h)
+}
+func (a *App) auth(h http.HandlerFunc) http.Handler {
+	return a.csrf(a.load(a.requireAuth(h)))
+}
+func (a *App) internal(h http.HandlerFunc) http.Handler {
+	return a.csrf(a.load(a.requireInternal(h)))
+}
+
+// Routes mounts the authenticated application routes onto an existing mux.
+// The public demo (/, /api/analyze) is registered separately by main.go.
+func (a *App) Routes(mux *http.ServeMux) {
+	mux.Handle("GET /login", a.pub(a.handleLoginForm))
+	mux.Handle("POST /login", a.pub(a.handleLogin))
+	mux.Handle("POST /logout", a.pub(a.handleLogout))
+	mux.Handle("GET /forgot", a.pub(a.handleForgotForm))
+	mux.Handle("POST /forgot", a.pub(a.handleForgot))
+	mux.Handle("GET /set-password", a.pub(a.handleSetPasswordForm))
+	mux.Handle("POST /set-password", a.pub(a.handleSetPassword))
+
+	mux.Handle("GET /app", a.auth(a.handleApp))
+	mux.Handle("POST /app/analyze", a.auth(a.handleAppAnalyze))
+	mux.Handle("GET /app/bom/{id}", a.auth(a.handleBOM))
+
+	mux.Handle("GET /admin", a.internal(a.handleAdmin))
+	mux.Handle("GET /admin/catalog", a.internal(a.handleCatalog))
+	mux.Handle("POST /admin/catalog", a.internal(a.handleCatalogSave))
+	mux.Handle("POST /admin/catalog/delete", a.internal(a.handleCatalogDelete))
+	mux.Handle("POST /admin/catalog/seed", a.internal(a.handleCatalogSeed))
+	mux.Handle("GET /admin/subscribers", a.internal(a.handleSubscribers))
+	mux.Handle("POST /admin/subscribers", a.internal(a.handleSubscriberCreate))
+	mux.Handle("POST /admin/users", a.internal(a.handleUserCreate))
+	mux.Handle("GET /admin/boms", a.internal(a.handleBOMs))
+}
diff --git a/internal/app/schema.sql b/internal/app/schema.sql
new file mode 100644
index 0000000..da304d1
--- /dev/null
+++ b/internal/app/schema.sql
@@ -0,0 +1,66 @@
+-- Spread application schema. Applied idempotently on startup.
+
+CREATE TABLE IF NOT EXISTS subscribers (
+    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+    name        TEXT NOT NULL,
+    is_internal BOOLEAN NOT NULL DEFAULT FALSE,
+    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS users (
+    id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+    subscriber_id BIGINT NOT NULL REFERENCES subscribers(id),
+    email         TEXT NOT NULL UNIQUE,
+    password_hash TEXT NOT NULL DEFAULT '',
+    name          TEXT NOT NULL DEFAULT '',
+    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+    token_hash TEXT PRIMARY KEY,
+    user_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    expires_at TIMESTAMPTZ NOT NULL
+);
+
+-- one-time tokens for invites and password resets
+CREATE TABLE IF NOT EXISTS user_tokens (
+    token_hash TEXT PRIMARY KEY,
+    user_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    purpose    TEXT NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    expires_at TIMESTAMPTZ NOT NULL,
+    used_at    TIMESTAMPTZ
+);
+
+CREATE TABLE IF NOT EXISTS catalog (
+    part_number  TEXT PRIMARY KEY,
+    description  TEXT NOT NULL DEFAULT '',
+    spread_price NUMERIC(12,4) NOT NULL,
+    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS boms (
+    id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+    subscriber_id BIGINT NOT NULL REFERENCES subscribers(id),
+    uploaded_by   BIGINT REFERENCES users(id),
+    ref           TEXT NOT NULL,
+    filename      TEXT NOT NULL DEFAULT '',
+    total_savings NUMERIC(14,2) NOT NULL DEFAULT 0,
+    our_cut       NUMERIC(14,2) NOT NULL DEFAULT 0,
+    net_savings   NUMERIC(14,2) NOT NULL DEFAULT 0,
+    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS bom_lines (
+    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+    bom_id       BIGINT NOT NULL REFERENCES boms(id) ON DELETE CASCADE,
+    line         INT NOT NULL DEFAULT 0,
+    part_number  TEXT NOT NULL DEFAULT '',
+    description  TEXT NOT NULL DEFAULT '',
+    quantity     NUMERIC NOT NULL DEFAULT 0,
+    unit_price   NUMERIC(12,4) NOT NULL DEFAULT 0,
+    spread_price NUMERIC(12,4) NOT NULL DEFAULT 0,
+    line_saving  NUMERIC(14,2) NOT NULL DEFAULT 0,
+    matched      BOOLEAN NOT NULL DEFAULT FALSE
+);
diff --git a/internal/app/session.go b/internal/app/session.go
new file mode 100644
index 0000000..58d1d66
--- /dev/null
+++ b/internal/app/session.go
@@ -0,0 +1,136 @@
+package app
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"net/http"
+	"time"
+)
+
+const (
+	cookieName = "spread_session"
+	sessionTTL = 30 * 24 * time.Hour
+)
+
+type ctxKey int
+
+const (
+	userKey ctxKey = iota
+	subKey
+	csrfKey
+)
+
+func hashToken(raw string) string {
+	sum := sha256.Sum256([]byte(raw))
+	return hex.EncodeToString(sum[:])
+}
+
+// randomToken returns a 256-bit URL-safe random string.
+func randomToken() string {
+	buf := make([]byte, 32)
+	_, _ = rand.Read(buf)
+	return base64.RawURLEncoding.EncodeToString(buf)
+}
+
+func (a *App) createSession(ctx context.Context, userID int64) (string, error) {
+	raw := randomToken()
+	_, err := a.pool.Exec(ctx,
+		`INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`,
+		hashToken(raw), userID, time.Now().Add(sessionTTL))
+	if err != nil {
+		return "", err
+	}
+	return raw, nil
+}
+
+func (a *App) lookupSession(ctx context.Context, raw string) (User, Subscriber, error) {
+	var u User
+	err := a.pool.QueryRow(ctx,
+		`SELECT u.id, u.subscriber_id, u.email, u.name
+		   FROM sessions s JOIN users u ON u.id = s.user_id
+		  WHERE s.token_hash = $1 AND s.expires_at > now()`,
+		hashToken(raw)).Scan(&u.ID, &u.SubscriberID, &u.Email, &u.Name)
+	if err != nil {
+		return User{}, Subscriber{}, err
+	}
+	sub, err := a.subscriber(ctx, u.SubscriberID)
+	return u, sub, err
+}
+
+func (a *App) destroySession(ctx context.Context, raw string) {
+	_, _ = a.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hashToken(raw))
+}
+
+func (a *App) setSessionCookie(w http.ResponseWriter, raw string) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     cookieName,
+		Value:    raw,
+		Path:     "/",
+		HttpOnly: true,
+		Secure:   a.secureCookie,
+		SameSite: http.SameSiteLaxMode,
+		Expires:  time.Now().Add(sessionTTL),
+	})
+}
+
+func (a *App) clearSessionCookie(w http.ResponseWriter) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     cookieName,
+		Value:    "",
+		Path:     "/",
+		HttpOnly: true,
+		Secure:   a.secureCookie,
+		SameSite: http.SameSiteLaxMode,
+		MaxAge:   -1,
+	})
+}
+
+// current returns the logged-in user and subscriber from the request context.
+func current(r *http.Request) (User, Subscriber, bool) {
+	u, ok := r.Context().Value(userKey).(User)
+	if !ok {
+		return User{}, Subscriber{}, false
+	}
+	s, _ := r.Context().Value(subKey).(Subscriber)
+	return u, s, true
+}
+
+// load attaches the session user/subscriber to the request context if a valid
+// session cookie is present. It never redirects.
+func (a *App) load(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if c, err := r.Cookie(cookieName); err == nil && c.Value != "" {
+			if u, s, err := a.lookupSession(r.Context(), c.Value); err == nil {
+				ctx := context.WithValue(r.Context(), userKey, u)
+				ctx = context.WithValue(ctx, subKey, s)
+				r = r.WithContext(ctx)
+			}
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+// requireAuth redirects to /login when there is no session.
+func (a *App) requireAuth(fn http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if _, _, ok := current(r); !ok {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		fn(w, r)
+	}
+}
+
+// requireInternal requires a session belonging to the internal subscriber.
+func (a *App) requireInternal(fn http.HandlerFunc) http.HandlerFunc {
+	return a.requireAuth(func(w http.ResponseWriter, r *http.Request) {
+		if _, s, _ := current(r); !s.IsInternal {
+			http.Redirect(w, r, "/app", http.StatusSeeOther)
+			return
+		}
+		fn(w, r)
+	})
+}
diff --git a/internal/app/subscribers.go b/internal/app/subscribers.go
new file mode 100644
index 0000000..1bb1298
--- /dev/null
+++ b/internal/app/subscribers.go
@@ -0,0 +1,95 @@
+package app
+
+import (
+	"context"
+	"fmt"
+
+	"golang.org/x/crypto/bcrypt"
+)
+
+// SubscriberWithUsers is a subscriber and its users, for the admin listing.
+type SubscriberWithUsers struct {
+	Subscriber
+	CreatedAt string
+	Users     []User
+}
+
+func (a *App) createSubscriber(ctx context.Context, name string) (int64, error) {
+	var id int64
+	err := a.pool.QueryRow(ctx,
+		`INSERT INTO subscribers (name) VALUES ($1) RETURNING id`, name,
+	).Scan(&id)
+	return id, err
+}
+
+// createUser inserts a user and returns its id. An empty password creates a
+// "pending" account (no usable hash) that must set a password via an invite
+// link before it can log in.
+func (a *App) createUser(ctx context.Context, subscriberID int64, email, name, password string) (int64, error) {
+	hash := ""
+	if password != "" {
+		if len(password) < 8 {
+			return 0, fmt.Errorf("password must be at least 8 characters")
+		}
+		h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+		if err != nil {
+			return 0, err
+		}
+		hash = string(h)
+	}
+	var id int64
+	err := a.pool.QueryRow(ctx,
+		`INSERT INTO users (subscriber_id, email, name, password_hash) VALUES ($1, $2, $3, $4) RETURNING id`,
+		subscriberID, normEmail(email), name, hash).Scan(&id)
+	return id, err
+}
+
+// listSubscribers returns every subscriber with its users, internal first.
+func (a *App) listSubscribers(ctx context.Context) ([]SubscriberWithUsers, error) {
+	rows, err := a.pool.Query(ctx,
+		`SELECT id, name, is_internal, to_char(created_at, 'YYYY-MM-DD')
+		   FROM subscribers ORDER BY is_internal DESC, id`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var subs []SubscriberWithUsers
+	idx := map[int64]int{}
+	for rows.Next() {
+		var s SubscriberWithUsers
+		if err := rows.Scan(&s.ID, &s.Name, &s.IsInternal, &s.CreatedAt); err != nil {
+			return nil, err
+		}
+		idx[s.ID] = len(subs)
+		subs = append(subs, s)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	urows, err := a.pool.Query(ctx,
+		`SELECT id, subscriber_id, email, name FROM users ORDER BY email`)
+	if err != nil {
+		return nil, err
+	}
+	defer urows.Close()
+	for urows.Next() {
+		var u User
+		if err := urows.Scan(&u.ID, &u.SubscriberID, &u.Email, &u.Name); err != nil {
+			return nil, err
+		}
+		if i, ok := idx[u.SubscriberID]; ok {
+			subs[i].Users = append(subs[i].Users, u)
+		}
+	}
+	return subs, urows.Err()
+}
+
+func (a *App) counts(ctx context.Context) (subs, users, cat, boms int) {
+	_ = a.pool.QueryRow(ctx, `SELECT count(*) FROM subscribers`).Scan(&subs)
+	_ = a.pool.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users)
+	_ = a.pool.QueryRow(ctx, `SELECT count(*) FROM catalog`).Scan(&cat)
+	_ = a.pool.QueryRow(ctx, `SELECT count(*) FROM boms`).Scan(&boms)
+	return
+}
diff --git a/internal/app/templates.go b/internal/app/templates.go
new file mode 100644
index 0000000..cfd4798
--- /dev/null
+++ b/internal/app/templates.go
@@ -0,0 +1,311 @@
+package app
+
+import "html/template"
+
+const pageCSS = `
+@font-face{font-family:'Iosevka';font-style:normal;font-weight:400;font-display:swap;src:url('/assets/fonts/iosevka-latin-400-normal.woff2') format('woff2')}
+@font-face{font-family:'Iosevka';font-style:normal;font-weight:500;font-display:swap;src:url('/assets/fonts/iosevka-latin-500-normal.woff2') format('woff2')}
+@font-face{font-family:'Iosevka';font-style:normal;font-weight:600;font-display:swap;src:url('/assets/fonts/iosevka-latin-600-normal.woff2') format('woff2')}
+@font-face{font-family:'Iosevka';font-style:italic;font-weight:400;font-display:swap;src:url('/assets/fonts/iosevka-latin-400-italic.woff2') format('woff2')}
+@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url('/assets/fonts/ibm-plex-sans-latin-400-normal.woff2') format('woff2')}
+@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url('/assets/fonts/ibm-plex-sans-latin-500-normal.woff2') format('woff2')}
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+:root{--bg:#0a0a0c;--surface:#18181c;--border:#2a2a2e;--border-strong:#3a3a40;
+--text:#f5f4f0;--muted:#a8a6a0;--dim:#6a6864;--green:#5dd39e;--green-bg:#11201a;--green-dim:#1f3a2c;
+--red:#ff8a7a;--red-bg:#2a1410;--serif:'Iosevka',ui-monospace,monospace;--sans:'IBM Plex Sans',system-ui,sans-serif;--mono:'Iosevka',ui-monospace,monospace}
+html,body{background:var(--bg);color:var(--text);font-family:var(--sans);-webkit-font-smoothing:antialiased}
+a{color:inherit;text-decoration:none}
+.wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem}
+.card{width:100%;max-width:400px;background:var(--surface);border:0.5px solid var(--border);border-radius:16px;padding:2.5rem}
+.brand{font-family:var(--serif);font-size:22px;font-weight:500;letter-spacing:-0.5px}
+.brand span{color:var(--green);font-size:13px}
+.brand em{font-style:normal;color:var(--dim);font-size:16px}
+h1{font-family:var(--serif);font-size:30px;font-weight:500;letter-spacing:-1px;margin-bottom:1.25rem}
+label{display:block;font-size:12px;color:var(--muted);margin-bottom:1rem;letter-spacing:0.2px}
+input,select{display:block;width:100%;margin-top:6px;padding:11px 13px;background:var(--bg);color:var(--text);
+border:0.5px solid var(--border-strong);border-radius:9px;font-family:var(--sans);font-size:14px}
+input:focus,select:focus{outline:none;border-color:var(--green)}
+input[readonly]{color:var(--muted);border-style:dashed}
+button{font-family:var(--sans);font-size:14px;font-weight:500;padding:12px 22px;border:none;border-radius:100px;
+background:var(--green);color:#061a12;cursor:pointer;margin-top:6px}
+button.ghost,a.btn-secondary{background:transparent;color:var(--text);border:0.5px solid var(--border-strong);display:inline-block}
+.btn-sm{padding:6px 14px;font-size:12px;margin-top:0}
+button.link{background:none;border:none;color:var(--muted);font-size:13px;cursor:pointer;padding:0;margin:0}
+button.link:hover{color:var(--text)}
+.btn-danger{background:transparent;color:var(--red);border:0.5px solid #4a2018;padding:6px 12px;font-size:12px;border-radius:100px;cursor:pointer;margin:0}
+.muted{color:var(--muted);font-size:14px;line-height:1.55;margin-bottom:0.75rem}
+.muted b{color:var(--text);font-weight:500}.muted a{color:var(--green)}
+.err{background:var(--red-bg);color:var(--red);border:0.5px solid #4a2018;border-radius:9px;padding:10px 13px;font-size:13px;margin-bottom:1.25rem}
+.notice{background:var(--green-bg);color:var(--green);border:0.5px solid var(--green-dim);border-radius:9px;padding:10px 13px;font-size:13px;margin-bottom:1.25rem}
+
+/* admin */
+.topbar{display:flex;align-items:center;justify-content:space-between;padding:1rem 2rem;border-bottom:0.5px solid var(--border);position:sticky;top:0;background:rgba(10,10,12,.85);backdrop-filter:blur(10px);z-index:10}
+.topnav{display:flex;align-items:center;gap:1.5rem}
+.topnav a{font-size:13px;color:var(--muted)}.topnav a:hover{color:var(--text)}
+.topnav form{margin:0}
+.admin{max-width:900px;margin:0 auto;padding:2.5rem 2rem}
+.admin h2{font-family:var(--serif);font-size:20px;font-weight:500;margin:2rem 0 1rem;letter-spacing:-.3px}
+table.admin-t{width:100%;border-collapse:collapse;font-size:13px;margin-bottom:1rem}
+table.admin-t th{text-align:left;padding:9px 10px;font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--dim);border-bottom:0.5px solid var(--border)}
+table.admin-t td{padding:10px;border-bottom:0.5px solid var(--border);color:var(--muted);vertical-align:middle}
+table.admin-t td.mono{font-family:var(--mono);color:var(--text)}
+.stat-row{display:flex;gap:12px;margin:1.5rem 0;flex-wrap:wrap}
+.stat{flex:1;min-width:120px;background:var(--surface);border:0.5px solid var(--border);border-radius:12px;padding:1.1rem 1.25rem}
+.stat .n{font-family:var(--serif);font-size:30px;font-weight:500;letter-spacing:-1px}
+.stat .l{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:var(--dim);margin-top:4px}
+.form-inline{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;background:var(--surface);border:0.5px solid var(--border);border-radius:12px;padding:1.25rem;margin-bottom:1rem}
+.form-inline label{margin:0;flex:1;min-width:140px}
+.form-inline .btnwrap{display:flex;gap:8px;align-items:center}
+.form-inline button{margin-top:0}
+.pill{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:1px;padding:2px 9px;border-radius:100px;border:0.5px solid var(--border-strong);color:var(--dim);vertical-align:middle}
+.pill.int{color:var(--green);border-color:var(--green-dim)}
+a.editlink{color:var(--green);font-family:var(--mono);font-size:11px}
+`
+
+var tmpl = template.Must(template.New("").Parse(`
+{{define "head"}}<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · Spread</title><style>` + pageCSS + `</style></head><body><div class="wrap">{{end}}
+{{define "foot"}}</div></body></html>{{end}}
+
+{{define "ahead"}}<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · Spread admin</title><style>` + pageCSS + `</style></head><body>
+<div class="topbar">
+  <div class="brand">Spread<span>●</span> <em>· admin</em></div>
+  <nav class="topnav">
+    <a href="/admin">Overview</a>
+    <a href="/admin/catalog">Catalog</a>
+    <a href="/admin/subscribers">Subscribers</a>
+    <a href="/admin/boms">BOMs</a>
+    <form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link">Log out</button></form>
+  </nav>
+</div>
+<main class="admin">{{end}}
+{{define "afoot"}}</main></body></html>{{end}}
+
+{{define "login"}}{{template "head" .}}
+<div class="card">
+  <div class="brand">Spread<span>●</span></div>
+  <h1>Sign in</h1>
+  {{if .Error}}<div class="err">{{.Error}}</div>{{end}}
+  <form method="post" action="/login">
+    <input type="hidden" name="csrf_token" value="{{.CSRF}}">
+    <label>Email<input type="email" name="email" value="{{.Email}}" required autofocus></label>
+    <label>Password<input type="password" name="password" required></label>
+    <button type="submit">Sign in →</button>
+  </form>
+  <p class="muted" style="margin-top:1rem"><a href="/forgot">Forgot your password?</a></p>
+</div>{{template "foot" .}}{{end}}
+
+{{define "chead"}}<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · Spread</title>
+<style>` + pageCSS + `</style></head><body>
+<div class="topbar">
+  <div class="brand">Spread<span>●</span></div>
+  <nav class="topnav">
+    <a href="/app">Your BOMs</a>
+    <a href="/">Public demo</a>
+    <form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link">Log out</button></form>
+  </nav>
+</div>
+<main class="admin">{{end}}
+{{define "cfoot"}}</main></body></html>{{end}}
+
+{{define "app"}}{{template "chead" .}}
+<h1>Your BOMs</h1>
+{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
+{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
+<form class="form-inline" method="post" action="/app/analyze" enctype="multipart/form-data">
+  <input type="hidden" name="csrf_token" value="{{.CSRF}}">
+  <label>Upload a BOM (.csv or .xlsx)<input type="file" name="bom" accept=".csv,.tsv,.txt,.xlsx" required></label>
+  <div class="btnwrap"><button type="submit">Re-quote →</button></div>
+</form>
+<p class="muted">Signed in as <b>{{.User.Email}}</b> — <b>{{.Sub.Name}}</b>. Uploads are re-quoted against the live catalog and saved here.</p>
+<table class="admin-t">
+  <thead><tr><th>BOM</th><th>Uploaded</th><th>Parts</th><th>Total savings</th><th>Net</th><th></th></tr></thead>
+  <tbody>
+  {{range .BOMs}}
+    <tr>
+      <td class="mono">{{.Ref}}</td>
+      <td>{{.CreatedAt}}</td>
+      <td>{{.Lines}}</td>
+      <td class="mono">{{printf "$%.2f" .TotalSavings}}</td>
+      <td class="mono" style="color:var(--green)">{{printf "$%.2f" .NetSavings}}</td>
+      <td style="text-align:right"><a class="editlink" href="/app/bom/{{.ID}}">view</a></td>
+    </tr>
+  {{else}}
+    <tr><td colspan="6" style="color:var(--dim)">No BOMs yet — upload one above to see it re-quoted.</td></tr>
+  {{end}}
+  </tbody>
+</table>
+{{template "cfoot" .}}{{end}}
+
+{{define "bom"}}{{template "chead" .}}
+{{with .BOM}}
+<p class="muted"><a href="{{$.BackURL}}">← back</a></p>
+<h1>{{.Ref}}</h1>
+<p class="muted">{{.SubscriberName}} · {{.CreatedAt}} · {{.Lines}} parts{{if .Filename}} · {{.Filename}}{{end}}</p>
+<div class="stat-row">
+  <div class="stat"><div class="n">{{printf "$%.2f" .TotalSavings}}</div><div class="l">Total savings</div></div>
+  <div class="stat"><div class="n">{{printf "$%.2f" .OurCut}}</div><div class="l">Our cut (20%)</div></div>
+  <div class="stat"><div class="n" style="color:var(--green)">{{printf "$%.2f" .NetSavings}}</div><div class="l">Net savings</div></div>
+</div>
+<table class="admin-t">
+  <thead><tr><th>Part</th><th>Qty</th><th>Market</th><th>Spread price</th><th>You save</th></tr></thead>
+  <tbody>
+  {{range .Items}}
+    <tr>
+      <td>{{.Description}}<br><span class="mono" style="font-size:11px;color:var(--dim)">{{.PartNumber}}{{if not .Matched}} · est.{{end}}</span></td>
+      <td class="mono">{{printf "%.0f" .Quantity}}</td>
+      <td class="mono">{{printf "$%.4f" .UnitPrice}}</td>
+      <td class="mono">{{printf "$%.4f" .SpreadPrice}}</td>
+      <td class="mono" style="color:var(--green)">{{printf "$%.2f" .LineSaving}}</td>
+    </tr>
+  {{end}}
+  </tbody>
+</table>
+{{end}}
+{{template "cfoot" .}}{{end}}
+
+{{define "boms"}}{{template "ahead" .}}
+<h1>Uploaded BOMs</h1>
+<table class="admin-t">
+  <thead><tr><th>BOM</th><th>Subscriber</th><th>Uploaded by</th><th>When</th><th>Parts</th><th>Savings</th><th></th></tr></thead>
+  <tbody>
+  {{range .BOMs}}
+    <tr>
+      <td class="mono">{{.Ref}}</td>
+      <td>{{.SubscriberName}}</td>
+      <td class="mono">{{.UploadedByEmail}}</td>
+      <td>{{.CreatedAt}}</td>
+      <td>{{.Lines}}</td>
+      <td class="mono">{{printf "$%.2f" .TotalSavings}}</td>
+      <td style="text-align:right"><a class="editlink" href="/app/bom/{{.ID}}">view</a></td>
+    </tr>
+  {{else}}
+    <tr><td colspan="7" style="color:var(--dim)">No BOMs uploaded yet.</td></tr>
+  {{end}}
+  </tbody>
+</table>
+{{template "afoot" .}}{{end}}
+
+{{define "admin"}}{{template "ahead" .}}
+<h1>Overview</h1>
+<p class="muted">Signed in as <b>{{.User.Email}}</b> — <b>{{.Sub.Name}}</b>.</p>
+<div class="stat-row">
+  <div class="stat"><div class="n">{{.Counts.Catalog}}</div><div class="l">Catalog parts</div></div>
+  <div class="stat"><div class="n">{{.Counts.Subs}}</div><div class="l">Subscribers</div></div>
+  <div class="stat"><div class="n">{{.Counts.Users}}</div><div class="l">Users</div></div>
+  <div class="stat"><div class="n">{{.Counts.Boms}}</div><div class="l">BOMs uploaded</div></div>
+</div>
+<p class="muted"><a href="/admin/catalog">Manage catalog →</a></p>
+<p class="muted"><a href="/admin/subscribers">Manage subscribers &amp; users →</a></p>
+<p class="muted"><a href="/admin/boms">Review uploaded BOMs →</a></p>
+{{template "afoot" .}}{{end}}
+
+{{define "catalog"}}{{template "ahead" .}}
+<h1>Catalog</h1>
+{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
+{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
+<form class="form-inline" method="post" action="/admin/catalog">
+  <input type="hidden" name="csrf_token" value="{{.CSRF}}">
+  <label>Part number<input name="part_number" value="{{with .Edit}}{{.PartNumber}}{{end}}"{{if .Edit}} readonly{{end}} required></label>
+  <label>Description<input name="description" value="{{with .Edit}}{{.Description}}{{end}}"></label>
+  <label>Spread price<input name="spread_price" type="number" step="0.0001" min="0" value="{{with .Edit}}{{printf "%.4f" .SpreadPrice}}{{end}}" required></label>
+  <div class="btnwrap">
+    <button type="submit">{{if .Edit}}Update{{else}}Add part{{end}}</button>
+    {{if .Edit}}<a class="btn-secondary btn-sm" href="/admin/catalog">Cancel</a>{{end}}
+  </div>
+</form>
+<table class="admin-t">
+  <thead><tr><th>Part number</th><th>Description</th><th>Spread price</th><th></th></tr></thead>
+  <tbody>
+  {{range .Catalog}}
+    <tr>
+      <td class="mono">{{.PartNumber}}</td>
+      <td>{{.Description}}</td>
+      <td class="mono">{{printf "$%.4f" .SpreadPrice}}</td>
+      <td style="text-align:right;white-space:nowrap">
+        <a class="editlink" href="/admin/catalog?edit={{.PartNumber}}">edit</a>
+        <form method="post" action="/admin/catalog/delete" style="display:inline;margin-left:12px">
+          <input type="hidden" name="csrf_token" value="{{$.CSRF}}">
+          <input type="hidden" name="part_number" value="{{.PartNumber}}">
+          <button class="btn-danger" type="submit">delete</button>
+        </form>
+      </td>
+    </tr>
+  {{else}}
+    <tr><td colspan="4" style="color:var(--dim)">No catalog entries yet.</td></tr>
+  {{end}}
+  </tbody>
+</table>
+<form method="post" action="/admin/catalog/seed"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="btn-secondary btn-sm">{{if .Catalog}}Re-seed demo parts{{else}}Seed demo catalog{{end}}</button></form>
+{{template "afoot" .}}{{end}}
+
+{{define "subscribers"}}{{template "ahead" .}}
+<h1>Subscribers &amp; users</h1>
+{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
+{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
+
+<h2>Add a subscriber</h2>
+<form class="form-inline" method="post" action="/admin/subscribers">
+  <input type="hidden" name="csrf_token" value="{{.CSRF}}">
+  <label>Company / subscriber name<input name="name" required></label>
+  <div class="btnwrap"><button type="submit">Create subscriber</button></div>
+</form>
+
+<h2>Add a user</h2>
+<form class="form-inline" method="post" action="/admin/users">
+  <input type="hidden" name="csrf_token" value="{{.CSRF}}">
+  <label>Subscriber<select name="subscriber_id" required>{{range .Subs}}<option value="{{.ID}}">{{.Name}}{{if .IsInternal}} (internal){{end}}</option>{{end}}</select></label>
+  <label>Email<input type="email" name="email" required></label>
+  <label>Name<input name="name"></label>
+  <label>Password <span style="color:var(--dim)">(blank = email an invite)</span><input type="password" name="password" minlength="8"></label>
+  <div class="btnwrap"><button type="submit">Create user</button></div>
+</form>
+
+{{range .Subs}}
+<h2>{{.Name}} {{if .IsInternal}}<span class="pill int">internal</span>{{else}}<span class="pill">customer</span>{{end}}</h2>
+<table class="admin-t">
+  <thead><tr><th>Email</th><th>Name</th><th>User ID</th></tr></thead>
+  <tbody>
+  {{range .Users}}<tr><td class="mono">{{.Email}}</td><td>{{.Name}}</td><td class="mono">{{.ID}}</td></tr>
+  {{else}}<tr><td colspan="3" style="color:var(--dim)">No users yet.</td></tr>{{end}}
+  </tbody>
+</table>
+{{end}}
+{{template "afoot" .}}{{end}}
+
+{{define "forgot"}}{{template "head" .}}
+<div class="card">
+  <div class="brand">Spread<span>●</span></div>
+  <h1>Reset password</h1>
+  {{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
+  <form method="post" action="/forgot">
+    <input type="hidden" name="csrf_token" value="{{.CSRF}}">
+    <label>Email<input type="email" name="email" required autofocus></label>
+    <button type="submit">Send reset link →</button>
+  </form>
+  <p class="muted" style="margin-top:1rem"><a href="/login">Back to sign in</a></p>
+</div>{{template "foot" .}}{{end}}
+
+{{define "setpassword"}}{{template "head" .}}
+<div class="card">
+  <div class="brand">Spread<span>●</span></div>
+  <h1>Set your password</h1>
+  {{if .Error}}<div class="err">{{.Error}}</div>{{end}}
+  <form method="post" action="/set-password">
+    <input type="hidden" name="csrf_token" value="{{.CSRF}}">
+    <input type="hidden" name="token" value="{{.Token}}">
+    <label>New password<input type="password" name="password" required minlength="8" autofocus></label>
+    <label>Confirm password<input type="password" name="confirm" required minlength="8"></label>
+    <button type="submit">Set password →</button>
+  </form>
+</div>{{template "foot" .}}{{end}}
+
+{{define "tokenerror"}}{{template "head" .}}
+<div class="card">
+  <div class="brand">Spread<span>●</span></div>
+  <h1>Link expired</h1>
+  <p class="muted">This link is invalid or has already been used. Ask an admin to re-send your invite, or <a href="/forgot">request a new reset link</a>.</p>
+</div>{{template "foot" .}}{{end}}
+`))
diff --git a/internal/app/tokens.go b/internal/app/tokens.go
new file mode 100644
index 0000000..79c2266
--- /dev/null
+++ b/internal/app/tokens.go
@@ -0,0 +1,86 @@
+package app
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"golang.org/x/crypto/bcrypt"
+)
+
+const (
+	inviteTTL = 7 * 24 * time.Hour
+	resetTTL  = 24 * time.Hour
+)
+
+var errBadToken = errors.New("this link is invalid or has expired")
+
+// issueToken creates a one-time token for a user and returns the raw value to
+// embed in a link. Any prior unused token of the same purpose is dropped.
+func (a *App) issueToken(ctx context.Context, userID int64, purpose string, ttl time.Duration) (string, error) {
+	raw := randomToken()
+	_, err := a.pool.Exec(ctx,
+		`DELETE FROM user_tokens WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL`,
+		userID, purpose)
+	if err != nil {
+		return "", err
+	}
+	_, err = a.pool.Exec(ctx,
+		`INSERT INTO user_tokens (token_hash, user_id, purpose, expires_at) VALUES ($1, $2, $3, $4)`,
+		hashToken(raw), userID, purpose, time.Now().Add(ttl))
+	if err != nil {
+		return "", err
+	}
+	return raw, nil
+}
+
+// peekToken validates a token without consuming it (for rendering the form).
+func (a *App) peekToken(ctx context.Context, raw string) error {
+	if raw == "" {
+		return errBadToken
+	}
+	var n int
+	err := a.pool.QueryRow(ctx,
+		`SELECT 1 FROM user_tokens WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`,
+		hashToken(raw)).Scan(&n)
+	if errors.Is(err, pgx.ErrNoRows) {
+		return errBadToken
+	}
+	return err
+}
+
+// consumeToken validates a token, marks it used, and returns the user id.
+func (a *App) consumeToken(ctx context.Context, raw string) (int64, error) {
+	if raw == "" {
+		return 0, errBadToken
+	}
+	var userID int64
+	err := a.pool.QueryRow(ctx,
+		`UPDATE user_tokens SET used_at = now()
+		  WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
+		  RETURNING user_id`,
+		hashToken(raw)).Scan(&userID)
+	if errors.Is(err, pgx.ErrNoRows) {
+		return 0, errBadToken
+	}
+	return userID, err
+}
+
+// setPassword updates a user's password hash.
+func (a *App) setPassword(ctx context.Context, userID int64, password string) error {
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return err
+	}
+	_, err = a.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, string(hash), userID)
+	return err
+}
+
+func (a *App) subscriberForUser(ctx context.Context, userID int64) (Subscriber, error) {
+	var subID int64
+	if err := a.pool.QueryRow(ctx, `SELECT subscriber_id FROM users WHERE id = $1`, userID).Scan(&subID); err != nil {
+		return Subscriber{}, err
+	}
+	return a.subscriber(ctx, subID)
+}
diff --git a/internal/app/users.go b/internal/app/users.go
new file mode 100644
index 0000000..155bdac
--- /dev/null
+++ b/internal/app/users.go
@@ -0,0 +1,139 @@
+package app
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/jackc/pgx/v5"
+	"golang.org/x/crypto/bcrypt"
+)
+
+// User is an authenticated account. A user always belongs to one subscriber.
+type User struct {
+	ID           int64
+	SubscriberID int64
+	Email        string
+	Name         string
+}
+
+// Subscriber is a tenant. The internal subscriber (is_internal=true) sees the
+// admin/CMS view; every other subscriber sees the customer dashboard.
+type Subscriber struct {
+	ID         int64
+	Name       string
+	IsInternal bool
+}
+
+var errNoCredentials = errors.New("invalid email or password")
+
+func normEmail(email string) string {
+	return strings.ToLower(strings.TrimSpace(email))
+}
+
+// authenticate verifies an email/password pair and returns the user.
+func (a *App) authenticate(ctx context.Context, email, password string) (User, error) {
+	var u User
+	var hash string
+	err := a.pool.QueryRow(ctx,
+		`SELECT id, subscriber_id, email, name, password_hash FROM users WHERE email = $1`,
+		normEmail(email),
+	).Scan(&u.ID, &u.SubscriberID, &u.Email, &u.Name, &hash)
+	if errors.Is(err, pgx.ErrNoRows) {
+		return User{}, errNoCredentials
+	}
+	if err != nil {
+		return User{}, err
+	}
+	if hash == "" || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
+		return User{}, errNoCredentials
+	}
+	return u, nil
+}
+
+func (a *App) subscriber(ctx context.Context, id int64) (Subscriber, error) {
+	var s Subscriber
+	err := a.pool.QueryRow(ctx,
+		`SELECT id, name, is_internal FROM subscribers WHERE id = $1`, id,
+	).Scan(&s.ID, &s.Name, &s.IsInternal)
+	return s, err
+}
+
+// InitAdmin ensures the internal subscriber exists and creates or updates the
+// first admin user under it. Used by the `spread init-admin` subcommand.
+func (a *App) InitAdmin(ctx context.Context, email, password string) error {
+	if len(password) < 8 {
+		return fmt.Errorf("password must be at least 8 characters")
+	}
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return err
+	}
+
+	tx, err := a.pool.Begin(ctx)
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback(ctx)
+
+	var subID int64
+	err = tx.QueryRow(ctx, `SELECT id FROM subscribers WHERE is_internal = TRUE ORDER BY id LIMIT 1`).Scan(&subID)
+	if errors.Is(err, pgx.ErrNoRows) {
+		if err = tx.QueryRow(ctx,
+			`INSERT INTO subscribers (name, is_internal) VALUES ('Spread (internal)', TRUE) RETURNING id`,
+		).Scan(&subID); err != nil {
+			return err
+		}
+	} else if err != nil {
+		return err
+	}
+
+	_, err = tx.Exec(ctx,
+		`INSERT INTO users (subscriber_id, email, password_hash, name)
+		 VALUES ($1, $2, $3, 'Admin')
+		 ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, subscriber_id = EXCLUDED.subscriber_id`,
+		subID, normEmail(email), string(hash),
+	)
+	if err != nil {
+		return err
+	}
+	return tx.Commit(ctx)
+}
+
+// OpenAndInitAdmin is a convenience for the init-admin subcommand.
+func OpenAndInitAdmin(ctx context.Context, dsn, email, password string) error {
+	a, err := Open(ctx, Config{DSN: dsn})
+	if err != nil {
+		return err
+	}
+	defer a.Close()
+	return a.InitAdmin(ctx, email, password)
+}
+
+func (a *App) findOrCreateSubscriber(ctx context.Context, name string) (int64, error) {
+	var id int64
+	err := a.pool.QueryRow(ctx,
+		`SELECT id FROM subscribers WHERE name = $1 AND is_internal = FALSE ORDER BY id LIMIT 1`, name,
+	).Scan(&id)
+	if errors.Is(err, pgx.ErrNoRows) {
+		return a.createSubscriber(ctx, name)
+	}
+	return id, err
+}
+
+// OpenAndCreateCustomer creates a customer user, making the named subscriber if
+// it doesn't exist yet. Convenience for the create-customer subcommand.
+func OpenAndCreateCustomer(ctx context.Context, dsn, email, subscriberName, password string) error {
+	a, err := Open(ctx, Config{DSN: dsn})
+	if err != nil {
+		return err
+	}
+	defer a.Close()
+	subID, err := a.findOrCreateSubscriber(ctx, subscriberName)
+	if err != nil {
+		return err
+	}
+	_, err = a.createUser(ctx, subID, email, "", password)
+	return err
+}
diff --git a/internal/spread/analyze.go b/internal/spread/analyze.go
new file mode 100644
index 0000000..9b5e60d
--- /dev/null
+++ b/internal/spread/analyze.go
@@ -0,0 +1,69 @@
+package spread
+
+import "math"
+
+func round(x float64, places int) float64 {
+	p := math.Pow(10, float64(places))
+	return math.Round(x*p) / p
+}
+
+// spreadPrice returns our unit price for a line and whether it was an exact
+// catalog match. It never returns a price above the customer's current price.
+func spreadPrice(li LineItem, cat Catalog) (price float64, matched bool) {
+	if p, ok := cat.Lookup(li.PartNumber); ok {
+		matched = true
+		price = p
+	} else {
+		price = li.UnitPrice * (1 - modelDiscount(li))
+	}
+	price = round(price, 4)
+	if price > li.UnitPrice || li.UnitPrice <= 0 {
+		price = li.UnitPrice
+	}
+	return price, matched
+}
+
+// Analyze re-quotes every line against the built-in catalog (public demo).
+func Analyze(ref string, items []LineItem) Report {
+	return AnalyzeWith(ref, items, BuiltinCatalog{})
+}
+
+// AnalyzeWith re-quotes every line against the given catalog and rolls up the
+// totals. Parts not found in the catalog are priced by the category model.
+func AnalyzeWith(ref string, items []LineItem, cat Catalog) Report {
+	rep := Report{BOMRef: ref}
+	rep.Totals.CutRate = CutRate
+
+	for _, li := range items {
+		price, matched := spreadPrice(li, cat)
+		unitSave := round(li.UnitPrice-price, 4)
+		if unitSave < 0 {
+			unitSave = 0
+		}
+		al := AnalyzedLine{
+			LineItem:    li,
+			SpreadPrice: price,
+			UnitSaving:  unitSave,
+			LineMarket:  round(li.UnitPrice*li.Quantity, 2),
+			LineSpread:  round(price*li.Quantity, 2),
+			Matched:     matched,
+		}
+		al.LineSaving = round(al.LineMarket-al.LineSpread, 2)
+		if al.LineSaving < 0 {
+			al.LineSaving = 0
+		}
+
+		rep.Lines = append(rep.Lines, al)
+		rep.Totals.Market += al.LineMarket
+		rep.Totals.Spread += al.LineSpread
+		rep.Totals.Savings += al.LineSaving
+	}
+
+	rep.Totals.Lines = len(rep.Lines)
+	rep.Totals.Market = round(rep.Totals.Market, 2)
+	rep.Totals.Spread = round(rep.Totals.Spread, 2)
+	rep.Totals.Savings = round(rep.Totals.Savings, 2)
+	rep.Totals.OurCut = round(rep.Totals.Savings*CutRate, 2)
+	rep.Totals.NetSavings = round(rep.Totals.Savings-rep.Totals.OurCut, 2)
+	return rep
+}
diff --git a/internal/spread/catalog.go b/internal/spread/catalog.go
new file mode 100644
index 0000000..a06bf74
--- /dev/null
+++ b/internal/spread/catalog.go
@@ -0,0 +1,117 @@
+package spread
+
+import (
+	"strings"
+	"unicode"
+)
+
+// catalog holds the parts we can source at a fixed unit price, keyed by
+// normalized part number. In the demo these are hardcoded; in production
+// this map would be backed by live supplier quotes.
+//
+// The five parts below match the canonical example on the landing page, so
+// the shipped example-bom.csv reproduces the exact $883.50 savings figure.
+var catalog = map[string]float64{
+	"GRM188R71H104KA93D": 0.0142,
+	"DIN 912":            0.0335,
+	"UL1007":             0.9450,
+	"STM32F103C8T6":      2.9200,
+	"RC0805FR-0710KL":    0.0086,
+}
+
+// categoryDiscount maps a keyword found in a part's description to the
+// fraction we can typically shave off market price for that category.
+// Ordering matters: the first keyword that matches wins.
+var categoryDiscount = []struct {
+	keyword  string
+	discount float64
+}{
+	{"capacitor", 0.21},
+	{"resistor", 0.28},
+	{"inductor", 0.22},
+	{"diode", 0.24},
+	{"transistor", 0.24},
+	{"mcu", 0.24},
+	{"microcontroller", 0.24},
+	{"ic", 0.23},
+	{"regulator", 0.23},
+	{"connector", 0.19},
+	{"header", 0.19},
+	{"led", 0.26},
+	{"crystal", 0.20},
+	{"oscillator", 0.20},
+	{"screw", 0.25},
+	{"bolt", 0.25},
+	{"nut", 0.25},
+	{"washer", 0.27},
+	{"fastener", 0.25},
+	{"standoff", 0.24},
+	{"wire", 0.21},
+	{"cable", 0.21},
+	{"pcb", 0.16},
+	{"enclosure", 0.15},
+	{"heatsink", 0.18},
+}
+
+// defaultDiscount applies when no category keyword matches.
+const defaultDiscount = 0.15
+
+// NormalizePN canonicalizes a part number for catalog lookups: uppercased,
+// trimmed, internal whitespace collapsed. The app stores and looks up catalog
+// keys with the same function so matching is consistent.
+func NormalizePN(pn string) string {
+	pn = strings.ToUpper(strings.TrimSpace(pn))
+	return strings.Join(strings.Fields(pn), " ")
+}
+
+// Catalog is an exact-match supplier price source. Parts it doesn't know fall
+// back to the category model inside the analyzer.
+type Catalog interface {
+	Lookup(partNumber string) (spreadPrice float64, ok bool)
+}
+
+// BuiltinCatalog is the demo's hardcoded catalog, used by the public endpoint.
+type BuiltinCatalog struct{}
+
+func (BuiltinCatalog) Lookup(partNumber string) (float64, bool) {
+	p, ok := catalog[NormalizePN(partNumber)]
+	return p, ok
+}
+
+// tokenize lowercases and splits text into whole words so short keywords
+// like "ic" match only as words, not as substrings of "generic".
+func tokenize(s string) map[string]bool {
+	set := map[string]bool{}
+	for _, f := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
+		return !unicode.IsLetter(r) && !unicode.IsDigit(r)
+	}) {
+		set[f] = true
+	}
+	return set
+}
+
+// modelDiscount estimates a discount for a part we don't stock by name,
+// based on its category plus a small volume bonus for large quantities.
+func modelDiscount(li LineItem) float64 {
+	words := tokenize(li.Description + " " + li.PartNumber)
+	d := defaultDiscount
+	for _, c := range categoryDiscount {
+		if words[c.keyword] {
+			d = c.discount
+			break
+		}
+	}
+	// Volume bonus: up to +4 points for high-quantity lines.
+	switch {
+	case li.Quantity >= 10000:
+		d += 0.04
+	case li.Quantity >= 1000:
+		d += 0.02
+	case li.Quantity >= 100:
+		d += 0.01
+	}
+	if d > 0.45 {
+		d = 0.45
+	}
+	return d
+}
diff --git a/internal/spread/http.go b/internal/spread/http.go
new file mode 100644
index 0000000..02de765
--- /dev/null
+++ b/internal/spread/http.go
@@ -0,0 +1,87 @@
+package spread
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"path/filepath"
+	"strings"
+)
+
+const maxUpload = 8 << 20 // 8 MiB
+
+// Handler is the shared HTTP handler for POST /api/analyze. It accepts either
+// a multipart form (field "bom") or a raw body with ?filename=. It is used
+// both by the self-host server and the Vercel serverless function.
+func Handler(w http.ResponseWriter, r *http.Request) {
+	if r.Method == http.MethodOptions {
+		writeCORS(w)
+		w.WriteHeader(http.StatusNoContent)
+		return
+	}
+	if r.Method != http.MethodPost {
+		writeErr(w, http.StatusMethodNotAllowed, "POST a BOM file to this endpoint")
+		return
+	}
+	writeCORS(w)
+
+	filename, data, err := readUpload(r)
+	if err != nil {
+		writeErr(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	items, err := ParseBOM(filename, data)
+	if err != nil {
+		writeErr(w, http.StatusUnprocessableEntity, err.Error())
+		return
+	}
+
+	rep := Analyze(bomRef(filename), items)
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(rep)
+}
+
+func readUpload(r *http.Request) (filename string, data []byte, err error) {
+	ct := r.Header.Get("Content-Type")
+	if strings.HasPrefix(ct, "multipart/form-data") {
+		if err = r.ParseMultipartForm(maxUpload); err != nil {
+			return "", nil, err
+		}
+		f, hdr, ferr := r.FormFile("bom")
+		if ferr != nil {
+			return "", nil, ferr
+		}
+		defer f.Close()
+		data, err = io.ReadAll(io.LimitReader(f, maxUpload))
+		return hdr.Filename, data, err
+	}
+	// raw body
+	data, err = io.ReadAll(io.LimitReader(r.Body, maxUpload))
+	filename = r.URL.Query().Get("filename")
+	if filename == "" {
+		filename = "upload.csv"
+	}
+	return filename, data, err
+}
+
+func bomRef(filename string) string {
+	base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
+	base = strings.TrimSpace(base)
+	if base == "" {
+		return "BOM-UPLOAD"
+	}
+	return base
+}
+
+func writeCORS(w http.ResponseWriter) {
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
+	w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+}
+
+func writeErr(w http.ResponseWriter, code int, msg string) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(code)
+	_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}
diff --git a/internal/spread/parse.go b/internal/spread/parse.go
new file mode 100644
index 0000000..c6e22b5
--- /dev/null
+++ b/internal/spread/parse.go
@@ -0,0 +1,276 @@
+package spread
+
+import (
+	"archive/zip"
+	"bytes"
+	"encoding/csv"
+	"encoding/xml"
+	"fmt"
+	"io"
+	"path/filepath"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+// ParseBOM turns an uploaded file into line items, dispatching on extension
+// and falling back to CSV. Supported: .csv, .tsv, .txt, .xlsx.
+func ParseBOM(filename string, data []byte) ([]LineItem, error) {
+	switch strings.ToLower(filepath.Ext(filename)) {
+	case ".xlsx":
+		rows, err := readXLSX(data)
+		if err != nil {
+			return nil, err
+		}
+		return rowsToItems(rows)
+	default:
+		rows, err := readCSV(data)
+		if err != nil {
+			return nil, err
+		}
+		return rowsToItems(rows)
+	}
+}
+
+func readCSV(data []byte) ([][]string, error) {
+	data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")) // strip UTF-8 BOM
+	r := csv.NewReader(bytes.NewReader(data))
+	r.FieldsPerRecord = -1
+	r.TrimLeadingSpace = true
+	if bytes.Count(data, []byte{'\t'}) > bytes.Count(data, []byte{','}) {
+		r.Comma = '\t'
+	}
+	return r.ReadAll()
+}
+
+// --- column mapping -------------------------------------------------------
+
+// fieldAliases maps a normalized header token to the canonical field it
+// feeds. Headers are matched case-insensitively with punctuation stripped.
+var fieldAliases = map[string]string{
+	"partnumber": "pn", "part": "pn", "partno": "pn", "pn": "pn",
+	"mpn": "pn", "manufacturerpartnumber": "pn", "mfgpn": "pn", "mfrpartnumber": "pn",
+	"description": "desc", "desc": "desc", "name": "desc", "partdescription": "desc", "component": "desc",
+	"manufacturer": "mfr", "mfr": "mfr", "mfg": "mfr", "brand": "mfr", "maker": "mfr",
+	"quantity": "qty", "qty": "qty", "quantityperbom": "qty", "extendedqty": "qty", "count": "qty",
+	"unitprice": "price", "price": "price", "cost": "price", "unitcost": "price",
+	"currentprice": "price", "currentunitprice": "price", "market": "price",
+	"marketprice": "price", "eachprice": "price", "priceeach": "price",
+}
+
+func normHeader(s string) string {
+	var b strings.Builder
+	for _, r := range strings.ToLower(s) {
+		if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+			b.WriteRune(r)
+		}
+	}
+	return b.String()
+}
+
+func parseNumber(s string) float64 {
+	s = strings.TrimSpace(s)
+	s = strings.NewReplacer("$", "", ",", "", " ", "", "€", "", "£", "").Replace(s)
+	if s == "" {
+		return 0
+	}
+	f, _ := strconv.ParseFloat(s, 64)
+	return f
+}
+
+// rowsToItems finds the header row, maps its columns, and reads the rest.
+func rowsToItems(rows [][]string) ([]LineItem, error) {
+	hdr := -1
+	var colFor map[int]string
+	for i, row := range rows {
+		m := map[int]string{}
+		for j, cell := range row {
+			if f, ok := fieldAliases[normHeader(cell)]; ok {
+				m[j] = f
+			}
+		}
+		// A valid header needs at least a part/description column plus a price.
+		hasName, hasPrice := false, false
+		for _, f := range m {
+			if f == "pn" || f == "desc" {
+				hasName = true
+			}
+			if f == "price" {
+				hasPrice = true
+			}
+		}
+		if hasName && hasPrice {
+			hdr, colFor = i, m
+			break
+		}
+	}
+	if hdr < 0 {
+		return nil, fmt.Errorf("could not find a header row with a part/description column and a price column")
+	}
+
+	var items []LineItem
+	line := 0
+	for _, row := range rows[hdr+1:] {
+		var li LineItem
+		for j, cell := range row {
+			cell = strings.TrimSpace(cell)
+			switch colFor[j] {
+			case "pn":
+				li.PartNumber = cell
+			case "desc":
+				li.Description = cell
+			case "mfr":
+				li.Manufacturer = cell
+			case "qty":
+				li.Quantity = parseNumber(cell)
+			case "price":
+				li.UnitPrice = parseNumber(cell)
+			}
+		}
+		if li.PartNumber == "" && li.Description == "" {
+			continue // blank/spacer row
+		}
+		if li.Quantity <= 0 {
+			li.Quantity = 1
+		}
+		if li.Description == "" {
+			li.Description = li.PartNumber
+		}
+		line++
+		li.Line = line
+		items = append(items, li)
+	}
+	if len(items) == 0 {
+		return nil, fmt.Errorf("no line items found below the header row")
+	}
+	return items, nil
+}
+
+// --- minimal XLSX reader (stdlib only) ------------------------------------
+
+type xlsxSST struct {
+	SI []struct {
+		T string   `xml:"t"`
+		R []string `xml:"r>t"`
+	} `xml:"si"`
+}
+
+type xlsxSheet struct {
+	Rows []struct {
+		Cells []struct {
+			Ref     string   `xml:"r,attr"`
+			Type    string   `xml:"t,attr"`
+			V       string   `xml:"v"`
+			InlineT string   `xml:"is>t"`
+			InlineR []string `xml:"is>r>t"`
+		} `xml:"c"`
+	} `xml:"sheetData>row"`
+}
+
+// colIndex converts a cell reference's column letters ("B12" -> 1).
+func colIndex(ref string) int {
+	n := 0
+	for i := 0; i < len(ref); i++ {
+		c := ref[i]
+		switch {
+		case c >= 'A' && c <= 'Z':
+			n = n*26 + int(c-'A'+1)
+		case c >= 'a' && c <= 'z':
+			n = n*26 + int(c-'a'+1)
+		default:
+			return n - 1
+		}
+	}
+	return n - 1
+}
+
+func readXLSX(data []byte) ([][]string, error) {
+	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+	if err != nil {
+		return nil, fmt.Errorf("not a valid .xlsx file: %w", err)
+	}
+
+	var shared []string
+	var sheetName string
+	for _, f := range zr.File {
+		if f.Name == "xl/sharedStrings.xml" {
+			var sst xlsxSST
+			if err := unmarshalZip(f, &sst); err != nil {
+				return nil, err
+			}
+			for _, si := range sst.SI {
+				if si.T != "" {
+					shared = append(shared, si.T)
+				} else {
+					shared = append(shared, strings.Join(si.R, ""))
+				}
+			}
+		}
+	}
+	// Pick the first worksheet (lexicographically) for the demo.
+	var sheetFiles []string
+	for _, f := range zr.File {
+		if strings.HasPrefix(f.Name, "xl/worksheets/") && strings.HasSuffix(f.Name, ".xml") {
+			sheetFiles = append(sheetFiles, f.Name)
+		}
+	}
+	if len(sheetFiles) == 0 {
+		return nil, fmt.Errorf("no worksheet found in .xlsx")
+	}
+	sort.Strings(sheetFiles)
+	sheetName = sheetFiles[0]
+
+	var sheet xlsxSheet
+	for _, f := range zr.File {
+		if f.Name == sheetName {
+			if err := unmarshalZip(f, &sheet); err != nil {
+				return nil, err
+			}
+		}
+	}
+
+	var out [][]string
+	for _, row := range sheet.Rows {
+		var cells []string
+		for _, c := range row.Cells {
+			idx := colIndex(c.Ref)
+			if idx < 0 {
+				idx = len(cells)
+			}
+			for len(cells) <= idx {
+				cells = append(cells, "")
+			}
+			var val string
+			switch c.Type {
+			case "s": // shared string
+				if i, err := strconv.Atoi(strings.TrimSpace(c.V)); err == nil && i >= 0 && i < len(shared) {
+					val = shared[i]
+				}
+			case "inlineStr":
+				if c.InlineT != "" {
+					val = c.InlineT
+				} else {
+					val = strings.Join(c.InlineR, "")
+				}
+			default:
+				val = c.V
+			}
+			cells[idx] = val
+		}
+		out = append(out, cells)
+	}
+	return out, nil
+}
+
+func unmarshalZip(f *zip.File, v any) error {
+	rc, err := f.Open()
+	if err != nil {
+		return err
+	}
+	defer rc.Close()
+	b, err := io.ReadAll(rc)
+	if err != nil {
+		return err
+	}
+	return xml.Unmarshal(b, v)
+}
diff --git a/internal/spread/types.go b/internal/spread/types.go
new file mode 100644
index 0000000..91e3b2d
--- /dev/null
+++ b/internal/spread/types.go
@@ -0,0 +1,48 @@
+package spread
+
+// CutRate is the only number in Spread's business model: we keep this
+// fraction of the savings we find, and nothing else. If savings are zero,
+// our cut is zero.
+const CutRate = 0.20
+
+// LineItem is one row of an uploaded bill of materials, after parsing.
+// UnitPrice is what the customer pays today (their "market" price).
+type LineItem struct {
+	Line         int     `json:"line"`
+	PartNumber   string  `json:"part_number"`
+	Description  string  `json:"description"`
+	Manufacturer string  `json:"manufacturer,omitempty"`
+	Quantity     float64 `json:"quantity"`
+	UnitPrice    float64 `json:"unit_price"`
+}
+
+// AnalyzedLine is a LineItem re-quoted against the supplier catalog.
+type AnalyzedLine struct {
+	LineItem
+	SpreadPrice float64 `json:"spread_price"`
+	UnitSaving  float64 `json:"unit_saving"`
+	LineMarket  float64 `json:"line_market"`
+	LineSpread  float64 `json:"line_spread"`
+	LineSaving  float64 `json:"line_saving"`
+	// Matched is true when the part was found by exact part number in the
+	// catalog; false when the price was estimated from the category model.
+	Matched bool `json:"matched"`
+}
+
+// Totals is the summary block shown under the table.
+type Totals struct {
+	Lines      int     `json:"lines"`
+	Market     float64 `json:"market"`
+	Spread     float64 `json:"spread"`
+	Savings    float64 `json:"savings"`
+	OurCut     float64 `json:"our_cut"`
+	NetSavings float64 `json:"net_savings"`
+	CutRate    float64 `json:"cut_rate"`
+}
+
+// Report is the full analysis returned to the client.
+type Report struct {
+	BOMRef string         `json:"bom_ref"`
+	Lines  []AnalyzedLine `json:"lines"`
+	Totals Totals         `json:"totals"`
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..11b8ff3
--- /dev/null
+++ b/main.go
@@ -0,0 +1,123 @@
+// Command spread is the self-hostable server. It always serves the public,
+// stateless demo (/, /api/analyze). When DATABASE_URL is set it also mounts the
+// Postgres-backed application: accounts, sessions, /login, /admin, /app.
+//
+// Excluded from the Vercel deploy (see .vercelignore); there the api/ function
+// serves the endpoint and the static files are served by Vercel's CDN.
+package main
+
+import (
+	"context"
+	"embed"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+
+	"spread/internal/app"
+	"spread/internal/spread"
+)
+
+//go:embed index.html app.js example-bom.csv assets
+var static embed.FS
+
+func main() {
+	if len(os.Args) > 1 {
+		switch os.Args[1] {
+		case "init-admin":
+			if err := runInitAdmin(os.Args[2:]); err != nil {
+				log.Fatalf("init-admin: %v", err)
+			}
+			return
+		case "create-customer":
+			if err := runCreateCustomer(os.Args[2:]); err != nil {
+				log.Fatalf("create-customer: %v", err)
+			}
+			return
+		}
+	}
+
+	addr := ":" + envOr("PORT", "8137")
+	mux := http.NewServeMux()
+	mux.HandleFunc("/api/analyze", spread.Handler)
+
+	if dsn := os.Getenv("DATABASE_URL"); dsn != "" {
+		a, err := app.Open(context.Background(), app.Config{
+			DSN:          dsn,
+			SecureCookie: os.Getenv("SPREAD_SECURE_COOKIES") == "1",
+			BaseURL:      envOr("SPREAD_BASE_URL", "http://localhost"+addr),
+			Mailer:       app.NewMailerFromEnv(),
+		})
+		switch {
+		case err == nil:
+			defer a.Close()
+			a.Routes(mux)
+			log.Printf("accounts enabled: /login, /admin, /app (Postgres)")
+		case os.Getenv("SPREAD_DB_OPTIONAL") == "1":
+			// Dev convenience: run the public demo even if Postgres is down.
+			log.Printf("WARNING: database unavailable (%v) — serving public demo only", err)
+		default:
+			log.Fatalf("database: %v", err)
+		}
+	} else {
+		log.Printf("no DATABASE_URL — serving public demo only")
+	}
+
+	mux.Handle("/", http.FileServer(http.FS(static)))
+	log.Printf("spread listening on http://localhost%s", addr)
+	log.Fatal(http.ListenAndServe(addr, mux))
+}
+
+// runInitAdmin creates or resets the first admin user under the internal
+// subscriber. Password is read from SPREAD_ADMIN_PASSWORD.
+func runInitAdmin(args []string) error {
+	if len(args) < 1 {
+		return fmt.Errorf("usage: DATABASE_URL=... SPREAD_ADMIN_PASSWORD=... spread init-admin <email>")
+	}
+	dsn := os.Getenv("DATABASE_URL")
+	if dsn == "" {
+		return fmt.Errorf("DATABASE_URL must be set")
+	}
+	pw := os.Getenv("SPREAD_ADMIN_PASSWORD")
+	if pw == "" {
+		return fmt.Errorf("SPREAD_ADMIN_PASSWORD must be set")
+	}
+	if err := app.OpenAndInitAdmin(context.Background(), dsn, args[0], pw); err != nil {
+		return err
+	}
+	log.Printf("admin user %q ready under the internal subscriber", args[0])
+	return nil
+}
+
+// runCreateCustomer creates a customer user under a (possibly new) subscriber.
+// Password is read from SPREAD_USER_PASSWORD.
+func runCreateCustomer(args []string) error {
+	if len(args) < 1 {
+		return fmt.Errorf("usage: DATABASE_URL=... SPREAD_USER_PASSWORD=... spread create-customer <email> [subscriber-name]")
+	}
+	dsn := os.Getenv("DATABASE_URL")
+	if dsn == "" {
+		return fmt.Errorf("DATABASE_URL must be set")
+	}
+	pw := os.Getenv("SPREAD_USER_PASSWORD")
+	if pw == "" {
+		return fmt.Errorf("SPREAD_USER_PASSWORD must be set")
+	}
+	email := args[0]
+	subscriber := "Customer"
+	if len(args) > 1 {
+		subscriber = args[1]
+	}
+	if err := app.OpenAndCreateCustomer(context.Background(), dsn, email, subscriber, pw); err != nil {
+		return err
+	}
+	log.Printf("customer %q ready under subscriber %q", email, subscriber)
+	return nil
+}
+
+func envOr(key, def string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return def
+}
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..57496a3
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,5 @@
+{
+  "$schema": "https://openapi.vercel.sh/vercel.json",
+  "cleanUrls": true,
+  "trailingSlash": false
+}