Engagement Letter

Last updated on

A prose document — the artifact with two signature slots, a composition that renders its scope as numbered clauses, and one sample that runs to two pages.

engagement-letter is the one block here with no table and no Bundle: a letter is a single document, and what paginates it is nine numbered clauses of running prose rather than rows of a predictable height, each one its own unbreakable unit tall enough on its own to decide where a page breaks.

Preview

Rendering live preview requires JavaScript.

Installation

npx shadcn@4 add @paradoc/engagement-letter

Or, with the Paradoc CLI, which writes the namespace into components.json for you:

npx paradoc-cli add engagement-letter

Installs to artifacts/paradoc/engagement-letter.artifact.ts, artifacts/paradoc/engagement-letter.data.ts, and components/paradoc/engagement-letter.tsx and brings along document, field, list, page-number, party, section, signature, text.

Usage

Composition

components/paradoc/engagement-letter.tsx

/** @jsxRuntime classic */import React from "react";import type { Form } from "@paradoc/types";import { Document } from "@/components/paradoc/document";import {  markDocumentRoot,  useList,  type DocumentData,  type DocumentTokensInput,  type FormatOptions,  type PageFurniture,} from "@paradoc/react";import { Field } from "@/components/paradoc/field";import { List, type ListItem } from "@/components/paradoc/list";import { PageNumber } from "@/components/paradoc/page-number";import { Party } from "@/components/paradoc/party";import { Section } from "@/components/paradoc/section";import { Signature } from "@/components/paradoc/signature";import { Text } from "@/components/paradoc/text";import { engagementLetterForm } from "../../artifacts/paradoc/engagement-letter.artifact";/** * The engagement letter, composed from the components. * * One tree, built from `Document`, `Section`, `Text`, `Party`, `Field`, `List` * and `Signature`, that carries no copy of any label or format and * sizes none of its own text. It is the same tree the preview paginates and the * PDF renders; `engagementLetterFurniture` numbers its pages in both. * * **The clauses are a `List`, not a hand-built row.** A numbered clause is a * heading and a paragraph that belong together: split one across a page and the * number is on the page above its own words. `List` is exactly that rule — * every item is one keep, keyed `clause:<index>`, and the level withdraws from * a page holding none of them — so the composition writes no marker, no keep * and no withdrawal of its own. The items are read from the data rather than * written out, because the artifact declares the clauses as a list and a * composition that hard-coded ten of them would render nine when a filler * supplies nine. * * **The clause text is read from the context, not from a nested `Field` or * `Text`.** An item is a keep, and a keep inside a keep is measured twice: the * plan would see the clause and its own paragraph as two overlapping units. So * the clause draws the value the same way `Field` does — through the document's * formatter, at the path the artifact names — and stays one unit, set in the * item's own body text with only the heading's weight added. The prose fields * outside the clauses are `Field`s of their own, and the long ones are printed * as paragraphs so a filler's blank line breaks the page rather than * overflowing it. * * **The parties are `Party` blocks.** The firm and the client are the * artifact's parties, so each is named by its own block. Their addresses and * the client's contact are fields beside it: the party schema models the party * itself, not where it is or who in it the letter is addressed to. * * **There is no `Bundle`.** A letter is one document, and the element walk * stops at the first root it finds. One root is what keeps one declaration from * becoming two. *//** Stable keep prefix for the numbered clauses. The PDF is hinted with the same ids. */const CLAUSE_KEEP_PREFIX = "clause";/** * The scope of services, one numbered clause per item. * * The marker, the keep per item and the withdrawal from a page holding none of * them are all `List`'s. What is left here is the one thing the component * cannot know: that a clause is a heading over its own detail, at two paths the * artifact declares. */function Clauses({ path }: { path: string }) {  const list = useList(path);  // Both lines take the item's own size and leading from `List`; the heading  // adds only its weight.  const items: ListItem[] = list.rows.map((_clause, index) => ({    text: (      <span className="flex flex-col gap-1">        <span data-field-path={`${path}.${index}.heading`} className="font-semibold text-neutral-900">          {list.text(index, "heading")}        </span>        <span data-field-path={`${path}.${index}.detail`}>{list.text(index, "detail")}</span>      </span>    ),  }));  return <List id={CLAUSE_KEEP_PREFIX} items={items} />;}/** * The letter's page furniture: the page number and the count, in the footer. * * Hand the same object to `<Pages furniture>` and to `renderPdf`, so the * preview and the PDF number the same pages. It is drawn inside the margin, so * the page plan and the page count are what they are without it. */export const engagementLetterFurniture: PageFurniture = { footer: <PageNumber /> };export interface EngagementLetterDocumentProps {  /** The engagement letter data to render. */  data: DocumentData;  /** Overrides the artifact, for tests that vary it. */  artifact?: Form;  /** How values the serializer registry does not cover are formatted, and which registry (US or EU) covers the rest. */  format?: FormatOptions;  /** Tenant branding. See `src/examples/tokens.ts` for a set that changes every token. */  tokens?: DocumentTokensInput;}/** * The composition's content. Nothing here sizes its own text: the title is a * `Text` heading, the parties are `Party` blocks, and every value inherits the * document's body size, so the whole letter follows `typography`. */function EngagementLetterBody({ artifact }: { artifact: Form }) {  return (    <>      <Section id="masthead" className="flex flex-row justify-between gap-8 border-b border-neutral-800 pb-4">        <div className="flex basis-1/2 flex-col gap-1">          <Text keepId="title" role="heading" as="span">            {artifact.title}          </Text>          <Party role="firm" label={false} />          <Field path="firmAddress" label={false} />        </div>        <div className="flex basis-1/3 flex-col gap-2">          <Field path="reference" />          <Field path="effectiveDate" />        </div>      </Section>      <Section id="client" title="To" className="flex flex-col gap-1">        <Party role="client" label={false} className="flex flex-col gap-0.5 font-medium" />        <Field path="clientContact" label={false} />        <Field path="clientAddress" label={false} />      </Section>      <Section id="matter" title="Matter">        <Field path="matter" label={false} paragraphs />      </Section>      <Section id="scope" title="Scope of services">        <Clauses path="scopeOfServices" />      </Section>      <Section id="fees" title="Fees" className="flex flex-col gap-2">        <Field path="feeBasis" label={false} paragraphs />        <Field path="retainer" />      </Section>      <Section id="term" title="Term and termination" className="flex flex-col gap-2">        <Field path="term" label={false} paragraphs />        <Field path="termination" label={false} paragraphs />      </Section>      <Section id="governing-law" title="Governing law">        <Field path="governingLaw" label={false} paragraphs />      </Section>      <Section id="acceptance" title="Agreed" className="flex flex-col gap-4 pt-4">        <div className="flex flex-row gap-10">          <Signature party="firm" className="flex basis-1/2 flex-col gap-1" />          <Signature party="client" className="flex basis-1/2 flex-col gap-1" />        </div>      </Section>    </>  );}/** * The composed engagement letter. * * Exported by name and as the module's default. The default is what a React * layer binds to when the renderer imports the module the layer's path names, * which is the convention a composition module follows. */export function EngagementLetterDocument({  data,  artifact = engagementLetterForm,  format,  tokens,}: EngagementLetterDocumentProps) {  return (    <Document artifact={artifact} data={data} format={format} tokens={tokens} id="engagement-letter">      <EngagementLetterBody artifact={artifact} />    </Document>  );}markDocumentRoot(EngagementLetterDocument);export default EngagementLetterDocument;

Sample data

artifacts/paradoc/engagement-letter.data.ts

import type { RuntimeParty } from "@paradoc/types";import type { DocumentData } from "@paradoc/react";/** * Sample data for the engagement letter. * * One set, and one is enough. The priced samples carry a short set and an * overflow set because their question is where a table breaks, and a table * breaks differently at four rows and at sixty. A letter's question is whether * a clause of running prose survives the break above it intact and whether the * signing blocks stay with the clause they close, and one letter of nine * clauses asks both: it runs past the end of the first page, so the plan has a * break to place, and it ends in two `Signature` keeps that must not be split. * * The clause count is asserted in `@paradoc/react`'s `tests/engagement-letter-artifact.test.ts`, * so shortening the letter fails rather than quietly making the second page * disappear. */const firm = {  name: "Ashgrove Rowan",  legalName: "Ashgrove Rowan LLP",  domicile: "US",  entityType: "Limited liability partnership",  taxId: "26-4471903",};const client = {  name: "Harbor Freight Collective",  legalName: "Harbor Freight Collective, Inc.",  domicile: "US",  entityType: "Corporation",  taxId: "58-1029384",};/** One numbered undertaking. The composition renders each as its own keep. */interface Clause {  heading: string;  detail: string;}/** Nine clauses: the letter runs onto a second page, so the plan has a break to place. */const SCOPE: Clause[] = [  {    heading: "Advice on the carrier agreements",    detail:      "We will review the twelve carrier agreements you have sent us, report on the terms that differ from your standard form, and tell you which of those differences we think matter. The report will be one document covering all twelve rather than one per agreement.",  },  {    heading: "Renegotiation of the two largest",    detail:      "We will act for you in renegotiating the agreements with Vantage Line and Colter Haulage, including drafting the amended terms and attending the meetings at which they are discussed. We will not commit you to anything without your written instruction.",  },  {    heading: "Standard form for new carriers",    detail:      "We will draft a standard carrier agreement for you to use with new carriers, together with a short note explaining which clauses you may vary in a negotiation and which you should not.",  },  {    heading: "Regulatory review",    detail:      "We will review your operations against the federal and state carrier regulations that apply in the four states you operate in, and report anything that needs to change. This review covers the rules as they stand at the effective date; we will tell you about later changes only if you ask us to keep the review current.",  },  {    heading: "Insurance and indemnity terms",    detail:      "We will advise on the insurance and indemnity provisions in each agreement, and on whether the cover you hold answers the liabilities those provisions leave with you. We do not advise on whether the cover is competitively priced, which is a matter for your broker.",  },  {    heading: "Disputes short of proceedings",    detail:      "We will advise on disputes with carriers and correspond with them on your behalf. If a dispute goes to proceedings we will tell you before it does, and we will agree separate terms with you for that work rather than doing it under this letter.",  },  {    heading: "Data protection in the driver application",    detail:      "We will review the notices and consents in your driver application against the privacy laws of the states your drivers work in, and draft replacements for any that do not answer them.",  },  {    heading: "Reporting",    detail:      "We will write to you monthly with the position on each part of this engagement, the fees incurred to date, and anything we need a decision on. You may ask for a report at any other time and we will not charge for producing it.",  },  {    heading: "What is not included",    detail:      "We are not engaged to advise on tax, on employment matters, or on the law of any country other than the United States. If you want advice on any of those we will agree it with you separately, in writing, before we start.",  },];/** * The sample's data, whose parties carry runtime ids. * * A document only prints a party, so `DocumentData` asks for the wider `Party`. * The seal binds a signer to a party by id, so the sample states that its own * parties have one rather than asserting it later. */export interface EngagementLetterData extends DocumentData {  parties: Record<string, RuntimeParty | RuntimeParty[]>;}/** The engagement letter sample: a firm engaged on a haulier's carrier contracts. */export const engagementLetterData: EngagementLetterData = {  fields: {    reference: "AR-2026-0917",    effectiveDate: "2026-09-15",    matter: "Carrier contracts and regulatory review",    firmAddress: {      line1: "310 Congress Avenue",      line2: "Floor 11",      locality: "Austin",      region: "TX",      postalCode: "78701",      country: "US",    },    firmContact: { name: "Priya Raman", firstName: "Priya", lastName: "Raman", title: "Ms." },    clientAddress: {      line1: "88 Wharf Road",      locality: "Oakland",      region: "CA",      postalCode: "94607",      country: "US",    },    clientContact: { name: "Marisol Vega", firstName: "Marisol", lastName: "Vega", title: "Ms." },    scopeOfServices: SCOPE,    feeBasis:      "We charge by the hour at the rates in the schedule we sent you with this letter, reviewed each January. We invoice monthly in arrears, and payment is due 30 days from the invoice date. We will tell you before the fees on any part of this engagement pass the estimate we gave you for it.",    retainer: { amount: 15000, currency: "USD" },    term:      "The engagement begins on the effective date above and runs until the work described in the scope is finished, or until either party ends it under the clause below.",    termination:      "Either party may end this engagement by giving 14 days written notice to the other. You are liable for fees and expenses incurred to the date the notice takes effect. Our duty of confidentiality survives the end of the engagement and has no time limit.",    governingLaw:      "This engagement is governed by the law of the State of Texas, and the courts of Travis County have exclusive jurisdiction over any dispute arising from it.",  },  parties: {    firm: { id: "firm-0", ...firm },    client: { id: "client-0", ...client },  },};

Composition

The letter is one Document, built from Section, Text, Party, Field, and List — the masthead, the client's details, the matter, the scope of services, the fees, the term, and the governing law all read from the artifact through the same context every other document here uses. No line sets its own text size: the title is a Text heading and every value follows the document's typography token. The firm and the client are named by Party blocks; their addresses and the client's contact are fields beside them, because a party record does not carry either.

The scope of services is a numbered List: each clause is a heading and a paragraph that must land on the same page, and every list item is one keep, so the composition writes no marker and no keep of its own. The clause text is read from the artifact's scopeOfServices list rather than through a nested Field — a keep inside a keep would be measured twice, once as the clause and once as its own paragraph. A Page Number in the footer, exported as engagementLetterFurniture, numbers the pages of the preview and of the sealed PDF alike.

The letter closes with two Signature blocks, one per party, sitting inside the closing "Agreed" section so neither is ever split from the clause above it. Rendering the letter live means wrapping it in Pages, the same as any other document here — the composition itself renders bare, with no Bundle, because a letter is one document and the element walk stops at the first root it finds.

Variants

A block's Variants section shows an existing alternate sample-data scenario, never a new prop configuration. The letter ships exactly one sample-data scenario — nine clauses long enough to run the letter onto a second page, so the plan has a break to place and the closing signatures have to survive it. There is no second scenario to show, so rather than invent one, this variant documents that and renders the same real letter again.

Standard letter

The block's one real sample scenario, shown again for its own subheading.

Rendering live preview requires JavaScript.

On this page