Changelog
All notable changes to this package are documented here. Format loosely follows Keep a Changelog.
3.3.0 (2026-09-10)
Accessibility, and the CI that would have caught it.
Four operations in the editor existed only as mouse gestures — a keyboard
user could not perform them at all. One of them, renaming a table column, was
a regression introduced by 3.2.0: it removed the "Columns (header, comma)"
field, which was the only keyboard path, and left a double-click on a <span>
in its place.
Added
- A rename button (pencil) on every table column chip, next to
ƒx, style and remove. A real<button>, so Tab reaches it and a screen reader announces it — which the double-click never was. The double-click still works; it is now the shortcut, not the only way. - A rename button on every row of the field list, for the same reason and in the same shape.
IconPenciljoins the exported icon set (now 21).- The three modals are now real dialogs.
role="dialog",aria-modal="true", andaria-labelledbypointing at the heading already on screen (so the two cannot drift apart). Plus the behaviour that makes the markup mean something: focus moves into the dialog on open, Tab is trapped inside it, and focus returns to the element that opened it on close. Shared incomponents/ui/useDialogA11y.ts, becausePdfPreviewModaldoes not go through theModalshell. PdfPreviewModalcloses with Escape. It was the only modal in the package that closed on click alone.
Fixed
- Selecting a field was mouse-only. The field-list row carried the
onClickand nothing in the list was focusable except the action buttons, so a keyboard user could not select a field at all — which gates every edit, since the property panel follows the selection. The field name is now a<button>witharia-pressed; the row stays clickable as a mouse convenience, markedrole="presentation"because it is no longer the accessible control. Same treatment for the KPI sub-element rows. - The "hide tab" × was a
<span role="button">inside a<button>— nested interactive content, which is invalid HTML, and with notabIndex, so hiding a tab was mouse-only too. It is a sibling<button>now, inside a new.jpd-tab__slotwrapper. The<button className="jpd-tab">itself is untouched on purpose: itsdata-activeis read by aquerySelector, by the CSS and by the JSX, and those three move together. Visible difference: the active-tab underline now ends at the label instead of running under the ×. PdfPreviewModal's close button had no accessible name — a bare×glyph in a button, announced as just "button".- Renaming replaced nothing. Both rename inputs open with the text
selected. Before, the caret landed at the end and typing appended
(
"PNR"+ what you typed) — found in the browser, not in a test. Worth recording the second half:onFocusdoes not fix this. React'sautoFocusfocuses during commit, before the handler is resolvable, so it never fires (measured:selectionStartandselectionEndboth at the end). It takes a stable ref callback — and stable matters, since an inline arrow would re-select on every keystroke.
Changed
jsx-a11yis enabled in the linter. It was simply absent fromplugins, which is how 25 findings accumulated unseen. Fourteen rules are errors; four are off with the reason written next to them in.oxlintrc.json, and the reason is the point:click-events-have-key-events,no-static-element-interactionsandno-noninteractive-element-interactionsfire on gesture surfaces — the canvas sheet, the column resize handle, the KPI drag, the double-click that swaps an image, the cell that enters inline editing. None has a sensible keyboard equivalent ("drag this 3mm right" is not a key), and all are reachable another way: position, size, column width, image and cell content are all editable from the property panel, which is plain form fields. Turning them on would produce 13 warnings that only go quiet by puttingrole="button"and an emptyonKeyDownon<div>s that are not buttons — lying to the screen reader to satisfy the linter.interactive-supports-focusstays an error, and it is the rule that caught the tab-bar ×.no-autofocusis off because in an inline editor the autofocus is the correct behaviour.CardTitlepasseschildrenexplicitly instead of through the spread. With<h3 {...rest} />theheading-has-contentrule cannot see any content and flags everyCardTitleas an empty heading; written this way the rule stays on and still catches a genuinely empty one.viewportRefis typedMutableRefObject, notRefObject— see the CI section for why.
CI
The accessibility work above was invisible to CI, and so were three other things the package claims:
peerDependenciesacceptsreact ^18 || ^19, and only one was ever tested. A newreact-18job downgrades React and runs typecheck, tests and build. It failed immediately, on two real cross-major type incompatibilities that a React 18 consumer building from source would have hit:Slot<P, E>in the UI registry. In@types/react18 the call signature of an exotic component (whatforwardRefreturns) yieldsReactNode, whileFunctionComponentinsideComponentTypeyieldsReactElement | null— narrower, so our own twelveforwardRefcomponents stopped being assignable to their own slots (12 × TS2322). The type is now a union that covers both majors, without loosening anything for the consumer.viewportRef.@types/react19 madeRefObjectmutable and hasuseRef<T|null>(null)returnRefObject<T|null>; in 18RefObject<T>has a readonlycurrent, and a<div>'srefprop there rejects that instantiation.MutableRefObject<T|null>is{ current: T|null }in both.
enginespromisesnode >=18, and nothing tested it. A Node matrix on the main job would not work: the dev toolchain does not run on 18 (vitest 4 wants^20||^22||>=24, oxlint wants^20.19||>=22.12). But a consumer does not run vitest — it importsdist. So the newconsumer-nodejob packs with a supported Node and then installs and executes the tarball on 18, 20 and 22, which is the promise actually being made.- The docs site was only a gate after merge. The Docusaurus build ran only
in
pages.yml, which fires on push to master — so broken MDX reached master and failed the deploy, not the PR. And MDX breaks easily here: the changelog and the data-binding page are full of{...}and<...>, which MDX reads as expression and JSX. The newdocsjob builds both locales, because a missing translation or MDX error only in the pt-BR mirror would not show up in theenbuild.
Tests
test/a11y.test.tsx (new, 14 cases): the dialog markup asserted without a DOM
(which is exactly why ModalShell is separate from Modal), source guards
for each operation that was mouse-only, and a guard that the linter plugin
stays enabled. All 14 were mutation-tested — 12 deliberate regressions, all
caught. Two things that pass worth writing down, because both are guard
failure modes this repo has been bitten by before:
- The first version matched its own comment. The comments here quote the
old code on purpose (
era um <span role="button">), so the source guards read throughstripComments— which already existed intest/support/classScan.ts. toContain("useEscapeToClose")passed on the import line: deleting the call and keeping the import did not fail it. Found by mutating the guard itself. It checks for the call now.
3.2.0 (2026-09-04)
A table column now has an identity of its own. Renaming its title changes the
title and nothing else, the ƒx editor always opens with the token that is
actually in use, and every JSON key has a form that can be written as a path —
including the ones that had none before.
Added
-
Bracketed paths —
{[token]}. The permissive lexing this format has always had (an operator is only an operator with whitespace on both sides, so{my-key}and{my key}are paths) covered almost every JSON key, but not all of them: a key with a literal dot, or with(/)/,/", or with a space-surrounded operator inside it, had no form at all. The delimited form names them:Written Resolves to {[id]}the key id{[cliente].[nome]}walks cliente→nome{[cliente.nome]}the literal key "cliente.nome"— a dot inside the brackets does not separate{["token name"]}the key "token name"— a segment with a space must be quoted{[total] + 1}arithmetic; the operator is outside the brackets {CURRENCY([total], "R$", 2)}a bracketed path as a function argument A segment with a space has to be quoted (
"or', whichever the content allows), and that is a deliberate refusal rather than an inconvenience:[a + b]is ambiguous between the key"a + b"and arithmetic inside the brackets, and picking one silently is worse than saying no.emptySegment,unclosedBracketandspaceInSegmentare the three new expression error codes.The AST's
pathnode now carriessegments: string[]instead ofpath: string. That is not cosmetic: a dotted string cannot represent[a.b](one segment containing a dot), which is exactly the ambiguity the brackets exist to resolve. -
Rename a column by double-clicking it in the "Current table columns" list, and the new
renameTableColumn(index, label)action behind it. It changeshead[index]and, for a calculated column, the binding'slabel— and touches nothing else. -
tokenFor(key)— the single rule for turning a key into a token, used by the newly bound table, by the normalizer, and by theƒxfield chips, so the three cannot disagree. PlussegmentForfor the segment alone. -
columnFormulaFor(content, columns, index)— the same precedence the PDF uses, exported for anyone drawing their own panel. -
makeBoundTable(nextY, path, columns)— a table already bound to an array path, with each column's token filled in. This was outside the package entirely, and all five examples in this repo reimplemented it — wrong in the same way (see Fixed). -
normalizeTableColumns(template, bindings)— converts raw-key columns into{label, formula}in data that is already saved. Idempotent, and exported fromjson-pdf-designer/servertoo, since normalising a back catalogue is as much a script's job as an editor's. It is not called automatically: rewriting the consumer's template on mount would be an invisible side effect.
Changed
-
{[a]}used to mean the key literally named[a](the lexer did not treat brackets, so they were absorbed into the identifier) and now means the patha. A JSON key with a bracket in its name is written{["[a]"]}. Everything else is untouched:{cliente.nome},{my-key}and{my key}are still the paths they always were.- {[a]} // the key named "[a]"+ {["[a]"]} // the same key, after 3.2.0 -
The "Columns (header, comma)" field is gone from the table panel. It was the cause of the rename bug, not a casualty of the fix: it replaced the whole
headon every keystroke. The actionsetTableHeadand theonSetHeadListprop remain exported — a consumer renderingPropertyPanelTabledirectly does not break; the panel simply no longer has a control that rewrites the list. -
The canvas cell hides the brackets.
{[id]}is what is stored and{id}is what is drawn —displayCellalready did this for{CURRENCY(x, "R$", 2)}. In a 68-column table, 68 pairs of brackets is noise, and the stored form stays explicit.
Fixed
-
The
ƒxeditor of a table column opened empty. The panel seeded it frombinding.columns[i], and only when that entry was an object, while the PDF readscontent[0][i]first. So on a table bound from a data source — whosecolumns[i]is a plain string and whosecontent[0][i]holds the token — the editor was reading the fallback store while the PDF read the primary one. The token was there, it worked, and the interface never showed it. Theƒxbutton's highlight came from the same wrong read, so it did not light up either. Both now go throughcolumnFormulaFor, which is the resolver's own precedence. -
Renaming a column title lost its data reference. There was no rename operation. The only way to change a title was the comma-separated field, which called
setTableHeadwith the entire list on every keystroke; that re-derives every slot by matching each new name against the old head, and a renamed name is by definition absent from the old head. So the column fell through to the fallback and lostcontent[0][i](the token — the thing that decides the PDF),columnStyles[i]andcolumnWidths[i], whilebinding.columns[i]was overwritten with the new title as a JSON key. Renaming was indistinguishable from "delete column X, insert column Y".Verified in
report-builder: after renaming,binding.columns[2]is stillfare_amount, the design cell is still100.00, and the app's own Ctrl+Z undoes the rename — so it goes through the consumer'ssetStatelike every other mutation. -
A table dropped from the field tree was born without tokens. Building a bound table was never in the package (
onCanvasDropis a raw passthrough), so each example did it by hand and wrotecontent: [columns.map((c) => c.toUpperCase())]— a brace-free placeholder ("DESCRICAO"), which the resolver deliberately does not treat as a template. That is why the dropped table had an emptyƒxwhile a table bound through the panel did not. All five examples now go throughtokenFor, andmakeBoundTableputs the whole thing in the package. -
handleChangeBindingwrote the unbracketed form. Binding a table through the panel emitted`{${c}}`, which is wrong for any key with a dot, a space or a parenthesis. It usestokenFornow, and the columns it writes are{label, formula}rather than raw keys.
Internal — file layout
No public name changed: publicSurface.test.ts keeps an explicit inventory of
every runtime export in both directions, and it is untouched by these moves.
The exports map in package.json is closed (., ./server, ./preview,
the two stylesheets), so no internal path was ever reachable from a consumer's
import. What did change is source paths, which matters because
ARCHITECTURE.md and package-structure list them.
The shape this arrived at: thirteen folders under src/, none of them
holding a single file, and the biggest flat listing is components/ui/ at 21
— which is 21 one-component files, so the count is the point. Before, three
folders held one file each and src/ itself held ten non-entry files.
-
src/fields/groups the three per-field-type folders that were sitting at the root as siblings ofpdf/anddesigner/:fields/table/,fields/kpi/,fields/chart/. They are the same kind of thing — the pure logic one field type owns — andtypes/schema.tsalready spells out that field types are the axis the whole package turns on.kpi/itself replacedsrc/kpiFormat.ts, whose name described one of its thirteen exports (the other twelve are card defaults, corner radius, and per-element position/lock); it isfields/kpi/card.tsplusfields/kpi/format.tsnow. -
src/page/holdsunits.ts,sizes.ts(waspageSizes.ts) andzones.ts. This reverses a call made earlier in this same section: they were listed as "deliberately not moved", on the argument that autil/folder would be a junk drawer. That argument was right aboututil/and wrong about these three — they are not utilities, they are the sheet itself: how big it is, how it converts between mm/px/pt, and which band a field lands in.page/is a topic, not a drawer, which is the whole difference. -
src/template.tsreplacessrc/template/migrate.ts— a folder holding exactly one file, which is a directory level that carries no information. -
src/i18n/locales/en.ts,locales/pt-BR.ts. The two dictionaries were 1115 of the folder's 1219 lines, so the seven files that hold the actual i18n logic were buried under them in every listing. Nothing about the React-free boundary changed:dictionaries.tsstill holds the locale →Dictmap with no React import, which is what lets/serverreach a dictionary, andtest/entryBoundaries.test.tsstill guards it. -
src/expressions/engine/holdstokenize.ts,parse.ts,evaluate.ts,functions.tsandformatters.ts. This one was measured rather than eyeballed: of the twelve files inexpressions/, exactly those five have zero importers outside the folder, and the other seven (resolve.ts,templateText.ts,schemaExpressions.ts,suggest.ts,suspicious.ts,errors.ts,dataAccess.ts) have between two and seven each. The folder boundary now says what the import graph already said. -
src/components/PropertyPanel/andsrc/components/formula/take eleven files out of a twenty-file flat listing. The panels follow the shapeFieldBox/already established — folder named after the component, the component inindex.tsx, the per-type variants alongside it — soimport { PropertyPanel } from "../../components/PropertyPanel"resolves unchanged.formula/is lowercase because it is a topic and not a component:FormulaModal.tsxis the editor, butFormulaButton.tsxis imported on its own by the property panels, so naming the folder after either one would misdescribe it. -
test/followssrc/where it had fallen behind:test/page/holds the units/sizes/zones tests, andtest/template/migrate.test.tsbecametest/template.test.ts.test/expressions/was deliberately left flat —brackets.test.tsexercisestokenize,parseandevaluatetogether, so it has no side of anengine/split to sit on, and forcing one would put the folder boundary somewhere the tests do not actually divide.
Four comments in designer/ pointed at src/bindingBuilders.ts and
src/canvasGeometry.ts — two files that had already stopped existing in
earlier releases. They now name src/bindings/builders.ts and
src/canvas/geometry.ts. A comment citing a path that resolves to nothing is
the same rot as stale prose, and it is harder to notice because nothing
compiles it.
One guard caught a move on its own: the "no throw builds its message from a
dictionary" test reads a hardcoded list of seven source files, and
template/migrate.ts was one of them. It failed with a raw ENOENT from
inside the read helper — a real failure, but one whose message pointed at the
helper instead of at the list. It now asserts each target exists first, so the
next rename says "a target of this guard moved — update the list".
Still at the root of src/, and why: three of the eleven files are the entry
points (index.ts, server.ts, preview.ts). The other eight — errors.ts,
errorUtils.ts, fieldWarnings.ts, materialIcons.ts, numberFormat.ts,
schemaFactory.ts, drag.ts, template.ts — each have importers spread
across three or more folders, which is what "lives at the root" means. The
earlier note about errorUtils.ts stands and is worth repeating, because the
reasoning was nearly inverted by file size: it was considered for merging into
errors.ts on the grounds that it is eight lines against 639. The importer
graph says the opposite — errorUtils.ts has two importers and both are on
the React side, errors.ts has twelve and none of them import React. They sit
on opposite sides of the /server boundary. Two concerns, two files.
3.1.0 (2026-09-03)
Added
-
useDesignerZoom()— the canvas zoom is readable and writable from outside the canvas. Reported against 3.0.1: building the editor fromDesignerProvider+ loose parts, there was no way to read the current zoom, no way to trigger zoom/fit/reset from your own toolbar, and no way to move the zoom bar out of the canvas — the default.jpd-zoombarisposition: stickyinside.jpd-designer__canvas, so CSS could only shuffle it around that box's corners. The only alternative was reimplementing zoom from scratch, with a second copy of the value to drift out of sync with the sheet actually being rendered.const { zoom, min, max, setZoom, zoomIn, zoomOut, reset, fitWidth, fitHeight } = useDesignerZoom();Plus
viewportRef(the scrolling element, filled in by the mountedDesignerCanvas), the typeDesignerZoomValue, andclampZoom/ZOOM_MIN/ZOOM_MAX/ZOOM_STEPso a hand-built control cannot disagree with the canvas about the limits. -
<DesignerCanvas hideZoombar />— removes the default control and nothing else. The sheet, the rulers and the zoom itself keep working. -
<PageCanvas zoom onChangeZoom hideZoombar />— the component is now controlled-or-uncontrolled on zoom, decided by the presence of the prop. Omit it and it owns its own zoom exactly as before, which is what the headless path uses.- Why a separate context, and not one of the five. The original
decision to keep zoom internal had a real argument behind it: dragging
a slider would re-render every part if the value lived in the provider.
That was right about the problem and wrong about the solution — what
cascades is the value living in a context every part reads. Zoom got its
own context, so only components calling
useDesignerZoom()re-render: your bar and the canvas. The field list, the inspector and the property panels stay put. Same argument that already gave the primitive registry its own context. fitWidth()/fitHeight()measure the canvas's viewport, not the window, which is why they work from a button that is nowhere near the canvas: the mountedDesignerCanvasregisters itself inviewportRef. With no canvas mounted they do nothing on purpose — the old fallback waswindow.innerWidth, and that is how "fit width" once produced 113% with 338px of page off-screen.- Building a slider: use
step="any", notstep={step}. Withmin={0.25}andstep={0.1}a range input snaps to the grid 0.25 / 0.35 / … / 1.05, so asking for 1 gives 105% and the slider never lands on exactly 100%.ZOOM_STEPis the increment for the +/− buttons; the scale is continuous. examples/composed-layoutnow demonstrates it: its bar sits beside the page tabs, outside the canvas, with<DesignerCanvas hideZoombar />— a real slider, a live percentage, and fit/reset buttons.
- Why a separate context, and not one of the five. The original
decision to keep zoom internal had a real argument behind it: dragging
a slider would re-render every part if the value lived in the provider.
That was right about the problem and wrong about the solution — what
cascades is the value living in a context every part reads. Zoom got its
own context, so only components calling
Fixed
clampZoom(NaN)returnedNaN, and that reachedtransform: scale(NaN)— the sheet vanished with nothing in the console and nothing in the DOM looking wrong.Math.max(0.25, NaN)isNaN, andNaNsurvives the followingMath.min. It now returns 1 (100%), becauseNaNmeans "no value" and 100% is the least surprising answer;Infinitystill clamps to the maximum, since there a value does exist. Reachable two ways:fitWidth()over a page whosewidthisNaN, and a hand-built bar passingNumber("")from an emptied input.- The
fitWidth()guard usedpaginaPx <= 0, andNaN <= 0isfalse, so aNaNpage size went straight through the check into the division. It testsNumber.isFinitenow. test/publicSurface.test.tswas guarding one third of the surface. Its inventory filtered for PascalCase, so every hook (camelCase), helper (camelCase) and constant (SCREAMING_CASE) was unchecked — 104 runtime exports with no guard at all, in a suite that claimed to cover the public API "in both directions".useDesignerZoom,clampZoomand the threeZOOM_*entered the public API with the whole suite green, which is how it was found. All 104 are declared now, and the hook case checks both directions.
3.0.0 (2026-09-02)
Takes Tailwind out of the package, and breaks <Designer> into pieces you
place yourself. Those are one change and not two: the preset could only
become positionable pieces once the markup stopped carrying utility strings
that a Tailwind build had to resolve. What ships instead is a hand-written
theme.css over .jpd-* classes and --jpd-* custom properties, a
<DesignerProvider> with ten pieces and ten hooks around it, and a registry
that swaps the editor's own Button/Input/Modal for yours.
Breaking
-
json-pdf-designer/style.cssno longer exists — importjson-pdf-designer/theme.css. Both./style.cssand./dist/style.cssleft theexportsmap, and there is no alias, so the old import now fails to resolve at build time (ERR_PACKAGE_PATH_NOT_EXPORTED).- import "json-pdf-designer/style.css";+ import "json-pdf-designer/theme.css";One line is all you need:
theme.cssimports the reset itself.- Why: the package shipped compiled Tailwind, and that included Preflight — a global reset, landing in your app, from a stylesheet whose only job was to paint one editor.
- Why not an alias: it would quietly hand over a different stylesheet (no Preflight, new internals) and you would find out from a page that renders wrong. A resolve error is the better failure, because it happens in the build and points at this entry.
- Tailwind left the package entirely:
tailwindcssand@tailwindcss/cliare out ofdevDependencies, thebuild:cssscript is gone,src/style.cssis deleted, andnpm run buildis now justtsup.theme.cssandreset.cssare hand-written and not minified, on purpose — they are the public contract you read to learn the class and token names, and your bundler minifies them anyway.
-
theme.cssdoes not carry a global reset. Every.jpd-*class now carries the reset the element under it depended on — the job Preflight used to do. The only rule with a*in it is scoped to the editor's own roots and wrapped in:where(), which gives it zero specificity: any rule of yours beats it.- What Preflight gave you for free, and may now be missing from your
markup:
box-sizing: border-box,margin/padding: 0on headings, paragraphs and lists,list-style: none,font/color/backgroundon form controls,svg { display: block },codein a monospace family,table { border-collapse: collapse }, andappearance: button. If your app was leaning on the package's stylesheet for those, put back the ones you need. - New export
json-pdf-designer/reset.css— the appearance-free subset, for styling.jpd-*from scratch without taking the theme. Importing both is redundant, not an error. - The trap almost everyone hits writing a reset by hand:
border: 0setsborder-style: none, and then any laterborder-widthcomputes to zero. It has to beborder: 0 solid. Preflight wrote it that way deliberately; both of our stylesheets do too.
- What Preflight gave you for free, and may now be missing from your
markup:
-
Dark mode has a documented hook now:
[data-jpd-theme="dark"]. Put it on<html>or on any ancestor..darkstill works, kept as an alias of what 2.x used (@custom-variant dark (&:where(.dark, .dark *))) — verified in the browser that the two paint identically.[data-jpd-theme="light"]forces light.- No media query, on purpose: a library should not turn your light-only
app dark because the OS is. Follow the OS by reading
matchMediaand writing the attribute yourself. - Limitation worth knowing: a scoped dark island — the attribute on
a container rather than on
<html>— does not paint a portalled modal.<Modal>renders throughcreatePortal(document.body), which is exactly why the tokens live on:root.
- No media query, on purpose: a library should not turn your light-only
app dark because the OS is. Follow the OS by reading
-
Every class inside
<Designer>changed. The markup is.jpd-*plusdata-*now, in ajpd-block__element--modifierconvention with one element level; state is adata-*attribute, never a class (the rule: if the JSX would have to concatenate or choose a class string, it is adata-*). CSS you wrote against the old Tailwind class names stops matching.- Checked against this repo's own examples:
examples/custom-uistyles only the containers around the editor, with no descendant selector reaching into its markup, so no in-repo consumer broke. That is also the shape that keeps working — style your own shell, and target.jpd-*on purpose when you want to redefine the editor.
- Checked against this repo's own examples:
-
Modal:maxWidthClass→size. The old prop took a Tailwind class string, which cannot survive a package with no Tailwind in it.- <Modal title="Fields" maxWidthClass="max-w-3xl" onClose={close}>+ <Modal title="Fields" size="lg" onClose={close}>sizeis"sm" | "md" | "lg" | "xl" | "full", defaulting to"lg"= 48rem, which is whatmax-w-3xlgave. An arbitrary width isstyle={{ maxWidth: 900 }}, andstylenow reaches the panel.- No internal call site passed
maxWidthClass, and it was not documented anywhere — it only ever existed in the code — so dropping it does not invalidate any prose we wrote.
- No internal call site passed
-
classNamestops concatenating and starts merging.cxdedupes by exact token and returnsundefined(not"") when nothing is left, so components that used to emitclass="… "with a trailing space no longer do —Cardwas the worst of them, appending a space even with noclassNameat all. If you assert on markup, that space is gone.- Token order inside the
classattribute now decides nothing. It never decided cascade even with Tailwind, but with no utilities left there is nothing for it to decide:.jpd-btnand your class have the same specificity, so the winner is stylesheet order. Whoever needs to beattheme.cssloads their CSS after it, or usesstyle, which always wins.
- Token order inside the
-
Kit components forward a ref, so they are no longer plain functions.
Button,Input,Select,Textarea,Modal,Card,TabPaneland the rest areforwardRefcomponents:Button.nameis no longer"Button", and callingButton(props)directly stops working. It isforwardRefand not React 19'sref-as-a-prop because React 18 is still a supported peer.- The 20 icons are the exception. They stay plain functions with no
ref, and their props widened from
{ className }toIconProps=SVGAttributes. Deliberately notSVGProps, which extendsClassAttributesand would accept arefthat goes nowhere — the type would lie.
- The 20 icons are the exception. They stay plain functions with no
ref, and their props widened from
-
monono longer addsfont-mono, and no component emits a Tailwind class at all. OnInputandTextarea,monobecame adata-monoattribute thattheme.cssstyles.PaletteSwatchesandPalettePickerlost every prop whose value was a Tailwind class (size = "h-4 w-4",swatchSize,swatchGap,swatchShrink); since the clean API is the one making its public debut, there is no migration note for those.- This retires the "Using your own Tailwind installation" workflow.
There are no utilities left in
distto scan, so every@sourceorcontentrecipe pointing at this package is now actively harmful advice: it silently produces nothing. Bring your own Tailwind by all means — just point it at your own files and style.jpd-*with it.
- This retires the "Using your own Tailwind installation" workflow.
There are no utilities left in
-
The DOM structure inside
<Designer>changed too, not only the class names: each piece renders its own wrapper, with ajpd-*class on the root. If you were reaching into the editor's markup with a descendant selector, or with a Tailwind arbitrary variant like[&_.foo]:…on a container, that can break. Nothing in this repo did — the arbitrary-variant search overexamples/at 2.1.1 comes back empty. -
Double-click-to-collapse is an affordance of
DesignerSidebar, not of each piece. Inside<Designer>it is identical: double-click the active tab and the panel collapses.DesignerTabBarwrites the flag;DesignerSidebaris what reads it and collapses. So a standalone<DesignerTabBar>gives you a double-click with no visible effect, which is the right behavior for something with nothing to collapse.- Why not per piece: the
TabPaneltrick is a grid1fr→0fr, and that needs aflex columnparent withmin-block-size: 0. A loose piece cannot guarantee that parent, so per-piece collapse would have animated wrong, in silence, instead of degrading.
- Why not per piece: the
-
sidebarTabno longer gates a panel unless you ask it to. Tab gating is opt-in, throughwhenTab: with nowhenTaba piece renders always; withwhenTab="pagina"(or an array of tabs) it appears only there. That is howDesignerSidebarand<Designer>reproduce today's behavior, so inside the preset nothing changed.- Why: if gating were the default, putting
DesignerPropertyPanelandDesignerPageSettingsside by side in your own layout would erase one of them, because only one tab can be active. They would be pieces that look decomposed but only work inside a sidebar with tabs, which cancels the whole feature.
- Why: if gating were the default, putting
-
PdfPreviewModaldoes not inherit primitives from a<Designer>elsewhere on the page. It lives in thejson-pdf-designer/previewentry and resolves from the nearestUiComponentsProvider— and<Designer>'s provider only wraps<Designer>'s own subtree. If you want your Button inside the preview modal, put a<UiComponentsProvider>above the modal too (the same module-level constant is fine). -
generatePdfthrows typed errors now, anderror.messageis English. This is the one break in this list that has nothing to do with CSS or markup — it reaches code that never imports a stylesheet, including a backend using thejson-pdf-designer/serverentry.Up to 2.1.1 every failure was a plain
Error, and its message was hardcoded Portuguese — for everyone, including consumers running the editor in English. The only way to tell one failure from another was to match that prose:- // 2.1.1 — matching a Portuguese sentence, in an app that may be English- if (/tamanho inválido/.test(err.message)) showPageSizeHelp();- else if (/Paginação travada/.test(err.message)) reportBug(err);+ // 3.0.0 — matching a class+ if (err instanceof InvalidPageSizeError) showPageSizeHelp(err.pageId);+ else if (err instanceof PaginationStalledError) reportBug(err);There are 18 concrete classes under one abstract base (
PdfGenerationError, abstract on purpose — nothing should throw the base). Each carries a literalcodeand the structured fields the failure actually knows, so nothing has to be parsed back out of a sentence:class InvalidPageSizeError extends PdfGenerationError {readonly code = "invalidPageSize";readonly blame = "template";constructor(readonly pageId: string, readonly width: number, readonly height: number) { … }}-
Why
messageis English: it is the diagnostic layer — the console, your error tracker, the string someone pastes into an issue. A message that changes language makes log grouping depend on the reader's locale, and it makes a stack trace unsearchable. -
This adds localization, it does not remove it. The text a person reads now comes from the same dictionary you already hand the editor:
const problem = describePdfError(err, dictFor(locale));// → { code, blame, title, action?, field?, detail } | nulltitleandactionare inlocale;detailis the raw English message. So an English consumer stops seeing Portuguese, and switchinglocalere-translates. -
The trap, and it is the whole reason
describePdfErroris a separate call: never put the described text in state. Store the raw error and describe it while rendering. A translated phrase held in state freezes in the language it was created in, so the language switch leaves that one piece of the screen behind — and nothing fails, it just renders stale. We hit this thirteen times in this repo's own examples while migrating them, which is why all five now store a reason and resolve the text at render.- catch (err) { setError(describePdfError(err, dictFor(locale))); }+ catch (err) { setErrorBox({ err }); } // the raw error+ // …and in the render body:+ const problem = errorBox && describePdfError(errorBox.err, dictFor(locale)); -
describePdfErrorreturnsnullfor an error that is not the package's, on purpose, instead of inventing a title for a failure it does not understand. Your own errors keep your own copy — seeexamples/*/src/lib/generationError.tsfor the shape. -
blameis"data" | "template" | "config" | "package"— the same distinction a backend uses to choose between 4xx and 500, so it should not exist in two versions.blame: "package"means it is our bug, not the caller's template. -
Everything above is exported from both entries —
json-pdf-designerandjson-pdf-designer/server— so a server classifies a failure without pulling in React.
-
The cascade layer, and why it is not a regression
theme.css lives inside @layer json-pdf-designer. That is what makes the
className you pass always count: unlayered CSS beats layered CSS
regardless of specificity, so any rule of yours wins over ours by default,
with no specificity fight. The layer order is declared before the first rule
(@layer json-pdf-designer, utilities;) so it holds no matter which
stylesheet you import first.
The side effect is that a bare element selector — button { … } in your
global CSS — also wins, and reaches the editor's chrome. This is not new
in 3.0.0. It was measured: 2.x's dist/style.css was Tailwind v4 output,
and Tailwind v4 emits @layer theme / @layer base / @layer utilities,
so an unlayered element selector already won there. The layer preserves
2.1.1's cascade position rather than changing it. The advice is the same as
it was: scope by class, and target .jpd-* on purpose when you mean to
redefine the editor.
Added
<DesignerProvider>— the state provider, and the thing that makes the rest of this list possible. Props:template,onChangeTemplate,bindings,onChangeBindings,onCanvasDrop,dataSources,gridSizeMm,expandOnSelect,children.Designer.tsxwent from 986 lines to 101, because that is all the preset is now: three providers and two pieces in a two-column layout.- Ten positionable pieces:
DesignerCanvas,DesignerTabBar,DesignerFieldList,DesignerToolbar,DesignerPageSettings,DesignerPropertyPanel,DesignerFilterPanel,DesignerBindingEditor,DesignerInspector, andDesignerSidebar— the last one a convenience that composes the seven content pieces with the preset's tab gating.- Every piece takes
className(merged, yours last),style(yours wins) andwhenTab. Several takeparts, addressing the elements inside them by role, plus flags likeheading,hint,headerandposition. DesignerPropertyPaneltakessection="dados" | "estilo"as a prop rather than reading the active tab, so two instances with different sections render together.- Not positionable, on purpose:
FieldBox/*and thePropertyPanel{Text,Table,…}panels, which are dispatched byschema.type. A loose<DesignerTextPanel/>has no answer to "which schema?" other than "the selected one", and then it is just a worseDesignerPropertyPanel.
- Every piece takes
- Ten public hooks. Accessors
useDesignerData,useDesignerActions,useDesignerSelection,useDesignerUi,useDesignerConfig; selectorsuseDesignerSelectedSchema,useDesignerFieldListSchemas,useDesignerBulkEdit,useDesignerTabWarnings,useDesignerFilterColumns.- The five contexts are split by how often they change, so a piece
reads only what it uses.
useDesignerActionsnever changes identity — it is the load-bearing one, the reason a memoized piece can hold a mutator without re-rendering every time the template changes — whileuseDesignerDatachanges on every edit. - Used outside the provider they throw, with a message naming the
provider, instead of returning
null. A piece with no state has no fallback at all, so a silentnullwould leave you staring at a hole in the screen.
- The five contexts are split by how often they change, so a piece
reads only what it uses.
UiComponentsProvider/useUiComponents/defaultUiComponents— swap the primitives the editor uses internally. Twelve slots:Button,Input,ColorInput,Select,Textarea,Checkbox,Modal,Card,CardHeader,CardTitle,Badge,TabPanel. Every*Propstype is exported so an adapter is about five lines, typed at both ends, and the example writes it withsatisfies UiComponentsOverride.<Designer components={…}>is sugar for mounting the provider.undefinedon a key means inherit from the provider above, not back-to-default. To get ours back, name it:{ Modal: defaultUiComponents.Modal }.- The gotcha to read before writing an adapter: a slot receives props
as the caller wrote them, and the defaults live inside our components.
<Button>destructures{ variant = "primary", size = "sm" }in its own signature, so a caller writing<Button>ok</Button>sendsvariant: undefinedto your adapter — measured: five of the Toolbar's six buttons arrive with novariant. Your adapter needs its own defaults. The editor's are Button primary/sm, Modallg, andTabPanel.collapsedwith no default. - Unstable identity is the sharpest edge. An inline object creates a new component type on every render and React remounts what you slotted — the symptom is the field losing focus on every keystroke. Hoist the map to a module constant. Outside production the provider warns in the console, once.
components={{ Button: MuiButton }}does not type-check directly (MUI'svariantconflicts with ours), and no trick fixes it — the adapter is the answer.labelis the prop that bites: a slot that discardslabelremoves the accessible name from about sixteen controls. MUI'sTextFieldhas a compatiblelabel; Chakra'sInputdoes not. AndTextareais the one slot where ignoringrefbreaks something: the formula modal passes a ref to reposition the caret after you accept a suggestion.- Not slottable:
PageCanvas/Ruler/FieldBox(they are the PDF's mock-up — a stray primitive's padding breaks WYSIWYG), the tab bar (all of 2.1.1 went into fitting six tabs into 290px; a slotted Button'smin-widthundoes it), theFieldListrename input (it depends onautoFocusplus commit ononBlur), the hidden file input, the "ƒx" glyph, and the icons.
Checkbox— closes the gap left by three bare<input type="checkbox">in the editor, and is the twelfth slot.- Kit components now public:
PalettePicker,PaletteSwatches,MaterialIcon,CollapsibleSection,ClearFieldButton. All of them — and everything already public — takeclassName,styleand...rest, under one rule:className/style/...restgo to the element that gives the component its NAME; every other element it renders is addressed throughparts, by role. SoInput.classNameis still on the<input>(unchanged from 2.x) and the wrapping<label>isparts.root;Modal.classNameis the panel and the backdrop isparts.overlay.partsaccepts onlyclassName/style— no handlers, no refs — with a string shorthand (parts={{ label: "my-class" }}) and an object form when you needstyle.mergeStylegives yourstylethe win.BulkLockedstays internal on purpose: it means "locked because you selected several of the same type", which is a mode of<Designer>. Outside that context the component says nothing.
- A typed error surface:
PdfGenerationError(abstract) plus 18 concrete classes,PDF_ERROR_CODES(the 18 codes as aconsttuple),PdfErrorCode,PdfErrorBlame,AnyPdfError, andisPdfError(err), which narrowsunknowntoAnyPdfError. All from both entries. See the Breaking section for why the oldErrormessages had to go. describePdfError(err, dict)→PdfProblem | null— one call that turns a caught error into{ code, blame, title, action?, field?, detail }with the copy already in the dictionary's language. Pair it withdictFor(locale), also exported from both entries.- It is exhaustive, so "it fell through to the generic branch because I
did not recognise it" is no longer a state the package can be in — and
a code added to
PDF_ERROR_CODESstops compiling until it has copy in both languages. PdfProblemCodehas 19 members whilePDF_ERROR_CODEShas 18, and the extra one is not an oversight:"expression"coversExpressionError, which is its own hierarchy rather than one of the 18 (it already carries alocalize(dict), so re-describing it here would give the same failure two different sentences). It is also the one problem code you are unlikely to see fromgeneratePdf— generation is deliberately tolerant, and an invalid expression renders as an empty field. It surfaces when you call the strict API, e.g.parsein a backend validating a template before saving it.
- It is exhaustive, so "it fell through to the generic branch because I
did not recognise it" is no longer a state the package can be in — and
a code added to
json-pdf-designer/reset.cssas its own export — see the Breaking entry above.- New
<Designer>props, all optional:gridSizeMm,expandOnSelect,className,style,components. The seven props it always had are unchanged. examples/composed-layout(port 5177) — the app that only works because tab gating is opt-in: toolbar across the top, field list on the left, canvas in the middle, and a right-hand column of five stacked panels that inside<Designer>would be separate tabs. No piece receiveswhenTab. Measured: eight pieces in the DOM at once, and zero.jpd-tabs.examples/report-buildermoved to the pieces with an identical layout, and what the migration bought is<SelectedFieldBar>— the app's own shell reading the editor's selection through a hook, which was impossible in 2.x (the old comment inApp.tsxsaid exactly that:<Designer>owned the selection and there was no prop to drive it from outside). Verified: "remove" in the app's own bar takes the template from three fields to two, and the app's Ctrl+Z undoes it (two back to three) — so the package's mutator goes through the consumer'ssetState, and the consumer's undo/redo sees it.examples/custom-uideliberately stays on<Designer>: it is the regression test for the one-line path, and it imports no CSS from the package at all, styling.jpd-*from scratch — that is the no-stylesheet mode.examples/no-previewis a different thing: it importstheme.cssnormally, and is the best smoke test for the PREFLIGHT LOSS, because it has no Tailwind pipeline at any end —report-builderhas its own, so it emits Preflight by itself and will not reproduce a missing reset. Testing there first gives a false green.
Fixed
- The
Modalclose button'saria-labelwas the dialog's title. A screen reader announced "Formula editor" as the name of the button that closes the formula editor. It has its own translated name now (t.modal.close). - The canvas's
fitTonever used the canvas's viewport. It looked for[data-scroll-root], [class*="overflow-auto"], [class*="overflow-y-auto"]— butdata-scroll-rootwas never written anywhere, and the wrapper usedoverflow-x-auto, which contains neither of those strings as a substring. Measured on 2.1.1: "fit width" produced 113% with 338px of the page outside the viewport. With the attribute in place: 67%, and zero overflow. - The ruler had no dark mode. The background did darken
(
dark:bg-gray-800), but the ticks and the numbers were SVG presentation attributes with fixed hex values (stroke="#94a3b8",fill="#64748b") — dark ticks on a dark background. All three come from tokens now (--jpd-ruler-bg,--jpd-ruler-tick,--jpd-ruler-label), so they move together. - The formula modal's tab strip overflowed, in pt-BR only. Measured in
examples/no-previewat a 1280px viewport: "Campos do item" (96.7px) plus "Caminhos de dado" (108.8px) is 205.4px inside a 176px column, and the strip hadoverflow: visible— so the buttons painted over the editor column beside them. In English the same two tabs ("Item fields" / "Data paths") come to 151px and fit with room to spare, which is why the bug only existed translated. The strip is nowjpd-tabs jpd-tabs--scroll(overflow-x: autoplusmin-inline-size: 0), so it measuresscrollWidth207 againstclientWidth176 and scrolls instead of clipping.- Only that instance gets the modifier. The sidebar's own
.jpd-tabsmust not takeoverflow, because it would cut off the.jpd-tabs__morepopover.
- Only that instance gets the modifier. The sidebar's own
- Icons lost their
display: block. With no Preflight, every inline<svg>goes back to inline-baseline and picks up descender space inside a flex line — about 23 call sites, including the<IconX/>inside the kit's own buttons. Fixed at the base oficons.tsx, wherejpd-iconnow goes on every icon rather than at each call site. - The rename input's border had no dark colour.
FieldList's inline input carriedborder-sky-400with nodark:border-*— sky-400 over gray-800. - The table's palette picker arrow had no dark colour. It was preserved
by a
staticArrowprop that existed only to keep an unintentional detail alive. The prop is gone and the arrow follows the theme. gridSizeMmwas honoured by neither paste nor spawn.PageCanvasalready took the prop, butcomputeSpawnPosition,nextFreeYand the paste path used the constant directly: withgridSizeMm={3}, dragging snapped to 3 while spawning and pasting snapped to 5, so a new field was born off your grid. All four paths take the step now, and<Designer gridSizeMm>exists..jpd-tabs__striplost its::-webkit-scrollbar { display: none }in the migration — the old.jpd-scroll-xhad both rules and only one came across.scrollbar-widthalone covers neither Safari nor Chromium < 121, and in a 28px-tall strip the bar eats half the height.- A template with no page size crashed in layout instead of failing as a
template problem.
assertFinitePageSizelived inside the render pass, which runs afterlayoutDocument- and the layout reads the size directly (pageDef.page.height - footerHeight). So a template whosepagewas missing entirely - hand-edited JSON, a file from another tool, a shape the migration does not cover - threwTypeError: Cannot read properties of undefined (reading 'height')from inside the layout, before the guard was ever on the stack. The guard was crashing on exactly the input it exists to reject.- The ugly message was not the real cost. A
TypeErroris not one of ours, sodescribePdfErrorreturnsnullfor it, and a consumer classifies the failure asblame: "package"- "not your fault, report it" - when the problem was their template. That is the confusion the typed error surface exists to end. - Every page's size is now checked up front, before layout, over the
NORMALIZED page list (
pagesis optional; when absent the flat fields are the implicit page, which is the most common template of all). Checking all of them at once also means someone loading a file learns that page 7 is wrong without waiting for six good pages to render.
- The ugly message was not the real cost. A
- Text on the page had no colour of its own, so in dark mode it was
unreadable.
.jpd-pageis the mock-up of the PDF, and the sheet is always white paper — the PDF has no dark mode. But it only setbackground-color, so any field text without a colour of its own inherited--jpd-textfrom the theme: light grey on white. Measured inexamples/no-preview, a table's empty—cells came out at 1.54:1 — text that is there and cannot be read..jpd-pagenow also setscolor: var(--jpd-canvas-ink)(17.75:1), a token that deliberately has no dark override. A field withfontColorin the schema still overrides it inline, as always. - The palette trigger fell back to the system button colour. It is a
<button>that has a class, so the reset's per-element rule (:not([class])) does not reach it — by design, so the reset never repaints a consumer's own button — and it was not inreset.css's by-class list either. With nobackground-colorof its own it landed on the systemButtonFace, which undercolor-scheme: darkis dark grey: the label measured 3.45:1 and the arrow 1.25:1. It now declaresbackground-color: var(--jpd-surface-field), like every other control. reset.cssread three tokens onlytheme.cssdeclares.--jpd-font-sans,--jpd-font-monoand--jpd-text-placeholder, all with no fallback — and sincereset.cssis a public export in its own right, used on its own eachvar()resolved to nothing and the rule died silently. The worst of the three waswithInlineCode's<code>, which ships with no class and got its monospace family from Preflight alone.
What does not break, and is worth saying out loud
- The seven props
<Designer>always had are identical:template,onChangeTemplate,bindings,onChangeBindings,onCanvasDrop,dataSources,locale. Everything new on it is optional. - The tab-order and tab-visibility localStorage keys are preserved —
json-pdf-designer:tab-orderandjson-pdf-designer:hidden-tabs. A user's tab layout survives the upgrade; verified in the browser. PageCanvas,Toolbar,FieldList,PropertyPanel,BindingEditor,FilterTabandTemplateInspectorkeep today's props and are still exported from their own modules, so the headless-by-props path keeps working exactly as it did.react-rndis still only needed for the canvas. The piece owns the sheet's geometry (mm→px,transform: scale(zoom)) because react-rnd computes the drag delta against that transform — override it and the field runs away from the cursor. You own the viewport that scrolls, which is whatclassNamereaches, and the sidebar's width (the 320px lives in the preset's CSS, not in the piece). Zoom is still<PageCanvas>'s internal state and never moved into context.server.tsis untouched — byte-identicalserver.d.tsin the build. The React-free backend install is exactly what it was in 2.0.0.
2.1.1 (2026-09-01)
Fixed
- The side panel's tab bar scrolls instead of clipping a tab. With all six
tabs showing (Fields/Data/Style/Filter/Page/Inspector) the bar needed more
than the panel's 320px — measured 335px of tabs in 290px of room in
Portuguese, with "Inspetor" cut off by 45px, and the pin "×" that only the
active tab renders accounting for the last 12px. The strip now scrolls
horizontally, with no visible scrollbar (it would eat half of a 30px-tall
strip) and the active tab brought into view whenever it changes — including
when something else changes it, like selecting a field, which switches to
"Data" on its own. The "+" that reopens a hidden tab sits outside the
scrolling strip, so it can't be the first thing to slide out of reach.
- Widening the panel by tab count was the other option, and it's worse: Data/Style/Filter appear and disappear with the selected field's type, so the canvas would jump on every change of selection.
2.1.0 (2026-09-01)
Phase 1 of the post-2.0.0 roadmap: the foundation the rest depends on — template format versioning, then an expression AST, then a unified layout pass. On top of it, what the AST made possible: conditional visibility, a typed failure surface, and an expression editor with field list and autocomplete.
Added
-
Expression editor (
ƒx) — theƒxbutton now opens a window with the fields the schema is bound to on the left and a multi-line editor in the middle, with autocomplete of the format's functions and operators plus live validation. The editor holds the field value itself, braces included, opened with whatever was already there — you edit in place, so a literal prefix (FAT-inFAT-{invoice}) stays where it is. Suggestions appear only inside the braces; outside them you are writing literal text.- An unbalanced brace is now reported — nothing else did. The template
resolver matches
/\{([^{}]+)\}/g, so a{with no pair simply does not match and that stretch comes out as literal text in the PDF ({CURRENCY(totalprinted as-is). It is not an expression syntax error (the parser never sees it) nor a generation failure; it was a field coming out wrong in silence.braceError(template, t?)andtokenAtCaret(template, caret)are exported for a custom editor. - Available on four targets: a table column formula, each cell of the totals row, the KPI's title/value/caption, and a text field's content. Before this, only the column formula had anything, and it was a one-line input in a 320px sidebar.
- The field list has two groups, because they resolve in different scopes:
fields of each item of the bound array (
total, no prefix — what works inside a row) and the full data paths (invoices.total— what an aggregation needs). Mixing them up was one of the easiest mistakes to make. - Autocomplete offers the 11 functions with their hints plus
AND/OR/NOT, and it puts the surrounding spaces an operator needs — so it cannot produce the one-sided operator described below. - A syntax error blocks saving; a suspicious operator only warns (a JSON key
with
/in the name is legitimate). Modaljoins the ready-made UI exports.
- An unbalanced brace is now reported — nothing else did. The template
resolver matches
-
suspiciousOperator(source)/templateSuspiciousOperators(template)— an operator with whitespace on exactly one side is now reported. It is not a syntax error and cannot become one: an operator is only an operator when surrounded by whitespace on both sides, which is what keeps{my-key},{invoice/2}and{a==2}reachable as data paths. But{invoice /}—/with}on its right — silently became the key"invoice /", resolved to empty, and nothing flagged it. Now it does.- The check runs over the
identtokens, not the raw string, which is what makes it free of false positives: a real operator is already anoptoken, quoted text is astringtoken, and a negative sign (-1after a comma) is anumbertoken — none of them reach the check. SchemaExpressionErrorgainedseverity: "error" | "warning". Error means the field renders empty for sure; warning means it compiles but almost certainly is not what the author meant.fieldWarningreports the error first.
- The check runs over the
-
Conditional visibility (
schema.visibleWhen) — an expression, without braces, evaluated against the real JSON at generation time; the field is drawn only when it is true.{ type: "text", content: "Corporate discount", visibleWhen: 'customer.type == "company"' }{ type: "table", visibleWhen: "NOT cancelled" }- Works on every field type, including tables and repeated sections, and
on the repeating bands — a header/footer condition may use
pageNumber/pageCount, so "only on the last page" ispageNumber == pageCount. - Flow: hiding an item gives back its height and nothing else. What follows moves up by exactly that much, and the spacing authored on both sides still applies. Hiding one field of a row that has visible neighbours leaves the hole, because the row still exists for them; hide them all and the whole row goes.
- Absent (or blank) = always visible, so every existing template is unaffected.
- An invalid condition counts as visible, never hidden: a typo must not make a field vanish from a report in silence. The editor flags it (see below) and the field keeps showing until someone fixes it.
<Designer>has a "Show only when" input for it, next to X/Y/width/height, with the syntax error shown live under the field.
- Works on every field type, including tables and repeated sections, and
on the repeating bands — a header/footer condition may use
-
AND/OR/NOTin expressions —{a > 1 AND NOT cancelled}.ANDbinds tighter thanOR, parentheses group, and both short-circuit. Same lexical rule as every other operator: only surrounded by whitespace, so a JSON key literally namedANDis still reachable as{AND}. Case-insensitive, like function names. -
Expression validation is now public API —
expressionError(source),templateExpressionErrors(template)(both entries) andexpressionErrors(schema, binding)/fieldWarning(schema, binding, t)(main entry). A backend can reject a template with a broken expression before saving it; a custom editor UI can flag it the way<Designer>does.tonfieldWarningis now optional (English by default), anddictFor(locale)returns a translation dictionary as a plain value.fieldWarningwas exported but needed aDictthat could only be obtained fromuseT(), inside a React component — so the one place it was most useful, a validation pass outside the editor, could not call it. Found by writing that pass inexamples/report-builder.
-
Template.versionandmigrateTemplate()— aTemplateis a document format, not an internal structure: once it lives in a database it outlives any version of this package.versiontracks the JSON format (not the npm package),TemplateVersionis a one-member union today so that adding| 2makes the compiler point at every place that has to decide, andmigrateTemplate(input)normalizes a template from a DB/file/API into the format this build understands.- Absent
versionis treated as format 1 — exact for every template saved before the field existed, since no shape change has happened since. - A
versionhigher than this build understands throws instead of generating a PDF that silently drops fields it does not know. generatePdfcalls it internally, at the single point every template passes through, so a PDF is never generated from an unmigrated template.- Migrations live in one chain (
src/template/migrate.ts), one step per version — neverif (version === 1) … if (version === 2) …spread across callers. The chain is empty today because there is one format; it exists now because adding it after templates are in production databases costs far more. - Exported from both
json-pdf-designerandjson-pdf-designer/server.
- Absent
Fixed
-
Editing a table cell on the canvas now updates the column formula too. The cell is the column formula —
generate.tsresolves the row fromschema.content— and theƒxpanel readsbinding.columns[i].formula. Editing throughƒxalready wrote both; editing the cell directly wrote only the content, so the panel kept showing the old formula: two values for the same thing, with the one on display being the one that does not reach the PDF. Same rules in both directions (an emptied cell goes back to the raw column). -
A table column formula is no longer reported as a broken expression.
bindingExpressionErrorsvalidatedcolumn.formulaas a bare expression, but the renderer resolves it withrenderTemplate— a formula is a template, so"FAT-{fatura}"is correct and was being flagged. Two of the package's own shipped example templates lit up because of it. Now validated as a template, matching what actually runs. -
A malformed expression no longer takes the whole PDF down. Generation is tolerant again: an invalid
{...}resolves to empty, so one stray comma leaves that field blank instead of failinggeneratePdfand producing no document at all. That was the blast radius before the AST, and trading "one blank field" for "no report" was not an improvement.- The parser itself stays strict — that is what powers the new field warning
(
"Invalid expression in \"content\" — renders empty"), which is where the problem is meant to surface: in the editor, before generating. CONCAT(a,)— a trailing comma before)— is tolerated again, as it was before the AST.
- The parser itself stays strict — that is what powers the new field warning
(
-
A big report is no longer silently truncated. Two iteration counters (1000 table slices, 20000 section repeats) used to cap volume without saying so: 60 000 table rows came out as 40 998, and 20 000 section repeats came out as 18 667, in a PDF that looked complete. Omitting rows from a report without a word is the worst possible outcome.
- They also guarded against a loop that cannot happen: the table loop always
breaks on
capacity <= 0, and the section loop always manages to place the item on the next page (on a freshly opened page the cursor is the header height, soneedsNewPageForItemis false). Counting iterations measured the wrong thing. - The limit is now on pages — the actually scarce resource, whether the
pages come from a table, a section or several page designs — it defaults to
5000, it is configurable with
generatePdf(..., { maxPages }), and exceeding it throwsPageLimitErrornaming the field being paginated and saying what to do about it. Volumes that used to be truncated (60 000 rows → 1464 pages) now come out complete. - A pagination pass that consumes nothing and opens no page now throws too: that is an arithmetic bug in the package, and spinning until a counter ran out hid it.
- They also guarded against a loop that cannot happen: the table loop always
breaks on
-
Generation errors are exported as classes —
PageLimitError,UnsupportedGlyphError,ExpressionSyntaxError/ExpressionDepthError(underExpressionError) andDEFAULT_MAX_PAGES, from both entries. A backend can answer 413 for one and 400 for another without matching error messages. -
A control character in the data no longer takes the PDF down. A
\nin a customer name — from a textarea, an address with a line break, a CSV import — used to fail the whole document withWinAnsi cannot encode "\n". Control characters (C0, DEL, C1) are now replaced with a space before measuring or drawing, in every path that reaches the page: text field, table cell, KPI, chart label and the repeating bands. They have no glyph in any font, not even a full Unicode one passed viafontBytes, so this is not content loss — it is the only possible rendering. This was the highest-impact crash in the package: the trigger is the data, which whoever authored the template does not control. -
A missing glyph now says which field and which character. Emoji/CJK without a custom font still fails — dropping a character from a document someone signs would be worse — but the error is now
Campo "cliente_nome": o caractere "🎉" (U+1F389) não existe na fonte usada …instead of pdf-lib's bareWinAnsi cannot encode, which named nothing. Covered in all five paths (text, table cell, KPI, chart label, table nested in a repeated section), and an astral-plane emoji is reported as one character rather than two surrogate halves. -
A corrupt page background throws a real
Error. pdf-lib/pako threw a raw string there ("The input is not a PNG file!"), so a caller'scatch (e) { e.message }gotundefined. Same treatment the image field already had. -
An image binding now actually reaches the PDF.
drawImageFieldreadschema.contentand ignored the resolved value, so the editor let you bind an image field to the JSON and the generator always drew the design-time image. A bound value that is not a data URI (a wrong path, an http URL, loose text) leaves the field empty instead of failing. -
A degenerate number no longer produces an opaque pdf-lib
TypeError. ANaNfont size falls back to a default (a lost measurement must not cost the document); an invalid page size fails withPágina "…": tamanho inválido (width=NaN, …)— structural, and there is no sensible default to guess. -
A too-deeply-nested expression no longer takes the PDF down either. The depth guard used to throw a plain
Error, and the tolerant layer only caughtExpressionSyntaxError— so the one case the guard exists for (a malformed or malicious template) was also the one case tolerance did not cover. The errors are now a small class hierarchy (ExpressionError→ExpressionSyntaxError/ExpressionDepthError), so the tolerant layer catches both by type instead of by matching the error message with a regex, and anything that is not a template problem still propagates.- The original guarantee is intact and still tested: excessive nesting is a
clear, bounded error rather than a V8 stack overflow. It now lives on the
strict API (
parse,expressionError), while generation renders the field empty and the editor flags it — the same trade-off syntax errors make.
- The original guarantee is intact and still tested: excessive nesting is a
clear, bounded error rather than a V8 stack overflow. It now lives on the
strict API (
-
A number literal keeps the digits the author wrote.
{2.50}renders"2.50"again (not"2.5"),{007}renders"007". Inside a calculation the value is coerced as usual, so{2.50 + 0}is still2.5. -
The syntax-error position is now the exact offset. Each token carries its own start, so the position is right even with whitespace between tokens and with a repeated token — neither
indexOf(first occurrence) nor summing token lengths (ignores whitespace) got that right. -
Operator precedence in template expressions.
{a + b * c}now evaluates asa + (b * c). The previous engine folded left to right, like a pocket calculator, so witha=2 b=3 c=4it produced 20 instead of 14 — a wrong number on a financial report, with no error. -
Parenthesised grouping.
{(a + b) * c}now works. The previous engine had no notion of grouping: the function-call pattern did not match a leading(, the arithmetic pass could not group, and the result was0, silently. -
Text in an arithmetic expression no longer throws.
{"x" + 1}returns empty, the format's convention for "could not resolve". Before, the arithmetic pass gave up, the fallback re-processed the same string, and the recursion ran until the nesting guard threw — with a message about nesting depth that had nothing to do with the actual problem. -
Division by zero no longer throws.
{a / zero}returns empty, same reason as above. This one is a plausible real input: a denominator that happens to be zero on one row.
Changed
- The expression engine is now parse → AST → evaluate
(
src/expressions/), replacing the recursive string-rewriter that re-parsed the same string at every nesting level. The four fixes above come from the structure, not from patches on top of it.resolveToken(token, data)keeps its signature and itsstringreturn, so nothing downstream changed and the whole existing test suite ran unmodified as the regression proof.- The format's lexical rule is now explicit and tested: an operator is only
an operator when surrounded by whitespace on both sides.
{my-key}and{my key}stay paths (JSON keys with hyphens and spaces are common),{a - b}is subtraction. The old engine had this by accident, through a regex; a conventional tokenizer would have broken{my-key}into three tokens and returned 0. - Intermediate values are now
string | numberinstead of always strings. Coercion happens at each operator boundary — that is what makes precedence work at all. - Syntax errors (unterminated quote, unclosed paren) now throw with the
position, instead of silently returning
0or empty. - Still no
eval/new Function: a template can come from an untrusted source, and the evaluator walks the AST.
- Pagination is decided in one pass (
src/pdf/layout/layoutDocument.ts), replacing the two traversals it had before:generate.tsused to decide page breaks and draw in the same loop, whilecountBodyPageswalked the whole body a second time just to get the total, because{pageCount}needs the number before the first mark is drawn. Only the atomic decisions were shared (pagination.ts); the cursor advance, the table-slicing loop and the section repetition were written twice, and a change to one copy would have meant "the dry run said 7 pages, the drawing made 8".layoutDocument(template, data, bindings, inputs)returns aLayoutDocument: pages, each holdingPlacements already positioned and with values already resolved. Page count ispages.length— it cannot disagree with the drawing because it is the drawing.countBodyPagesis gone.generate.tsno longer contains a single pagination decision;render/*receives resolved values instead of resolving them.- The cursor after a table now advances by
computeTableSlice().heightMminstead of by the Y coordinatedrawTableSlicereturned — the layout no longer has to draw in order to know where to continue. - Section measurement moved to
layout/sectionLayout.tsand the table metrics topdf/tableMetrics.ts, solayout/no longer imports fromrender/and is genuinely pdf-lib-free. - Verified against the v2.0.0 build on 11 templates (tables of 1/35/36/37/
120/600 rows, totals row,
repeatHeader: false, text-table-text flow, master-detail sections,Template.pages): identical page counts everywhere, and byte-identical content streams except one Y coordinate that differs by 1.4e-13 mm — float round-trip noise the new path has one step less of.
- A bare comparison now renders as
"true"/"false"—{a > 1}used to render empty, because the old engine treated the whole thing as a path and found nothing. This is deliberate: the old output was accidental, and a comparison that evaluates to something is the foundationvisibleWhenneeds. A comparison inside{IF(...)}behaves exactly as before.
2.0.0 (2026-09-01)
Ships the pdfjs-dist split that had been sitting under "Planned" —
plus the second half of it that entry wasn't accounting for: the PDF
page background also went through pdf.js, straight from <Designer>, so
the new entry point alone would not have freed the main entry. That
feature is dropped here (page backgrounds are images only now), which
leaves the preview as the single place pdf.js is used.
Breaking
-
pdfjs-distis no longer installed automatically — it moved from a requireddependencyto an optional peer dependency (peerDependenciesMeta["pdfjs-dist"].optional: true), the same treatmentwawoff2got in 1.6.2. Only projects that render a PDF preview need it; runnpm install pdfjs-distin your own project if you do.- Why: pdf.js is ~35MB installed, and as a
dependencyof the main entry every consumer paid for it — including apps that only render<Designer>and never preview anything.
- Why: pdf.js is ~35MB installed, and as a
-
react-rndis no longer installed automatically either — same move, and it is what actually makes the React-free backend install real. It was a plaindependency, so it came down on every install, and because its ownreact/react-dompeers are not optional, npm then installed the whole React stack (react,react-dom,react-draggable,re-resizable,scheduler,prop-types, …: ~8.7MB) even in a project that only ever importsjson-pdf-designer/server. Theoptional: truethis package already had on its own react peers was being defeated one level down.- If you use
<Designer>, add it alongside react:npm install react react-dom react-rnd. OnlyPageCanvas.tsxuses it (drag/resize), and nothing reachable from/serverdoes. - A backend install now resolves
fontkit,pdf-libandtiny-inflateplus their own trees, and nothing React.
- If you use
-
PdfPreview,PdfPreviewModal, andconfigurePdfWorkermoved to a newjson-pdf-designer/previewentry point. Update the import:- import { PdfPreviewModal, configurePdfWorker } from "json-pdf-designer";+ import { PdfPreviewModal, configurePdfWorker } from "json-pdf-designer/preview";Everything else —
<Designer>,generatePdf,downloadPdf, the UI components, the types — stays exactly where it was. A lazyimport()inside the old files would not have worked: re-exported from the main entry's module graph, any bundler resolving"json-pdf-designer"still had to resolvepdfjs-distat build time. -
A page background can no longer be a PDF — images only. The background upload accepts PNG/JPEG, and the button reads "Background image" instead of "Background PDF/image". Rasterizing an uploaded PDF's first page was the other thing pdf.js did in this package, and
src/pdf/backgroundImage.tsimported it directly while<Designer>imports that — so the entry split above would have left pdf.js in the main entry's graph regardless. Dropping the feature is what actually frees the main entry, and it leaves rendering the preview as the only use of pdf.js in the whole package.- Migration: if you use a letterhead that only exists as a PDF, export
its page to PNG once and upload that. Existing templates are
unaffected —
Template.backgroundImagewas always a PNG data URI, so a background captured from a PDF before this release keeps working exactly as before. fileToBackgroundImage(file)(internal) no longer branches onapplication/pdf;src/pdf/rasterizePdfPage.tsis gone.
- Migration: if you use a letterhead that only exists as a PDF, export
its page to PNG once and upload that. Existing templates are
unaffected —
Added
examples/no-preview— a fourth example app:<Designer>+generatePdf+downloadPdf, straight to download with no preview screen, and nopdfjs-distin any dependency field. It's the app that proves the main entry works without the optional peer, and it builds in CI, so a pdf.js import leaking back into the main entry fails the Pages build.test/entryBoundaries.test.ts— walks the source graph fromsrc/index.ts,src/server.ts, andsrc/preview.tsand fails ifpdfjs-distis reachable from the first two, or ifreact/react-dom/react-rndis reachable from/server. Two control cases keep a broken walker from making the others pass vacuously. The CI tarball check also assertspdfjs-distis absent fromnode_modulesafter installing the packed package.
1.6.4 (2026-08-31)
Follows up on an external code review — fact-checked its claims against the actual codebase before acting on them (see the plan/audit for the full verdict; several claims turned out stale or overstated, e.g. the reported npm/GitHub version mismatch no longer exists, and the "no eval/new Function" security concern was already satisfied). This release covers the parts that checked out as real and worth fixing now, plus a couple of follow-up features and an internal reorg done in the same pass.
Fixed
{SUM(a) - SUM(b)}now actually subtracts. Before, the function- call regex greedily matched the whole expression as a single call (capturing"a) - SUM(b"as one argument), silently rendering"0"instead of the difference. Any two function calls combined by an operator in the same{...}now correctly falls through to arithmetic instead.
Security
- Recursion depth limit on nested
{FUNCTION(...)}calls — previously unbounded; a template with several thousand nested parens (e.g.{CURRENCY(CURRENCY(CURRENCY(...)))}) could crash the process with an uncaught stack overflow. Now throws a clear, catchable error past ~40 nesting levels — far more than any legitimate template needs. - Image size/count limits —
ImageSchema.contentandTemplate.backgroundImagenow enforce a 15MB decoded-size cap per image and a 200-distinct-image cap per document (both throw a clear error instead of silently accepting anything). The browser-side background upload (fileToBackgroundImage) also rejects files over 20MB before attempting to process them. Matters most for anyone accepting templates from an untrusted source (multi-tenant).
Added
- Golden/torture test suite (
test/pdf/generate.torture.test.ts+test/pdf/fixtures/) — runs the realgeneratePdfpipeline (not a fake page, an actualpdf-libdocument) against deliberately extreme templates: empty table, a 600-row table spanning many physical pages, a section taller than an entire page, missing/null bound data, and the real text-encoding boundary (pt-BR accents work with the default font, emoji/CJK correctly needfontBytes— now locked in as an explicit, intentional test rather than an undocumented crash). - CI now verifies the published tarball, not just the source — a
new step runs
npm pack, installs the resulting.tgzinto a throwaway consumer, and callsgeneratePdffromjson-pdf-designer/serverfor real. Catchesexports/files/.d.tsregressions that only show up in what actually gets published (seetest/pack-consumer/). {IF(condition, "then", "else")}template function —conditionis either a comparison (status == "paid",total > 100; operators==,!=,>,>=,<,<=, always surrounded by spaces, reusing the same eq/gt/lt logic chart/KPI filters already use) or a bare path/expression checked for truthiness (empty string,"0", and"false"count as false). Only the chosen branch is resolved, so{IF(hasDiscount, discountAmount, "0")}doesn't fail even whendiscountAmountis missing from the data on the false branch. Added to the function picker in the binding editor and the table column- formula editor alongsideSUM/CONCAT/etc.- Template Inspector — new optional/removable sidebar tab (same
show/hide/reorder pattern as "Data"/"Style"/"Filter"/"Page") showing a
read-only tree of every field on the current page, grouped by zone
(Header/Body/Footer/margins, reusing the existing
classifyZone/isRedZonefromsrc/zones.ts— no new classification logic). Each row shows the field's type, position, parent section (if it's a section member), a short binding summary, and its z-index (same order send-to-back/bring-to-front already uses). Clicking a row selects that field on the canvas, reusing the existing selection handling — no parallel selection mechanism.
Changed
src/pdf/reorganized intolayout/andrender/— pure internal refactor, no public API change.generate.tsis now a thin orchestrator; the pagination math (buildBodyItems,boundsOf/gapAfter,normalizePageDefs,countBodyPages) moved intosrc/pdf/layout/, and the actualpdf-libdrawing (previouslydrawTable.ts/drawSection.ts/drawChart.ts/drawKpi.ts, plus two new files split out ofgenerate.tsfor text/image) moved intosrc/pdf/render/asrenderTable.ts/renderSection.ts/renderChart.ts/renderKpi.ts/renderText.ts/renderImage.ts, dispatched byrender/index.ts. Verified as a pure move: same test count passing before and after, and the built bundle size is unchanged. Only matters if you imported one of those internal files directly (not part of the package's public entry points) — update the import path if so.
1.6.3 (2026-08-31)
Removed
lodash.getdependency — replaced its one call site (resolving a repeated section's array binding path) with the package's own existing case-insensitive path getter (now exported frombindings.tsasgetCaseInsensitive), matching how every other binding type already resolved paths. Removeslodash.getand@types/lodash.getentirely (both were flagged deprecated upstream) and fixes a real inconsistency: section paths used to be case-sensitive while chart/kpi/table paths were not.
1.6.2 (2026-08-31)
Breaking (peer dependency)
wawoff2is no longer installed automatically — it moved from a requireddependencyto an optional peer dependency (peerDependenciesMeta.wawoff2.optional: true). Only projects that embed a real.woff2font (generatePdf(..., { fontBytes })with.woff2bytes) need it now; runnpm install wawoff2in your own project if you do. Without it installed, passing a.woff2file throws a clear error (instead of the decompressor silently being there) telling you to install it or convert the font to.ttf/.otfoffline instead..ttf,.otf, and.woff(v1, decompressed via the still-mandatorytiny-inflate) are unaffected.- Why:
wawoff2's WASM binding has a Node-only code path (fs/path) that bundlers like Vite flag with a confusing (but harmless) "externalized for browser compatibility" warning — for every consumer, even ones that never embed a custom font. Making it optional means that warning, and the WASM binary itself, only shows up for projects that actually use the feature. src/pdf/fontUtils.tsnow lazy-loadswawoff2/build/decompress_binding.jsvia a dynamicimport()instead of a static one, only at the moment a.woff2file is actually being decompressed.
- Why:
Added
- Table color palette gained an explicit, selectable "Custom" entry in the picker (previously only reachable implicitly by not picking a preset).
- Table zebra/banded rows is now a toggle independent of which palette/preset is active — any preset can be applied with or without striped rows.
TableSchema.borderColor— the table grid color is configurable now (was a hardcoded gray in both the generated PDF and the canvas preview); color presets apply a real border color too.
1.6.1
- Internal refactor: reorganized
src/by domain (table/,chart/,designer/,bindings/builders.ts, etc. — see Architecture and Package structure for the current layout), extracted shared helpers to remove duplication across the PDF-drawing and canvas code, and added test coverage for previously untested pure-logic modules. No public API changes. - Fixed the table's rounded-corner border drawing a square outline on
top of a rounded fill when
headBorderRadius/bodyBorderRadius/footerBorderRadiuswas set.
1.6.0 and earlier
See the GitHub commit history for changes before this changelog started.