Styling the HTML output#

The HTML renderer’s output is a <div class="handcalcs"> with its own small stylesheet. That stylesheet only handles layout (spacing, indentation, grid alignment), and every rule in it is wrapped in :where(), which gives it zero specificity. Any CSS you write for .handcalcs wins, so you can style the output from your page without touching handcalcs.

Option 1: style it from your page#

Target the classes the renderer emits:

.handcalcs {
  font-family: "Iowan Old Style", Georgia, serif;
  color: #1f2937;
}
.handcalcs h1 { color: #0f766e; font-size: 1.25rem; }
.handcalcs .hc-comment { color: #6b7280; font-style: italic; }
.handcalcs .hc-header { font-weight: 600; }
.handcalcs .hc-params { border-top: 1px solid #e5e7eb; }

This is the best option when you control the page (a web app, a report template).

Spacing knobs#

The layout reads four CSS custom properties, set on .handcalcs. Override them the same way:

Property

Default

Controls

--hc-gap

0.35em

Vertical space between lines

--hc-indent

1.5em

Indentation of an if/for body

--hc-param-gap

2em

Space between the columns of a parameter grid

--hc-eq-gap

0.3em

Space on each side of = in a grid

.handcalcs { --hc-gap: 0.6em; --hc-indent: 2em; }

Option 2: ship the style with the renderer#

When the output goes somewhere you don’t control (an email, a notebook, a file you hand to someone), put the styles in the renderer. HTMLRenderer.STYLE is the stylesheet, emitted once per render; extend it in a subclass:

from handcalcs.renderers import HTMLRenderer

class BrandHTMLRenderer(HTMLRenderer):
    name = "brand_html"
    STYLE = HTMLRenderer.STYLE + (
        ".handcalcs :where(h1,h2,h3){color:#0f766e;}"
        ".handcalcs :where(.hc-comment){color:#6b7280;}"
    )

Keep wrapping your selectors in :where() so a host page can still override them.

A color theme with dark mode#

Define the colors as custom properties, then redefine them in a prefers-color-scheme: dark media query:

STYLE = HTMLRenderer.STYLE + (
    ".handcalcs{--hc-accent:#0f766e;--hc-muted:#6b7280;}"
    "@media (prefers-color-scheme:dark){.handcalcs{--hc-accent:#5eead4;--hc-muted:#9ca3af;}}"
    ".handcalcs :where(h1,h2,h3,h4,h5,h6){color:var(--hc-accent);}"
    ".handcalcs :where(.hc-comment){color:var(--hc-muted);}"
)

Option 3: add your own markup#

CSS can only style elements that exist. To style a part of a line, such as the result of each calculation, add an element with a rule. A calc_line:post rule receives each calculation line after it is rendered, as a list of parts (["M", "=", "w · L²/8", "=", "5 · 6²/8", "=", "22.5", "(kN m)"]), and can change them:

@ThemedHTMLRenderer.register("calc_line:post")
def highlight_result(renderer, rendered, node, base_context):
    """Wrap the result of a one-line calculation in <span class="hc-result">."""
    if not isinstance(rendered, list):
        return rendered  # a multi-line (# hc: -m) grid is already one string
    assign = base_context.current.assign
    positions = [i for i, part in enumerate(rendered) if part == assign]
    if len(positions) < 2:
        return rendered  # a parameter (target = value): leave inputs plain
    result = positions[-1] + 1
    rendered[result] = f'<span class="hc-result">{rendered[result]}</span>'
    return rendered

The full renderer, with the style for .hc-result and a dark-mode theme:

"""An HTMLRenderer with a color theme and highlighted results."""
from handcalcs.renderers import HTMLRenderer


class ThemedHTMLRenderer(HTMLRenderer):
    name = "themed_html"

    # Extra rules appended to the built-in stylesheet. They use :where() too, so
    # a host page can still override them.
    STYLE = HTMLRenderer.STYLE + (
        ".handcalcs{--hc-accent:#0f766e;--hc-muted:#6b7280;--hc-result-bg:#ccfbf1;}"
        "@media (prefers-color-scheme:dark){.handcalcs{"
        "--hc-accent:#5eead4;--hc-muted:#9ca3af;--hc-result-bg:#134e4a;}}"
        ".handcalcs :where(h1,h2,h3,h4,h5,h6){color:var(--hc-accent);}"
        ".handcalcs :where(.hc-comment){color:var(--hc-muted);font-style:italic;}"
        ".handcalcs :where(.hc-header){color:var(--hc-accent);font-weight:600;}"
        ".handcalcs :where(.hc-result){background:var(--hc-result-bg);"
        "padding:0 0.25em;border-radius:3px;font-weight:600;}"
    )


## Beam check
# Simply supported beam with a uniform load.
w = 5  # kN/m
L = 6  # m
M = w * L**2 / 8  # kN m
if M > 20:
    status = "check deflection"
Output (ThemedHTMLRenderer)

Beam check

Simply supported beam with a uniform load.

w = 5 (kN/m)
L = 6 (m)
M = w · L2 / 8 = 5 · 62 / 8 = 22.5 (kN m)
Since (M > 20) → (22.5 > 20) is True:
status = “check deflection”
<div class="handcalcs"><style>.handcalcs{--hc-accent:#0B5F66;--hc-accent-dark:#91CFD4;--hc-guide-1:#85B8BD;--hc-guide-2:#D5B386;--hc-guide-3:#A8B0C7;}:is([data-jp-theme-light="false"],.vscode-dark,.vscode-high-contrast,[data-theme="dark"]) .handcalcs{--hc-accent-text:var(--hc-accent-dark);--hc-tint:color-mix(in srgb,var(--hc-accent) 30%,transparent);--hc-highlight:color-mix(in srgb,var(--hc-accent) 38%,transparent);}.handcalcs{--hc-gap:0.35em;--hc-indent:1.5em;--hc-param-gap:2em;--hc-eq-gap:0.3em;--hc-accent-text:var(--hc-accent);--hc-tint:color-mix(in srgb,var(--hc-accent) 9%,transparent);--hc-highlight:color-mix(in srgb,var(--hc-accent) 12%,transparent);--hc-muted:color-mix(in srgb,currentColor 60%,transparent);--hc-chip:color-mix(in srgb,currentColor 6%,transparent);display:flex;flex-direction:column;gap:var(--hc-gap);line-height:1.6;font-variant-numeric:tabular-nums;}.handcalcs :where(.hc-block,.hc-body){display:flex;flex-direction:column;gap:var(--hc-gap);}.handcalcs :where(.hc-body){margin-inline-start:0.55em;padding-inline-start:calc(var(--hc-indent) - 0.55em);border-inline-start:2px solid var(--hc-guide);--hc-guide:var(--hc-guide-1);}.handcalcs :where(.hc-body .hc-body){--hc-guide:var(--hc-guide-2);}.handcalcs :where(.hc-body .hc-body .hc-body){--hc-guide:var(--hc-guide-3);}.handcalcs :where(.hc-body .hc-body .hc-body .hc-body){--hc-guide:var(--hc-guide-1);}.handcalcs :where(.hc-body .hc-body .hc-body .hc-body .hc-body){--hc-guide:var(--hc-guide-2);}.handcalcs :where(.hc-line,.hc-header,.hc-comment){margin-block:0;}.handcalcs :where(.hc-header){background:var(--hc-tint);border-radius:4px;padding:0.1em 0.5em;}.handcalcs :where(.hc-header)::before{content:"\25B8";color:var(--hc-accent-text);margin-inline-end:0.4em;}.handcalcs :where(.hc-kw){color:var(--hc-accent-text);}.handcalcs :where(.hc-result){color:var(--hc-accent-text);background:var(--hc-highlight);font-weight:600;padding:0 0.25em;border-radius:3px;-webkit-box-decoration-break:clone;box-decoration-break:clone;}.handcalcs :where(.hc-note){color:var(--hc-muted);}.handcalcs :where(.hc-check){font-weight:700;}.handcalcs :where(.hc-pass){color:#1A7F37;}.handcalcs :where(.hc-fail){color:#CF222E;}.handcalcs :where(.hc-import){align-self:start;font-family:var(--jp-code-font-family,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);font-size:0.85em;color:var(--hc-muted);background:var(--hc-chip);padding:0.05em 0.5em;border-radius:3px;}.handcalcs :where(.hc-comment,.hc-note) :where(code){font-size:0.9em;background:var(--hc-chip);padding:0 0.25em;border-radius:3px;}.handcalcs table.hc-params{margin:0;border:0;border-collapse:separate;border-spacing:0;table-layout:auto;width:auto;align-self:center;font-size:inherit;color:inherit;font-variant-numeric:tabular-nums;background:var(--hc-tint);border-radius:4px;padding:0.1em 0.6em;}.handcalcs table.hc-params.hc-multiline{align-self:start;background:none;border-radius:0;padding:0 0 0 0.5em;border-inline-start:2px solid var(--hc-accent-text);}.handcalcs table.hc-params tr:nth-child(n){background:none;}.handcalcs table.hc-params td{padding:0;border:0;vertical-align:baseline;line-height:inherit;white-space:normal;max-width:none;}.handcalcs table.hc-params td.hc-param-id{text-align:right;padding-inline-end:var(--hc-eq-gap);}.handcalcs table.hc-params td.hc-param-eq{text-align:center;}.handcalcs table.hc-params td.hc-param-val{text-align:left;padding-inline-start:var(--hc-eq-gap);}.handcalcs table.hc-params td.hc-param-gap{width:var(--hc-param-gap);}.handcalcs table.hc-params td.hc-param-comment{text-align:left;padding-inline-start:var(--hc-eq-gap);}.handcalcs :where(.hc-pre){white-space:pre;}.handcalcs :where(.hc-matrix){display:inline-table;vertical-align:middle;border-inline:1px solid currentColor;border-radius:0.2em;padding:0 0.15em;}.handcalcs :where(.hc-matrix-row){display:table-row;}.handcalcs :where(.hc-matrix-cell){display:table-cell;text-align:right;padding:0 0.3em;line-height:1.2;}.handcalcs{--hc-accent:#0f766e;--hc-muted:#6b7280;--hc-result-bg:#ccfbf1;}@media (prefers-color-scheme:dark){.handcalcs{--hc-accent:#5eead4;--hc-muted:#9ca3af;--hc-result-bg:#134e4a;}}.handcalcs :where(h1,h2,h3,h4,h5,h6){color:var(--hc-accent);}.handcalcs :where(.hc-comment){color:var(--hc-muted);font-style:italic;}.handcalcs :where(.hc-header){color:var(--hc-accent);font-weight:600;}.handcalcs :where(.hc-result){background:var(--hc-result-bg);padding:0 0.25em;border-radius:3px;font-weight:600;}</style><h1>Beam check</h1><p class="hc-comment">Simply supported beam with a uniform load.</p><div class="hc-line">w = 5 <span class="hc-note">(kN/m)</span></div><div class="hc-line">L = 6 <span class="hc-note">(m)</span></div><div class="hc-line">M = w · L<sup>2</sup> / 8 = 5 · 6<sup>2</sup> / 8 = <span class="hc-result"><span class="hc-result">22.5</span></span> <span class="hc-note">(kN m)</span></div><div class="hc-block"><div class="hc-header"><span class="hc-kw">Since</span> (M &gt; 20) → (22.5 &gt; 20) <span class="hc-kw">is True:</span></div><div class="hc-body"><div class="hc-line">status = “check deflection”</div></div></div></div>

The rule finds the result by position: the part after the last = (the context’s assign symbol). Lines with a single = are parameters, which it leaves plain. Registration explains rules in full.

Escaping#

The HTML renderer does not escape the text of comments, so a comment can contain markup (# See <a href="...">clause 13.3</a>). Don’t render untrusted text with it.

Next: LaTeX and Markdown tokens.