clarity-tax-report-ui
v1.7.0
Published
Shared components, tokens and runtime for Clarity Tax HTML reports. Renders identically under the platform's Nunjucks engine and a report repo's local Jinja2 harness.
Maintainers
Readme
clarity-tax-report-ui
Shared components, design tokens and runtime for Clarity Tax HTML reports.
A report stays hand-written HTML. What this package supplies is the structure: macros for the markup, one token set for every colour and dimension, and the grid, drawer and formatting behaviour that every report was otherwise reimplementing.
New here? Open docs/architecture.html — the flow
end to end, what each file holds, and what you can and cannot override.
npm install clarity-tax-report-ui
npx ct-report init template.html # once
npx ct-report sync template.html # after every upgradeAdoption is the report repo's decision. Install it and your template can call
ct.*; do nothing and your report renders exactly as it does today. Nothing in
the platform changes either way — there is no backend code in this design.
How it reaches the page
ct-report maintains one region of your template.html, between
<!-- ct:begin --> and <!-- ct:end -->. Inside it: the compiled stylesheet,
the runtime, and the macro definitions. Outside it: your report, untouched.
<head>
<!-- ct:begin 1.0.0 — generated by ct-report, do not edit inside these markers -->
<style>…tokens + components…</style>
<script>…runtime…</script>
{% macro data_table(s) %}…{% endmacro %}
{% set ct = { "data_table": data_table, … } %}
<!-- ct:end -->
</head>
<body>
{{ ct.report_head(report_title, generated_on) }} ← yours
</body>Why one file and not src/ → dist/. Report repos have no CI. A two-file
build fails invisibly: edit the source, forget to rebuild, and the platform
keeps serving the previous output with no warning and wrong figures. Here the
author edits the same file the platform renders, so an unbuilt edit is
impossible. Delete or corrupt the block and the render throws with a line
number. The worst remaining failure is a stylesheet a release or two old.
The dual-engine constraint
The same template.html is rendered by two engines:
| where | engine | how |
|---|---|---|
| platform | Nunjucks 3.2.4 | ReportGeneratorEngine, autoescape on, null loader |
| local | Jinja2 3.x | the report repo's run_report.py |
Every macro here lives in the intersection of the two languages, which is
narrower than either. test/parity.mjs renders each one under both and fails
the build on any difference — so a construct that works locally cannot reach a
release and break on the platform.
If you write template code of your own, these are the traps. Each was measured, not assumed:
Empty collections. [] and {} are truthy in Nunjucks (JavaScript) and
falsy in Jinja2 (Python). {% if rows %} takes opposite branches when the
collection is empty — silently, on the one input nobody tests. Use
{% if rows|length %}. An empty string is falsy in both, so {% if title %}
is fine.
Loose equality. Nunjucks == is JavaScript's. They agree for numbers and
null and disagree for the empty string: "" == 0 is true in Nunjucks,
false in Jinja2. So {% if v or v == 0 %} — the guard that lets a legitimate
zero through — is safe only where v is a number or null. Nunjucks has ===;
Jinja2 does not, so there is no portable strict comparison.
Null rendering. {{ v }} where v is null renders empty in Nunjucks and
None in Jinja2, and |default('') does not help — default only fires on
undefined. Guard every nullable value explicitly.
|tojson is not portable. Nunjucks emits raw JSON; Jinja2 escapes it to
". Use ct.data_script(key, section.rows_json), which applies |safe to
the string the engine already serialised.
Banned outright: .items(), {% for k,v in dict %}, s[:-3] slicing,
|slice, |round, |map, |selectattr, namespace,
{% for %}{% else %}, is defined, is none, and {% set %} carrying a value
out of a loop. Use |dictsort for dict iteration.
Numbers are formatted by the runtime, not the template — there is no
portable way to group thousands, because Jinja2 has string slicing and Nunjucks
does not. ct.num emits the raw value plus data-ct-num, and the runtime
rewrites it in place, so the figure stays readable if the runtime never runs.
Commands
| command | what it does |
|---|---|
| ct-report init [file] | insert the managed block (default template.html) |
| ct-report sync [file] | refresh the block from the installed version |
| ct-report check [file] | exit non-zero if the block is missing, stale or hand-edited |
| ct-report doctor [file] | show versions and validate the ct.* calls in the template |
Add them to the report repo so nobody has to remember the paths:
"scripts": {
"sync": "ct-report sync templates/my_report.html",
"check": "ct-report check templates/my_report.html",
"doctor": "ct-report doctor templates/my_report.html"
}Components
Run npm run gallery and open gallery/index.html for every component in
every variant, rendered by the shipped macros.
Primitives — surface stack inline grid text. Everything else
composes from these, which is why a KPI card and a table container share one
border rule instead of two that happen to agree.
Numbers — num money pct. Grouped thousands, parenthesised negatives in
red, tabular numerals. The conventions match the xlsx theme, so a report and its
spreadsheet export agree.
Document — doc_open doc_close report_head section_open
section_close footnote divider page_break.
Controls — toolbar_open/close toolbar_label toolbar_spacer search
button count segmented basis_toggle tabs tab_panel accordion_open.
Data — data_table for any engine section; matrix_open for the
frozen-column grid; buildup_table for a detail ladder.
Meta — kpi_open/kpi/kpi_close key_values filter_chips badge
callout empty_state.
Transport — data_script config_script.
The matrix, and the spine contract
The platform engine hands templates flat query results and does not pivot, so
the grid is built client-side. Rather than every report writing its own pivot,
ctReport.matrix() consumes one agreed row shape — one row per
(logical line × entity):
row_key parent_key depth row_type agg_rule label is_expandable
entity_code entity_name local reporting [section_number sort_order]row_type is one of spacer | group | leaf | subtotal | section_total |
grand_total | rate. A report that can produce this shape gets freezing, the
tree, entity filtering, column resize and cell selection without writing any of
it.
agg_rule drives the frozen Total column. sum and none are built in;
anything else is looked up in opts.aggregators, so a report keeps ownership of
rules only it understands:
var matrix = ctReport.matrix(spine, {
basis: 'local',
aggregators: {
// An effective tax rate is not the sum of its columns: it is one
// section's total over another's, recomputed on the visible entity set.
recompute_ratio: function (line, codes, basis, m) {
var num = m.sectionTotal(21, basis);
var den = m.sectionTotal(1, basis);
return num !== null && den ? num / den : null;
}
},
onCellSelect: function (info, td) { /* open a drawer */ }
});
ctReport.wireToolbar(matrix);Invariants it encodes
Four structural rules that cost provision_report a debugging round each, now
written down once:
border-collapse: separate— withcollapse, sticky cells lose their borders entirely.- The z-index ladder: body-scroll 0 < body-frozen 1 < head-scroll 2 < head-frozen 3 < resize 4. Put head-frozen below head-scroll and the frozen headings paint underneath the scrolling ones, so scrolling right leaves the Total figures pinned under an entity's heading.
- Every row type sets an opaque background. Sticky cells paint their own; a transparent one lets scrolling figures show through the frozen column at any z-index.
- The body reserves the same
.5pxright border the head has, as transparent.box-sizingis border-box, so a border the head has and the body lacks makes the two content boxes differ, and identical padding then lands the heading and its figures on different right edges.
Runtime API
window.ctReport, available once the block is in the page.
| | |
|---|---|
| version | the version that produced this block |
| format(v, dp, pct) | one figure, as a string |
| formatAll(root) | rewrite every [data-ct-num] under root |
| numSpan(v, opts) | a formatted <span> for runtime-built cells |
| data(key) | read a ct.data_script payload |
| config() | read the ct.config_script payload |
| matrix(spine, opts) | mount the grid; returns the instance |
| wireToolbar(m, ids) | bind basis, filter, expand, collapse and count |
| drawer(id) | drawer controller: open close setHead renderBuildup |
Number formatting, segmented controls, tabs and accordions bind themselves on load. You only call in for the matrix and the drawer.
Versioning
major.minor.patch, and adopting repos pin ^1, so every repo on the major
renders identically without anyone coordinating.
| bump | trigger | effect on ^1 |
|---|---|---|
| major | a macro removed or renamed, a required parameter added, a token removed | not taken; migrate on your own schedule |
| minor | new macro, new optional parameter, new token, new variant | taken on next sync; existing markup unchanged |
| patch | CSS internals, class renames, spacing fixes | taken on next sync |
Class names are internal and undocumented. Templates call macros and never
write a .ct-* class, which is what keeps most visual work to a patch — and
what stops a report from quietly drifting away from the others. If you need a
variation, it becomes a macro parameter, reviewed once and then available
everywhere.
The caveat. The caret resolves at sync time, not render time. With no CI
in report repos, a patch reaches a repo only when someone runs
npm update && ct-report sync. "Everyone on ^1 looks identical" is really
everyone who has synced since the release.
Developing this package
npm run build # tokens + css + macros + runtime -> dist/
npm run check:literals # fail on a raw hex or px outside the token layer
npm run test:parity # render every macro under Nunjucks AND Jinja2, diff
npm run gallery # gallery/index.html
npm test # all of the abovenpm test runs on prepublishOnly, so none of these can be skipped on the way
to a release.
Publishing
The package is public and unscoped, so no npm org is involved. npm does,
however, require either 2FA or a granular token to publish, even on an account
whose profile reports two-factor auth: disabled — a plain npm publish
returns:
403 Forbidden - Two-factor authentication or granular access token
with bypass 2fa enabled is required to publish packages.A classic token is not enough. One will authenticate — npm whoami
returns the username — and then fail the publish with the same 403. Classic
tokens are the ones npm token list shows; granular tokens do not appear
there, which is a quick way to tell which you have.
Either path works:
# 1. interactive, with 2FA enabled on the account
npm publish --access public --otp=123456
# 2. with a GRANULAR access token
# npmjs.com -> Access Tokens -> Generate New Token -> Granular
# - Permissions: Packages and scopes -> Read and write
# - Packages: All packages (see the note below)
# - Check: Bypass two-factor authentication
# This is also what CI needs.
export NPM_TOKEN=<granular token>
npm publish --access public.npmrc in this repo reads ${NPM_TOKEN} from the environment rather than
storing a token, so the secret never lands on disk or in git.
First publish needs "All packages" scope. A granular token restricted to named packages cannot create one that does not exist yet, so a token scoped to
clarity-tax-report-uiwill fail the initial publish with a 404 or 403. Use "All packages" for the first release, then narrow the token afterwards if you want to.
prepublishOnly runs the full gate first, so a build that fails the token
guard, the aggregation tests or dual-engine parity cannot be released.
After the first successful publish, point adopting repos at the registry instead of the working copy:
# in the report repo
npm pkg set dependencies.clarity-tax-report-ui=^1.0.0
npm installContributing
Three rules for contributors:
- Tokens only. Component CSS references
var(--ct-*)and nothing else.check:literalsenforces it; add the value tosrc/tokens/tokens.json, or allow it explicitly with a reason if it is structural rather than a design decision. - Compose from the primitives. A new component is built from
surface,stack,inline,textandgrid, not authored standalone. - Add a parity fixture. Every new macro gets one in
test/parity.mjs. The loose-equality bug above passed 14/14 fixtures because none of them had a blank param — it was caught downstream in a report. A macro without a fixture covering its awkward inputs is not covered.
