# MEGA INFRA · BOQ Converter Drop in **our** Candy PDF and **their** Excel. Get back their workbook populated, using their own formulas and layout, plus a proof report of exactly what was written, what was already correct, and where the two documents disagree. Anything you disagree with, you settle **in the app** and regenerate. The consultant never has to leave the screen. Runs entirely on your machine. Nothing is uploaded anywhere. The sidebar shows a **build fingerprint** — a hash of the `.py`/`.css`/`.toml` actually copied into the image, computed during the Docker build. It reads e.g. `build d7d427a2e770`. A commit SHA was the obvious first choice and was wrong twice over: Coolify does not pass a commit SHA into a Docker Compose build, so an `ARG` silently falls back to `dev` forever; and a file naming the commit cannot live in the commit it names, so it could only ever name the parent. A hash of the shipped files is true by construction and cannot go stale. ![the app](docs/app-screenshot.png) ## Settling a disagreement in the app Where the two documents differ, the app writes **nothing** — your value stands. Each dispute is one collapsed row. Open it and choose: - **Keep yours (as it is)** — the default, your file is untouched - **Use our PDF's value** — take the figure from the Candy export - **or type a value** — anything else, coerced to a number when it looks like one Press **Apply & regenerate** and the choices are written straight into the workbook; the row then moves to "settled" and drops off the open list. The PAYMENT column, your formulas and your formatting are never touched. ![disputes](docs/app-disputes.png) ## Design system — MEGA INFRASTRUCTURE Palette sampled from the dashboard. All tokens live in **`static/theme.css`** as CSS custom properties, so the brand can be retuned in one place without touching Python. | group | tokens | |---|---| | surfaces | `--canvas #1A202C` · `--topbar #1A212D` · `--surface #262A37` · `--surface-inset #252A36` · `--surface-raise #363B49` · `--hairline #2F3442` | | status fills | `--success-fill #22383E` / `--success-edge #1B534A` · `--warn-fill #3A373C` · `--danger-fill #3A2D38` | | accents | `--orange #FEA944` · `--orange-soft #F1A962` · `--orange-deep #DF8441` · `--green #00CD8A` · `--red #F04141` | | text | `--text #E1E1E1` · `--text-muted #999999` · `--text-warm #B3AC99` · `--text-link #00CD8A` | Warm neutrals are deliberate — `#E1E1E1` sits on a warm axis and pairs with `#B3AC99`; pure `#EEEEEE` would read cold next to `#FEA944`. **On the accessibility flag:** `#F04141` measures 3.77:1 on the card surface, so it is used only for the severity edge and large marks. Any red *text* uses `--red-soft #FF6B6B` at 5.15:1, which clears AA. Type is **Fraunces** for headings and **Nunito** for body. ### Icons are real SVG, not emoji Thirteen icons live in `boq_engine/theme.py` as inline SVG — 24x24, 1.75 stroke, round caps, so the set looks drawn by one hand. They inherit `currentColor`, so they pick up the palette for free, and being inline they need no network and no icon font. They sit in the brand mark, the uploader labels, the KPI tiles, the pills and the section headings — everywhere HTML actually renders. **Not** on buttons or tab labels. Streamlit renders those as *plain text*, so an inline SVG there leaks to the user as literal `` markup, and emoji read as emoticons. Those labels are deliberately typographic, which looks more corporate anyway. ### Two implementation notes worth knowing **The stylesheet is injected with `st.html`, not `st.markdown`.** Markdown injection duplicated the style block on every rerun, and one malformed selector silently killed every rule after it — the page rendered unstyled with no error anywhere. Serving it from `static/` was tried and doesn't work: Streamlit only serves its own hashed assets from that route, and an arbitrary path falls through to the SPA shell and returns `index.html` as `text/html`. `st.html` inserts the sheet into the page, isn't iframed, and replaces cleanly. **Buttons and tab labels are plain text.** See the icon note above — this is the one place Streamlit refuses to render HTML, and it fails loudly. **Status messages are quiet pills, not alert blocks.** A filled `st.warning` bar shouts at the reader for something that is background information. `theme.pill()` sits at the same volume as the legend it belongs next to. Genuine errors — source lines that have nowhere to go, subtotals that do not reconcile — still use real alerts, because those are the thing to act on. **Dispute choices are real Streamlit widgets.** Raw HTML `