Skip to content

Repository files navigation

Panel

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.

The landing page

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.

The studio

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.

The studio

npm install && npm run dev     # http://localhost:5178

The report in document mode

What it renders

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 custom tool

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 notification email

The same tool, drawn with tables, inside the email.

Running it

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 order

Layout

src/
  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

How Elements is used

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. cells takes arbitrary weights rather than only the eight preset ColumnLayouts, 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 no if (mode === ...) in the template.
  • The email card is held to 600px with columnsBackgroundColor on each Row. Row backgroundColor paints 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 strip data: URIs from img src, so the email carries the wordmark as type rather than a broken-image icon.

Three things worth knowing (Elements 0.1.20)

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 prop

A 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.

The report in web mode

Licence

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.

About

A pathology report, its results email, and a live studio. One React tree renders print, PDF, web, email and the text/plain part, with a custom Unlayer Elements tool that redraws itself as tables for Outlook.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages