A pathology report, the email that delivers it, and a studio where you can move the numbers and watch the document rewrite itself. Built with Unlayer Elements.
Open the live site → · no install, no account, everything renders in your browser
Most templates are a layout with the copy swapped out. This one is a layout with a decision in it. Every result carries a reference interval, and the document works out the rest: what is flagged, how far out it is, which three markers earn the summary strip and the email, what each panel's tally reads, how many markers are out of range. Change one number and five parts of the page rearrange.
Nothing on that page is a screenshot. The report in the hero is rendered live in the browser by the same code that writes the PDF, and the walkthrough hands you a slider wired straight into the real template.
Behind the call to action is the studio. Drag any result past a reference bound and the flag flips, the summary cards reorder by severity, the counts move, the scale tick slides, and all five outputs re-render. Four preset patients are included, one of them unremarkable, which makes the summary strip vanish entirely.
npm install && npm run dev # http://localhost:5178| Output | Mode | File |
|---|---|---|
| Pathology report, print and PDF | <Document> |
out/report.print.html, out/report.pdf |
| The same report for a patient portal | <Page> |
out/report.web.html |
| Results-ready notification | <Email> |
out/email.html |
The text/plain MIME part of that email |
renderToPlainText |
out/email.txt |
| Unlayer design JSON, loadable in the drag-and-drop editor | renderToJson |
out/email.design.json |
The rendered output is checked in, so you can open out/report.pdf without
running anything.
The one thing no built-in component can draw is the answer to "how far out?".
So the reference scale is a custom Elements tool, registered with registerTool
and shipping two exporters, because the two runtimes disagree about
everything:
web one relative box, band and tick positioned absolutely
email two stacked tables; the tick is a border-left on a cell whose
left neighbour is exactly as wide as the result's position
Outlook ignores position: absolute and will not honour a percentage width on a
div, so the email build reaches the same geometry the only way a mail client
will follow: percentage-width table cells. Same values, same component, one
definition. document inherits the web exporter, and every coloured surface
carries print-color-adjust: exact or the PDF drops the graphic entirely.
Because it is a real registered tool, renderToJson emits it as
{ "type": "custom", "slug": "reference_scale", values }, which is exactly the
shape the Unlayer builder stores. The design round-trips into the editor.
The same tool, drawn with tables, inside the email.
Requires Node 20+ and a local Chrome (the screenshot and PDF step drives the browser you already have instead of downloading its own).
npm install
npm run dev # the studio, live
npm run render # HTML, plain text and design JSON into out/
npm run build:site # production build of the studio into dist/
npm run shoot # screenshots into docs/screenshots/, PDF into out/
npm run typecheck
npm run build # all of the above, in ordersrc/
data/schema.ts the LabReport shape: lab, patient, sample, panels, sign-off
data/sample-report.ts one fictional patient, internally consistent
lib/flags.ts low/normal/high classification, severity ranking, counts
lib/scale.ts result and interval to three percentages on a track
tools/ReferenceScale.tsx the custom Elements tool, with both exporters
assets/mark.ts the lab's mark, as an inline SVG data URI
theme.ts colours, type stacks, widths
templates/primitives.tsx shared label/field/rule builders
templates/LabReport.tsx the report, as an array of Rows
templates/ResultsReadyEmail.tsx the notification
studio/ the interactive harness (not part of the template)
scripts/
render.tsx writes out/
shoot.ts screenshots and PDF via Playwright and your Chrome
Elements does the whole layout. There is no hand-written page HTML, only the
inline <b> and <span> inside Paragraph's html prop, which is what that
prop is for, and the custom tool's own exporters.
<Row cells={[44, 17, 13, 26]}>drives the results ledger.cellstakes arbitrary weights rather than only the eight presetColumnLayouts, which is what makes a real tabular report possible.- Hairline rules are
<Column border={{ borderBottomWidth: "1px", ... }}>rather than divider elements, so each ledger line owns its rule and they stay aligned to the gutter. - The gap between the summary cards is a
cells={[32, 2, 32, 2, 32]}spacer column, because a Column's background paints its full width and a margin would not survive the email exporters. reportRows()returns a flat array of Rows and knows nothing about mode.labReportDocument()wraps it in<Document>,labReportPage()wraps the identical array in<Page>. There is noif (mode === ...)in the template.- The email card is held to 600px with
columnsBackgroundColoron each Row. RowbackgroundColorpaints outside the content width and spills the card across the full width of the client. - The letterhead mark is an
<Image>with an inline SVG data URI, in the document only. Gmail and Outlook.com stripdata:URIs fromimg src, so the email carries the wordmark as type rather than a broken-image icon.
These cost me time, so they are written down here rather than in a commit message.
Hand renderToHtml the Body element itself, not a component wrapping it.
It reads contentWidth, backgroundColor and fontFamily off the element it is
given in order to build the document head. Pass <MyReport /> and it sees only
{ report }, so the page still renders, just at the default 500px in the default
font, with no error. That is why the builders here return the element:
renderToHtml(labReportDocument(report)) // works
renderToHtml(<LabReportDocument report={report} />) // silently loses every body propA Column renders its children by calling each one's Elements exporter. A
custom React wrapper placed inside a Column is never exported, so shared pieces
here are builder functions returning arrays of real Elements, not components.
Same reason a Row must be a direct child of the Body: Body clones only its
direct children to pass bodyValues down, and arrays flatten but fragments do
not.
noStackMobile did not apply in web mode. Below 480px <Page> stacks every
column, which suits marketing rows and ruins a four-column ledger. The
no-stack class the stacking CSS looks for was never emitted, whether set as a
flat prop or through values. The portal screenshot is therefore taken at 560px.
MIT. The lab, the patient, the doctor and every result in sample-report.ts are
invented. Nothing here is medical advice or a real clinical document.




