Skip to main content

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.
  • IconPencil joins the exported icon set (now 21).
  • The three modals are now real dialogs. role="dialog", aria-modal="true", and aria-labelledby pointing 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 in components/ui/useDialogA11y.ts, because PdfPreviewModal does not go through the Modal shell.
  • PdfPreviewModal closes 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 onClick and 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> with aria-pressed; the row stays clickable as a mouse convenience, marked role="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 no tabIndex, so hiding a tab was mouse-only too. It is a sibling <button> now, inside a new .jpd-tab__slot wrapper. The <button className="jpd-tab"> itself is untouched on purpose: its data-active is read by a querySelector, 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: onFocus does not fix this. React's autoFocus focuses during commit, before the handler is resolvable, so it never fires (measured: selectionStart and selectionEnd both at the end). It takes a stable ref callback — and stable matters, since an inline arrow would re-select on every keystroke.

Changed​

  • jsx-a11y is enabled in the linter. It was simply absent from plugins, 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-interactions and no-noninteractive-element-interactions fire 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 putting role="button" and an empty onKeyDown on <div>s that are not buttons — lying to the screen reader to satisfy the linter. interactive-supports-focus stays an error, and it is the rule that caught the tab-bar ×. no-autofocus is off because in an inline editor the autofocus is the correct behaviour.
  • CardTitle passes children explicitly instead of through the spread. With <h3 {...rest} /> the heading-has-content rule cannot see any content and flags every CardTitle as an empty heading; written this way the rule stays on and still catches a genuinely empty one.
  • viewportRef is typed MutableRefObject, not RefObject — see the CI section for why.

CI​

The accessibility work above was invisible to CI, and so were three other things the package claims:

  • peerDependencies accepts react ^18 || ^19, and only one was ever tested. A new react-18 job 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/react 18 the call signature of an exotic component (what forwardRef returns) yields ReactNode, while FunctionComponent inside ComponentType yields ReactElement | null — narrower, so our own twelve forwardRef components 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/react 19 made RefObject mutable and has useRef<T|null>(null) return RefObject<T|null>; in 18 RefObject<T> has a readonly current, and a <div>'s ref prop there rejects that instantiation. MutableRefObject<T|null> is { current: T|null } in both.
  • engines promises node >=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 imports dist. So the new consumer-node job 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 new docs job builds both locales, because a missing translation or MDX error only in the pt-BR mirror would not show up in the en build.

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 through stripComments — which already existed in test/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:

    WrittenResolves 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, unclosedBracket and spaceInSegment are the three new expression error codes.

    The AST's path node now carries segments: string[] instead of path: 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 changes head[index] and, for a calculated column, the binding's label — 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 ƒx field chips, so the three cannot disagree. Plus segmentFor for 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 from json-pdf-designer/server too, 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 path a. 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 head on every keystroke. The action setTableHead and the onSetHeadList prop remain exported — a consumer rendering PropertyPanelTable directly 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 — displayCell already 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 ƒx editor of a table column opened empty. The panel seeded it from binding.columns[i], and only when that entry was an object, while the PDF reads content[0][i] first. So on a table bound from a data source — whose columns[i] is a plain string and whose content[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 ƒx button's highlight came from the same wrong read, so it did not light up either. Both now go through columnFormulaFor, 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 setTableHead with 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 lost content[0][i] (the token — the thing that decides the PDF), columnStyles[i] and columnWidths[i], while binding.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 still fare_amount, the design cell is still 100.00, and the app's own Ctrl+Z undoes the rename — so it goes through the consumer's setState like every other mutation.

  • A table dropped from the field tree was born without tokens. Building a bound table was never in the package (onCanvasDrop is a raw passthrough), so each example did it by hand and wrote content: [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 ƒx while a table bound through the panel did not. All five examples now go through tokenFor, and makeBoundTable puts the whole thing in the package.

  • handleChangeBinding wrote 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 uses tokenFor now, 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 of pdf/ and designer/: fields/table/, fields/kpi/, fields/chart/. They are the same kind of thing — the pure logic one field type owns — and types/schema.ts already spells out that field types are the axis the whole package turns on. kpi/ itself replaced src/kpiFormat.ts, whose name described one of its thirteen exports (the other twelve are card defaults, corner radius, and per-element position/lock); it is fields/kpi/card.ts plus fields/kpi/format.ts now.

  • src/page/ holds units.ts, sizes.ts (was pageSizes.ts) and zones.ts. This reverses a call made earlier in this same section: they were listed as "deliberately not moved", on the argument that a util/ folder would be a junk drawer. That argument was right about util/ 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.ts replaces src/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.ts still holds the locale → Dict map with no React import, which is what lets /server reach a dictionary, and test/entryBoundaries.test.ts still guards it.

  • src/expressions/engine/ holds tokenize.ts, parse.ts, evaluate.ts, functions.ts and formatters.ts. This one was measured rather than eyeballed: of the twelve files in expressions/, 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/ and src/components/formula/ take eleven files out of a twenty-file flat listing. The panels follow the shape FieldBox/ already established — folder named after the component, the component in index.tsx, the per-type variants alongside it — so import { PropertyPanel } from "../../components/PropertyPanel" resolves unchanged. formula/ is lowercase because it is a topic and not a component: FormulaModal.tsx is the editor, but FormulaButton.tsx is imported on its own by the property panels, so naming the folder after either one would misdescribe it.

  • test/ follows src/ where it had fallen behind: test/page/ holds the units/sizes/zones tests, and test/template/migrate.test.ts became test/template.test.ts. test/expressions/ was deliberately left flat — brackets.test.ts exercises tokenize, parse and evaluate together, so it has no side of an engine/ 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 from DesignerProvider + 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-zoombar is position: sticky inside .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 mounted DesignerCanvas), the type DesignerZoomValue, and clampZoom / ZOOM_MIN / ZOOM_MAX / ZOOM_STEP so 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 mounted DesignerCanvas registers itself in viewportRef. With no canvas mounted they do nothing on purpose — the old fallback was window.innerWidth, and that is how "fit width" once produced 113% with 338px of page off-screen.
    • Building a slider: use step="any", not step={step}. With min={0.25} and step={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_STEP is the increment for the +/− buttons; the scale is continuous.
    • examples/composed-layout now 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.

Fixed​

  • clampZoom(NaN) returned NaN, and that reached transform: scale(NaN) — the sheet vanished with nothing in the console and nothing in the DOM looking wrong. Math.max(0.25, NaN) is NaN, and NaN survives the following Math.min. It now returns 1 (100%), because NaN means "no value" and 100% is the least surprising answer; Infinity still clamps to the maximum, since there a value does exist. Reachable two ways: fitWidth() over a page whose width is NaN, and a hand-built bar passing Number("") from an emptied input.
  • The fitWidth() guard used paginaPx <= 0, and NaN <= 0 is false, so a NaN page size went straight through the check into the division. It tests Number.isFinite now.
  • test/publicSurface.test.ts was 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, clampZoom and the three ZOOM_* 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.css no longer exists — import json-pdf-designer/theme.css. Both ./style.css and ./dist/style.css left the exports map, 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.css imports 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: tailwindcss and @tailwindcss/cli are out of devDependencies, the build:css script is gone, src/style.css is deleted, and npm run build is now just tsup. theme.css and reset.css are 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.css does 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: 0 on headings, paragraphs and lists, list-style: none, font/color/background on form controls, svg { display: block }, code in a monospace family, table { border-collapse: collapse }, and appearance: 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: 0 sets border-style: none, and then any later border-width computes to zero. It has to be border: 0 solid. Preflight wrote it that way deliberately; both of our stylesheets do too.
  • Dark mode has a documented hook now: [data-jpd-theme="dark"]. Put it on <html> or on any ancestor. .dark still 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 matchMedia and 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 through createPortal(document.body), which is exactly why the tokens live on :root.
  • Every class inside <Designer> changed. The markup is .jpd-* plus data-* now, in a jpd-block__element--modifier convention with one element level; state is a data-* attribute, never a class (the rule: if the JSX would have to concatenate or choose a class string, it is a data-*). CSS you wrote against the old Tailwind class names stops matching.

    • Checked against this repo's own examples: examples/custom-ui styles 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.
  • 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}>

    size is "sm" | "md" | "lg" | "xl" | "full", defaulting to "lg" = 48rem, which is what max-w-3xl gave. An arbitrary width is style={{ maxWidth: 900 }}, and style now 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.
  • className stops concatenating and starts merging. cx dedupes by exact token and returns undefined (not "") when nothing is left, so components that used to emit class="… " with a trailing space no longer do — Card was the worst of them, appending a space even with no className at all. If you assert on markup, that space is gone.

    • Token order inside the class attribute now decides nothing. It never decided cascade even with Tailwind, but with no utilities left there is nothing for it to decide: .jpd-btn and your class have the same specificity, so the winner is stylesheet order. Whoever needs to beat theme.css loads their CSS after it, or uses style, which always wins.
  • Kit components forward a ref, so they are no longer plain functions. Button, Input, Select, Textarea, Modal, Card, TabPanel and the rest are forwardRef components: Button.name is no longer "Button", and calling Button(props) directly stops working. It is forwardRef and not React 19's ref-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 } to IconProps = SVGAttributes. Deliberately not SVGProps, which extends ClassAttributes and would accept a ref that goes nowhere — the type would lie.
  • mono no longer adds font-mono, and no component emits a Tailwind class at all. On Input and Textarea, mono became a data-mono attribute that theme.css styles. PaletteSwatches and PalettePicker lost 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 dist to scan, so every @source or content recipe 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.
  • The DOM structure inside <Designer> changed too, not only the class names: each piece renders its own wrapper, with a jpd-* 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 over examples/ 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. DesignerTabBar writes the flag; DesignerSidebar is 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 TabPanel trick is a grid 1fr→0fr, and that needs a flex column parent with min-block-size: 0. A loose piece cannot guarantee that parent, so per-piece collapse would have animated wrong, in silence, instead of degrading.
  • sidebarTab no longer gates a panel unless you ask it to. Tab gating is opt-in, through whenTab: with no whenTab a piece renders always; with whenTab="pagina" (or an array of tabs) it appears only there. That is how DesignerSidebar and <Designer> reproduce today's behavior, so inside the preset nothing changed.

    • Why: if gating were the default, putting DesignerPropertyPanel and DesignerPageSettings side 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.
  • PdfPreviewModal does not inherit primitives from a <Designer> elsewhere on the page. It lives in the json-pdf-designer/preview entry and resolves from the nearest UiComponentsProvider — 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).

  • generatePdf throws typed errors now, and error.message is 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 the json-pdf-designer/server entry.

    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 literal code and 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 message is 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 } | null

      title and action are in locale; detail is the raw English message. So an English consumer stops seeing Portuguese, and switching locale re-translates.

    • The trap, and it is the whole reason describePdfError is 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));
    • describePdfError returns null for 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 — see examples/*/src/lib/generationError.ts for the shape.

    • blame is "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-designer and json-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.tsx went 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, and DesignerSidebar — 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) and whenTab. Several take parts, addressing the elements inside them by role, plus flags like heading, hint, header and position.
    • DesignerPropertyPanel takes section="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 the PropertyPanel{Text,Table,…} panels, which are dispatched by schema.type. A loose <DesignerTextPanel/> has no answer to "which schema?" other than "the selected one", and then it is just a worse DesignerPropertyPanel.
  • Ten public hooks. Accessors useDesignerData, useDesignerActions, useDesignerSelection, useDesignerUi, useDesignerConfig; selectors useDesignerSelectedSchema, useDesignerFieldListSchemas, useDesignerBulkEdit, useDesignerTabWarnings, useDesignerFilterColumns.
    • The five contexts are split by how often they change, so a piece reads only what it uses. useDesignerActions never 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 — while useDesignerData changes 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 silent null would leave you staring at a hole in the screen.
  • 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 *Props type is exported so an adapter is about five lines, typed at both ends, and the example writes it with satisfies UiComponentsOverride. <Designer components={…}> is sugar for mounting the provider.
    • undefined on 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> sends variant: undefined to your adapter — measured: five of the Toolbar's six buttons arrive with no variant. Your adapter needs its own defaults. The editor's are Button primary/sm, Modal lg, and TabPanel.collapsed with 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's variant conflicts with ours), and no trick fixes it — the adapter is the answer. label is the prop that bites: a slot that discards label removes the accessible name from about sixteen controls. MUI's TextField has a compatible label; Chakra's Input does not. And Textarea is the one slot where ignoring ref breaks 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's min-width undoes it), the FieldList rename input (it depends on autoFocus plus commit on onBlur), 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 — take className, style and ...rest, under one rule: className/style/...rest go to the element that gives the component its NAME; every other element it renders is addressed through parts, by role. So Input.className is still on the <input> (unchanged from 2.x) and the wrapping <label> is parts.root; Modal.className is the panel and the backdrop is parts.overlay. parts accepts only className/style — no handlers, no refs — with a string shorthand (parts={{ label: "my-class" }}) and an object form when you need style. mergeStyle gives your style the win.
    • BulkLocked stays 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 a const tuple), PdfErrorCode, PdfErrorBlame, AnyPdfError, and isPdfError(err), which narrows unknown to AnyPdfError. All from both entries. See the Breaking section for why the old Error messages 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 with dictFor(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_CODES stops compiling until it has copy in both languages.
    • PdfProblemCode has 19 members while PDF_ERROR_CODES has 18, and the extra one is not an oversight: "expression" covers ExpressionError, which is its own hierarchy rather than one of the 18 (it already carries a localize(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 from generatePdf — generation is deliberately tolerant, and an invalid expression renders as an empty field. It surfaces when you call the strict API, e.g. parse in a backend validating a template before saving it.
  • json-pdf-designer/reset.css as 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 receives whenTab. Measured: eight pieces in the DOM at once, and zero .jpd-tabs.
    • examples/report-builder moved 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 in App.tsx said 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's setState, and the consumer's undo/redo sees it.
    • examples/custom-ui deliberately 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-preview is a different thing: it imports theme.css normally, and is the best smoke test for the PREFLIGHT LOSS, because it has no Tailwind pipeline at any end — report-builder has its own, so it emits Preflight by itself and will not reproduce a missing reset. Testing there first gives a false green.

Fixed​

  • The Modal close button's aria-label was 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 fitTo never used the canvas's viewport. It looked for [data-scroll-root], [class*="overflow-auto"], [class*="overflow-y-auto"] — but data-scroll-root was never written anywhere, and the wrapper used overflow-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-preview at a 1280px viewport: "Campos do item" (96.7px) plus "Caminhos de dado" (108.8px) is 205.4px inside a 176px column, and the strip had overflow: 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 now jpd-tabs jpd-tabs--scroll (overflow-x: auto plus min-inline-size: 0), so it measures scrollWidth 207 against clientWidth 176 and scrolls instead of clipping.
    • Only that instance gets the modifier. The sidebar's own .jpd-tabs must not take overflow, because it would cut off the .jpd-tabs__more popover.
  • 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 of icons.tsx, where jpd-icon now goes on every icon rather than at each call site.
  • The rename input's border had no dark colour. FieldList's inline input carried border-sky-400 with no dark:border-* — sky-400 over gray-800.
  • The table's palette picker arrow had no dark colour. It was preserved by a staticArrow prop that existed only to keep an unintentional detail alive. The prop is gone and the arrow follows the theme.
  • gridSizeMm was honoured by neither paste nor spawn. PageCanvas already took the prop, but computeSpawnPosition, nextFreeY and the paste path used the constant directly: with gridSizeMm={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__strip lost its ::-webkit-scrollbar { display: none } in the migration — the old .jpd-scroll-x had both rules and only one came across. scrollbar-width alone 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. assertFinitePageSize lived inside the render pass, which runs after layoutDocument - and the layout reads the size directly (pageDef.page.height - footerHeight). So a template whose page was missing entirely - hand-edited JSON, a file from another tool, a shape the migration does not cover - threw TypeError: 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 TypeError is not one of ours, so describePdfError returns null for it, and a consumer classifies the failure as blame: "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 (pages is 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.
  • Text on the page had no colour of its own, so in dark mode it was unreadable. .jpd-page is the mock-up of the PDF, and the sheet is always white paper — the PDF has no dark mode. But it only set background-color, so any field text without a colour of its own inherited --jpd-text from the theme: light grey on white. Measured in examples/no-preview, a table's empty — cells came out at 1.54:1 — text that is there and cannot be read. .jpd-page now also sets color: var(--jpd-canvas-ink) (17.75:1), a token that deliberately has no dark override. A field with fontColor in 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 in reset.css's by-class list either. With no background-color of its own it landed on the system ButtonFace, which under color-scheme: dark is dark grey: the label measured 3.45:1 and the arrow 1.25:1. It now declares background-color: var(--jpd-surface-field), like every other control.
  • reset.css read three tokens only theme.css declares. --jpd-font-sans, --jpd-font-mono and --jpd-text-placeholder, all with no fallback — and since reset.css is a public export in its own right, used on its own each var() resolved to nothing and the rule died silently. The worst of the three was withInlineCode'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-order and json-pdf-designer:hidden-tabs. A user's tab layout survives the upgrade; verified in the browser.
  • PageCanvas, Toolbar, FieldList, PropertyPanel, BindingEditor, FilterTab and TemplateInspector keep today's props and are still exported from their own modules, so the headless-by-props path keeps working exactly as it did.
  • react-rnd is 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 what className reaches, 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.ts is untouched — byte-identical server.d.ts in 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 ƒx button 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- in FAT-{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(total printed 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?) and tokenAtCaret(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).
    • Modal joins the ready-made UI exports.
  • 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 ident tokens, not the raw string, which is what makes it free of false positives: a real operator is already an op token, quoted text is a string token, and a negative sign (-1 after a comma) is a number token — none of them reach the check.
    • SchemaExpressionError gained severity: "error" | "warning". Error means the field renders empty for sure; warning means it compiles but almost certainly is not what the author meant. fieldWarning reports the error first.
  • 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" is pageNumber == 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.
  • AND / OR / NOT in expressions — {a > 1 AND NOT cancelled}. AND binds tighter than OR, parentheses group, and both short-circuit. Same lexical rule as every other operator: only surrounded by whitespace, so a JSON key literally named AND is still reachable as {AND}. Case-insensitive, like function names.

  • Expression validation is now public API — expressionError(source), templateExpressionErrors(template) (both entries) and expressionErrors(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.

    • t on fieldWarning is now optional (English by default), and dictFor(locale) returns a translation dictionary as a plain value. fieldWarning was exported but needed a Dict that could only be obtained from useT(), 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 in examples/report-builder.
  • Template.version and migrateTemplate() — a Template is a document format, not an internal structure: once it lives in a database it outlives any version of this package. version tracks the JSON format (not the npm package), TemplateVersion is a one-member union today so that adding | 2 makes the compiler point at every place that has to decide, and migrateTemplate(input) normalizes a template from a DB/file/API into the format this build understands.

    • Absent version is treated as format 1 — exact for every template saved before the field existed, since no shape change has happened since.
    • A version higher than this build understands throws instead of generating a PDF that silently drops fields it does not know.
    • generatePdf calls 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 — never if (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-designer and json-pdf-designer/server.

Fixed​

  • Editing a table cell on the canvas now updates the column formula too. The cell is the column formula — generate.ts resolves the row from schema.content — and the ƒx panel reads binding.columns[i].formula. Editing through ƒx already 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. bindingExpressionErrors validated column.formula as a bare expression, but the renderer resolves it with renderTemplate — 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 failing generatePdf and 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.
  • 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, so needsNewPageForItem is 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 throws PageLimitError naming 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.
  • Generation errors are exported as classes — PageLimitError, UnsupportedGlyphError, ExpressionSyntaxError/ExpressionDepthError (under ExpressionError) and DEFAULT_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 \n in a customer name — from a textarea, an address with a line break, a CSV import — used to fail the whole document with WinAnsi 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 via fontBytes, 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 bare WinAnsi 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's catch (e) { e.message } got undefined. Same treatment the image field already had.

  • An image binding now actually reaches the PDF. drawImageField read schema.content and 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. A NaN font size falls back to a default (a lost measurement must not cost the document); an invalid page size fails with Pá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 caught ExpressionSyntaxError — 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.
  • 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 still 2.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 as a + (b * c). The previous engine folded left to right, like a pocket calculator, so with a=2 b=3 c=4 it 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 was 0, 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 its string return, 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 | number instead 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 0 or 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.ts used to decide page breaks and draw in the same loop, while countBodyPages walked 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 a LayoutDocument: pages, each holding Placements already positioned and with values already resolved. Page count is pages.length — it cannot disagree with the drawing because it is the drawing.
    • countBodyPages is gone. generate.ts no longer contains a single pagination decision; render/* receives resolved values instead of resolving them.
    • The cursor after a table now advances by computeTableSlice().heightMm instead of by the Y coordinate drawTableSlice returned — the layout no longer has to draw in order to know where to continue.
    • Section measurement moved to layout/sectionLayout.ts and the table metrics to pdf/tableMetrics.ts, so layout/ no longer imports from render/ 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 foundation visibleWhen needs. 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-dist is no longer installed automatically — it moved from a required dependency to an optional peer dependency (peerDependenciesMeta["pdfjs-dist"].optional: true), the same treatment wawoff2 got in 1.6.2. Only projects that render a PDF preview need it; run npm install pdfjs-dist in your own project if you do.

    • Why: pdf.js is ~35MB installed, and as a dependency of the main entry every consumer paid for it — including apps that only render <Designer> and never preview anything.
  • react-rnd is no longer installed automatically either — same move, and it is what actually makes the React-free backend install real. It was a plain dependency, so it came down on every install, and because its own react/react-dom peers 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 imports json-pdf-designer/server. The optional: true this 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. Only PageCanvas.tsx uses it (drag/resize), and nothing reachable from /server does.
    • A backend install now resolves fontkit, pdf-lib and tiny-inflate plus their own trees, and nothing React.
  • PdfPreview, PdfPreviewModal, and configurePdfWorker moved to a new json-pdf-designer/preview entry 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 lazy import() 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 resolve pdfjs-dist at 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.ts imported 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.backgroundImage was 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 on application/pdf; src/pdf/rasterizePdfPage.ts is gone.

Added​

  • examples/no-preview — a fourth example app: <Designer> + generatePdf + downloadPdf, straight to download with no preview screen, and no pdfjs-dist in 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 from src/index.ts, src/server.ts, and src/preview.ts and fails if pdfjs-dist is reachable from the first two, or if react/react-dom/ react-rnd is reachable from /server. Two control cases keep a broken walker from making the others pass vacuously. The CI tarball check also asserts pdfjs-dist is absent from node_modules after 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.content and Template.backgroundImage now 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 real generatePdf pipeline (not a fake page, an actual pdf-lib document) 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 need fontBytes — 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 .tgz into a throwaway consumer, and calls generatePdf from json-pdf-designer/server for real. Catches exports/files/.d.ts regressions that only show up in what actually gets published (see test/pack-consumer/).
  • {IF(condition, "then", "else")} template function — condition is 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 when discountAmount is missing from the data on the false branch. Added to the function picker in the binding editor and the table column- formula editor alongside SUM/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/ isRedZone from src/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 into layout/ and render/ — pure internal refactor, no public API change. generate.ts is now a thin orchestrator; the pagination math (buildBodyItems, boundsOf/ gapAfter, normalizePageDefs, countBodyPages) moved into src/pdf/layout/, and the actual pdf-lib drawing (previously drawTable.ts/drawSection.ts/drawChart.ts/drawKpi.ts, plus two new files split out of generate.ts for text/image) moved into src/pdf/render/ as renderTable.ts/renderSection.ts/ renderChart.ts/renderKpi.ts/renderText.ts/renderImage.ts, dispatched by render/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.get dependency — 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 from bindings.ts as getCaseInsensitive), matching how every other binding type already resolved paths. Removes lodash.get and @types/lodash.get entirely (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)​

  • wawoff2 is no longer installed automatically — it moved from a required dependency to an optional peer dependency (peerDependenciesMeta.wawoff2.optional: true). Only projects that embed a real .woff2 font (generatePdf(..., { fontBytes }) with .woff2 bytes) need it now; run npm install wawoff2 in your own project if you do. Without it installed, passing a .woff2 file throws a clear error (instead of the decompressor silently being there) telling you to install it or convert the font to .ttf/.otf offline instead. .ttf, .otf, and .woff (v1, decompressed via the still-mandatory tiny-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.ts now lazy-loads wawoff2/build/decompress_binding.js via a dynamic import() instead of a static one, only at the moment a .woff2 file is actually being decompressed.

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/ footerBorderRadius was set.

1.6.0 and earlier​

See the GitHub commit history for changes before this changelog started.