Write a proposal
A proposal is not a description of your work. It is a document that makes it easy for a specific person to say yes, and safe for them to defend that yes to someone else.
That second part is what most proposals forget. The person reading it often has to justify the decision to a partner, a board, a finance manager or their own future self. A proposal that only impresses the reader, without arming them, loses to a worse one that does.
Ask these eight questions first
Do not write a proposal you were not invited to write, and do not write one from assumptions. A proposal written without these is a document about you. A proposal written with them is a document about them, and that is the whole difference.
1. What is the problem, in your customer's words, with their numbers on it?
2. What happens if they do nothing? What does it cost them to keep living
with it?
3. Who decides, and who else has to agree?
4. What is the budget range, or at least is this a $5k or a $50k conversation?
5. What is the deadline, and what is driving it?
6. What have they tried already, or who else are they talking to?
7. What did they ask about twice, or seem most worried about?
8. What would make you walk away from this job?
Questions 1 to 5 are load bearing. If you cannot answer them, the next step is a conversation, not a document. Sending a proposal to end an uncomfortable silence is the most expensive habit in small business sales.
Question 7 is the one that wins proposals. Whatever they raised twice is what the decision actually turns on, and putting it in the situation section proves you listened, better than any claim about your company could.
Question 8 is for you, not for them. A proposal you would rather not win should be declined, or priced at what would make it worth doing. A reluctant proposal at a normal price sometimes wins, which is the worst outcome available.
Before writing
An unsolicited proposal is a brochure. If nobody has described a problem to you, you are guessing, and the reader can tell in the first paragraph.
Two answers from the intake deserve a second look before you start.
If question 2 comes back as “nothing much happens”, there is no deal here yet. The proposal is not the problem. Go back and find what it actually costs them to keep living with this, or accept that the timing is wrong and say so.
If question 3 turns up a second decision maker you have not met, the proposal has two audiences and only one of them has heard your pitch. Write it so the person who was never in the room can follow it alone, because they are the one who will raise the objection you are not there to answer.
The structure
Short, in this order. The order matters more than the content, because it puts their world first and yours last.
1. The situation their problem, in their words. No solution yet
2. The outcome what is true after this is done
3. The approach how, in enough detail to be credible, not enough to be a manual
4. Scope what is included, and explicitly what is not
5. Timeline phases, dates, and what you need from them
6. Investment the price, with options
7. Why us proof, kept short and specific
8. What happens next one clear action
9. Terms the boring parts, which protect both sides
Section by section in references/structure.md. Copy ready templates: assets/proposal-template.md and assets/sow-template.md.
The situation goes first and contains no selling. If you can describe their problem better than they can, you have already won the credibility argument. Most proposals open with a company history, which answers a question nobody asked.
Scope is the section that matters
Everything that goes wrong later goes wrong here. A proposal that is vague about scope is not being generous, it is deferring an argument to a point where the relationship is worse and the money is already spent.
Write the exclusions. This feels negative and it is the single highest value paragraph in the document. Three or four lines, specific:
Not included
Repairs to the existing switchboard, should testing reveal a fault
Out of hours work. Quoted at the after hours rate if required
Making good to plasterwork beyond the immediate penetration
Council permits and fees, which are charged at cost
Every one of those is a real invoice conversation that does not have to happen now.
Say what you need from them, and when. Access, decisions, information, sign off. Most projects that run late run late because the client was late, and a proposal that never stated the dependency cannot point at it afterwards.
Full treatment in references/scope-and-exclusions.md.
Price
Give options, usually three. A single price is a yes or no question. Three options changes the question to “which one”, which is a far easier conversation to be in. Make the middle one the one you expect them to take.
Price the outcome, not the hours, where you honestly can. “$14,000 for the switchboard upgrade, tested and certified” is a decision. “$180 per hour, approximately 70 hours” is an invitation to audit your timesheet, and it caps your upside at your speed.
State the price once, plainly, in its own line. Burying it, splitting it across paragraphs or writing it as a range reads as discomfort, and the reader mirrors it.
Never discount in the first version. A price that drops because they hesitated tells them the first number was invented, and they will wait longer next time. If you need to move, move the scope.
Behind any price is a cost you have to know. The short version: take the loaded cost of the person doing the work, wages plus 20% to 35% of on costs, divide by the hours they are actually billable rather than the hours you pay for, add the overhead each billable hour has to carry, and only then apply margin. Businesses that skip this price against the competitor down the road, whose costs they do not know.
Length
Two to six pages for most service work. A proposal that has to be long, because the project genuinely is, puts the argument in the first two pages and the detail in appendices.
The test: could the decision maker read the first page and know what they are buying, what it costs, and what happens next. If not, reorder it.
Rules
- Their words, not yours. Use the exact phrases they used for their problem. If they said “the reporting is a nightmare at month end”, write that, not “reporting inefficiencies”
- Specific beats impressive. “We have done 40 of these in aged care facilities, including three for your group” beats “we are industry leaders”
- One call to action. Not “let us know if you have any questions, or feel free to call, or we could set up a time”
- Put a date on it. A price valid for 30 days is normal, and an undated proposal is one that gets dug up in a year
- Make it signable. If accepting means typing a reply, drafting a purchase order and arranging a call, some proportion of yes never arrives. A signature block or a link is the difference
- Send it when you said you would. Being one day late on the proposal is a preview of the project, and both of you know it
What not to do
- Do not open with your company history. Nobody reads it. It goes near the end, short, and only the parts relevant to their problem
- Do not include a page of logos with no story. A named, similar client with a one line result beats twenty logos
- Do not list your process as numbered phases with invented names. “Phase 1: Discovery. Phase 2: Alignment.” This is filler and reads as filler
- Do not write a proposal to lose. If you would rather not do the work, decline or price it at what would make it worth doing. A reluctant proposal at a normal price sometimes wins, which is the worst outcome available
- Do not send it and wait. Say when you will follow up, then do it. A check on day 2 that it arrived, a call on day 5, and a “should I close this off” around day 20 that gives them permission to say no. The call is the step that works and the one people replace with another email
Producing the document
Write it as markdown and render it:
node scripts/render_pdf.mjs proposal.md --brand=brand.json
A proposal that arrives as a plain email with the price in the fourth paragraph loses to one that arrives as a document, even when the work is identical.
You get a cover page, running headers, page numbers and tables that hold together across pages. Needs Node 18 or newer and Chrome or Edge, and nothing installed. Copy assets/brand.example.json and change the values. Full detail in references/rendering-the-pdf.md.
Name the file the way they will file it: northside-switchboard-proposal-2026-03.pdf.
Reference files
Everything the skill tells your AI to read, exactly as it ships in the zip.
references/rendering-the-pdf.md 5.4 KB
# Rendering the document
`scripts/render_pdf.mjs` turns the markdown into a PDF with a cover page, a contents list, running headers, page numbers and tables that do not split a row across a page.
```
node scripts/render_pdf.mjs document.md
node scripts/render_pdf.mjs document.md --out=final.pdf --brand=brand.json
node scripts/render_pdf.mjs document.md --html
```
Needs Node 18 or newer and Chrome or Edge installed. There is no `npm install`: the markdown parser and the browser client are both inside the script.
`--html` stops after writing the intermediate HTML instead of rendering. Use it whenever the pagination is wrong: open that file in a browser and press print, and you see exactly what the renderer sees. Far faster than rendering a PDF to find out where a page broke.
## The cover page
Frontmatter at the very top of the file drives it. Leave it out and there is no cover, which is right for an internal draft and wrong for anything sent outside the business.
```
---
title: Proposal
subtitle: Switchboard upgrade
client: Northside Electrical
author: Dana Reyes
date: March 2026
confidential: true
---
```
Keys used: `title`, `subtitle`, `client`, `author`, `company`, `date`, `version`, `confidential`. Anything else is parsed and ignored, which makes frontmatter a safe place for notes to yourself. Without a `title` there is no cover at all.
Only `key: value` on single lines. No nesting, no lists, no multi line values.
## Markers
| Marker | Effect |
|---|---|
| `<!-- toc -->` | A contents list built from the `##` and `###` headings |
| `<!-- pagebreak -->` | Forces a new page. `\newpage` on its own line is the same |
Every other HTML comment is stripped, so author notes in a template never reach the PDF.
## The brand file
One JSON file sets colours, fonts, logo, page size and margins. Copy `assets/brand.example.json`, change the values, and every document rendered with it matches.
The logo path inside the brand file resolves relative to the brand file, not to wherever you are standing, so a brand folder moves as one piece.
**Do not edit `assets/document.css` to change a colour.** Colours and fonts are injected from the brand file as CSS variables. Editing the stylesheet is how one customer's document ends up with another customer's blue. If one document must differ, pass `--css=just-this-one.css`, which is appended after the main stylesheet so you only write the difference.
## Supported markdown
A deliberate subset: headings, paragraphs, bold, italic, strikethrough, code spans and fenced blocks, ordered and unordered lists with one level of nesting, task list checkboxes, blockquotes, horizontal rules, links, images and pipe tables.
Anything outside the subset falls through as a paragraph rather than failing, so a document never silently loses text.
**Not supported:** reference style links, inline HTML (escaped on purpose, so a document assembled partly from a customer's own words cannot inject markup), footnotes, nesting past one level, hard line breaks from trailing spaces, bare URL autolinks, and syntax highlighting in code fences.
## Table alignment, which matters most here
The delimiter row sets it, and a financial document lives or dies on this:
```
| Line | FY26 | FY27 |
|---------------|----------:|----------:|
| Revenue | 3,400,000 | 4,100,000 |
| Gross profit | 1,054,000 | 1,435,000 |
```
A colon at the end of a column's delimiter right aligns it. Right aligned cells also get tabular figures, so the digits line up in a column even in a proportional font. **Right align every column of numbers.** A column of currency that is left aligned reads as amateur from across a room.
## When it comes out wrong
| Problem | Cause |
|---|---|
| "No Chrome or Edge found" | The error lists every path it tried. Add yours to the `BROWSERS` array at the top of the script |
| Images are blank boxes | Image paths resolve relative to the markdown file, logo paths relative to the brand file. A very large image can also exceed the 15 second readiness ceiling: shrink it |
| Mysterious blank page | A pagebreak immediately before a heading that already starts a page, or a trailing pagebreak at the end of the document |
| The margins doubled | An `@page` rule was added to the stylesheet. Chrome applies it on top of the print call's margins. Remove it and set margins in the brand file |
| Everything on one enormous page | Something has a fixed height larger than the page, usually a pasted element with an inline style |
| A table row split across a page | A single row taller than a page cannot avoid breaking. Shorten the cell. A long table splitting is correct, and the header repeats automatically |
| Numbers do not line up | The column is not right aligned |
| Text where markup should be | The parser is a subset. See above |
Exit codes: `0` rendered, `1` usage or input error, `2` the browser failed to start or the render failed. On `2` the intermediate HTML is deliberately left on disk and its path is printed. Open it.
## Before sending
- **Read the PDF, not the markdown.** The PDF is the artefact, and reading the source and assuming is how a broken table reaches a bank
- **Check the last page.** A single orphaned line on a final page is the most common flaw in a generated document, and the fix is usually cutting two sentences rather than changing the CSS
- **Name the file the way the recipient will file it.** `northside-switchboard-proposal-2026-03.pdf`, not `document.pdf` or `final_v3_FINAL.pdf`
references/scope-and-exclusions.md 5.2 KB
# Scope, exclusions and change
Everything that goes wrong in a project goes wrong here. Vague scope is not generosity. It defers an argument to a moment when the relationship is worse, the money is spent, and one of you is going to lose.
## The four parts
A complete scope section has four parts. Most proposals have one.
| Part | Answers | Usually missing |
|---|---|---|
| Included | What they get | No |
| Excluded | What they do not | Yes |
| Assumptions | What the price depends on being true | Yes |
| Their obligations | What you need from them, and when | Yes |
## Included
Deliverables, not activities. A deliverable is a thing that exists at the end and can be pointed at.
- "A compliance register covering 14 sites, in a format your insurer accepts" is a deliverable
- "Compliance consulting" is not
Quantify everything that can be counted. Sites, rounds of revisions, pages, users, hours of training, kilometres. An uncounted noun becomes unlimited at the worst possible moment.
```
Included
Testing and tagging of all fixed appliances at 14 nominated sites
One compliance register, in the group's existing spreadsheet format
One remediation list per site
Two rounds of amendments to the register format
One handover session, up to 2 hours, on site or remote
```
## Excluded
The highest value paragraph in the document, and the one most people leave out because it feels negative.
It is not negative. It is the section that prevents the conversation where they expected something you never priced, and one of you has to absorb it. Write three to six lines, specific to this job, drawn from what has actually bitten you before.
```
Not included
Repairs or replacement of any item that fails testing. Quoted separately
Out of hours or weekend attendance, unless requested at the after hours rate
Making good to plaster, paint or ceiling tiles beyond the immediate penetration
Council permits and statutory fees, charged at cost plus 10%
Sites added after the asset list is confirmed. Priced per site
```
**Write exclusions from your own scar tissue.** The generic list is worth something. The list built from the last five jobs that went sideways is worth far more, and it is specific enough that the client reads it rather than skimming.
## Assumptions
What the price depends on being true. If one turns out false, the price changes, and this section is what makes that a normal conversation instead of a dispute.
```
This price assumes
Safe, unescorted access during business hours at all 14 sites
An accurate asset list provided within 5 working days of acceptance
Existing switchboards are labelled and accessible without dismantling
Fewer than 400 assets in total across the 14 sites
```
The numeric ones are the important ones. "Fewer than 400 assets" converts a nasty surprise into a line item.
## Their obligations
Most late projects are late because the client was late. A proposal that never stated the dependency cannot point at it afterwards, and the delay silently becomes yours.
Put them on the timeline with dates, not in a paragraph:
| We need | By when | If it is late |
|---|---|---|
| Signed acceptance | Before scheduling | Dates move |
| Asset list | Within 5 days of acceptance | Start date moves day for day |
| Site access contacts | Before week 2 | Affected sites reschedule |
| Sign off on the register format | Within 5 days of draft | Handover moves |
That last column is the one that does the work. Stating the consequence in advance, once, calmly, means you never have to argue it later.
## The change procedure
Say what happens when something changes, before anything changes.
```
Changes to scope
Any work outside the scope above is quoted in writing before it starts.
Nothing outside this scope is carried out without written approval, and
no approved variation is invoiced at more than the quoted amount.
```
That last clause is worth including. It costs you nothing, it removes the client's main fear about signing an open ended variation clause, and it distinguishes you from every contractor who has ever surprised them.
**Then actually follow it.** A change procedure that gets skipped the first time it is inconvenient is worse than none, because it teaches the client the document is decorative.
## Scope creep, in practice
It rarely arrives as a request for more work. It arrives as a small favour, then another. Each one is genuinely small and refusing feels petty.
The practical rule: **do the small thing, and say that you did.**
```
Happy to do that, no charge. Noting it here so the record stays straight:
that is outside the tested scope, and if the other 13 sites need the same
we will need to quote it as an addition.
```
Generous and precise at the same time. Nobody is offended, and the tenth request cannot come as a surprise to either of you.
## Two scope failures worth naming
**Unlimited revisions.** "Revisions until you are happy" means the project ends when the client runs out of energy. Say two rounds, say what a round is, and say what further rounds cost.
**The undefined noun.** "A report", "training", "support", "integration". Each of these can mean a day or a month. If a noun in your scope could mean two very different amounts of work, count it, bound it, or define it.
references/structure.md 4.7 KB
# The sections
## 1. The situation
Their problem, in their words, with their numbers on it. No solution, no selling, no mention of your company.
```
Northside currently runs switchboard compliance across 14 sites on a paper
schedule held by one facilities manager. Two audits in the last year found
lapsed testing. The group's insurer has asked for evidence of a compliance
regime by 30 June.
```
That paragraph does more work than any claim you could make about yourself. It proves you listened, and it frames the deadline as theirs rather than yours.
**Get it wrong and everything after is discounted.** If you are not certain of a fact, ask before sending rather than hedging in the document.
## 2. The outcome
What is true when this is finished. Written as a state, not as activity.
- "Every site tested, tagged and on a scheduled cycle, with a compliance register your insurer accepts" is an outcome
- "We will conduct testing and provide reports" is activity
Where you can, attach their number to it. Reduced downtime, a passed audit, hours back per month, a renewal secured.
## 3. The approach
Enough detail to be credible, not enough to be a manual. Four to eight lines for most work.
The reader is checking one thing: does this person know what they are doing. Two specifics prove it better than a page of process. Naming the standard you test to, the actual tool, the sequencing constraint, proves competence. Inventing phase names does not.
**Do not give away the whole method** if the method is what they would otherwise pay for. Enough to be trusted, not enough to be handed to a cheaper competitor.
## 4. Scope
See [scope-and-exclusions.md](scope-and-exclusions.md). The most important section in the document.
## 5. Timeline
Phases with dates, and what you need from them at each point.
```
Week 1 Site access arranged by Northside. Asset list confirmed
Weeks 2 to 4 Testing, sites 1 to 14
Week 5 Register compiled, remediation list issued
Week 6 Remediation quoted separately if required
```
Two rules:
- **Every client dependency is on the timeline, not buried in prose.** A project delayed by late access should be visibly delayed by late access
- **Dates start from acceptance, not from today.** "Six weeks from acceptance" survives a proposal that sits for a fortnight. A calendar date does not
## 6. Investment
The price. Its own section, its own line, stated once and plainly.
Three options, usually:
| | What it covers | |
|---|---|---:|
| Essential | The compliance requirement, nothing more | $14,400 |
| **Recommended** | Compliance plus the scheduled cycle and register | **$21,800** |
| Complete | The above plus remediation allowance and annual renewal | $34,500 |
Notes that belong here and nowhere else: what triggers a variation, payment terms, deposit, and how long the price holds.
**Do not apologise for the number and do not explain it defensively.** If the value section did its job, the price is a comparison the reader makes themselves.
## 7. Why us
Short. Specific. After the price, not before it, because it answers a question the reader only asks once they are interested.
A named, similar client with a one line result beats a page of logos:
```
We run the same register for Bellhaven Group across 22 sites. Their 2025
insurance audit cleared in one pass, with no remediation notices.
```
One or two of those. Then stop.
## 8. What happens next
One action. Not three.
```
Sign below, or reply to this email with "approved", and we will book the
first site visit within five working days.
```
If a signature is needed, put the block in the document. Every additional step between yes and started loses a percentage of deals, and the percentage is larger than anyone expects.
## 9. Terms
The boring parts, at the back, short: payment terms, what happens if scope changes, cancellation, insurance, who owns what is produced, how disputes get handled.
They protect both sides, and a proposal with none of them signals inexperience to anyone who has been burned before. Keep them to a page. A proposal where the terms outweigh the substance reads as defensive.
## The statement of work
For anything long or complex, the proposal wins the work and a statement of work governs it. See [assets/sow-template.md](../assets/sow-template.md).
The difference in one line: **the proposal is written to persuade, the statement of work is written to be referred to during an argument.** The SOW is more precise, less warm, and contains the deliverable list, acceptance criteria, the change procedure and the assumptions the price depends on.
If the job is small, fold the essentials into the proposal and skip the separate document. If the job is large, do not let the proposal do the SOW's job.
scripts/render_pdf.mjs 20.4 KB
/**
* Render a markdown document to a branded, print-quality PDF.
*
* node render_pdf.mjs plan.md
* node render_pdf.mjs plan.md --out=business-plan.pdf --brand=brand.json
* node render_pdf.mjs plan.md --html keep the intermediate HTML and stop
*
* Needs Node 18+ and Chrome or Edge installed. No npm install: the markdown parser
* and the CDP client below are the whole dependency list.
*
* Why Chrome and not a PDF library: a real business document needs a cover page,
* running headers and footers, page numbers, controlled page breaks and tables that
* do not split a row across a page. Chrome's print engine does all of that from CSS
* you can read, and the output is the same shape a designer would expect. A PDF
* library would mean drawing every box by hand.
*
* The intermediate HTML is written NEXT TO the source markdown, not in a temp
* directory, so relative image paths in the markdown resolve the way the author
* wrote them. It is deleted afterwards unless --html is passed.
*
* Frontmatter (optional, must be the very first thing in the file) drives the cover:
*
* ---
* title: Business plan
* subtitle: FY26 to FY28
* client: Northside Electrical
* author: Dana Reyes
* date: March 2026
* confidential: true
* ---
*
* Body markers:
* <!-- toc --> replaced by a contents list built from the h2 and h3 headings
* <!-- pagebreak --> forces a new page. \newpage on its own line does the same
*
* Exit codes: 0 rendered, 1 usage or input error, 2 browser or render failure.
*/
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const srcArg = argv.find((a) => !a.startsWith("--"));
const flag = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const htmlOnly = argv.includes("--html");
if (!srcArg) {
console.error("Usage: node render_pdf.mjs <document.md> [--out=file.pdf] [--brand=brand.json] [--css=extra.css] [--html]");
process.exit(1);
}
const srcPath = resolve(srcArg);
if (!existsSync(srcPath)) {
console.error(`No such file: ${srcPath}`);
process.exit(1);
}
const srcDir = dirname(srcPath);
const outPath = resolve(flag("out") ?? join(srcDir, `${basename(srcPath, extname(srcPath))}.pdf`));
const BROWSERS = [
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
];
const DEFAULT_BRAND = {
name: "",
accent: "#0B6EE8",
ink: "#12161C",
muted: "#5A6472",
rule: "#DCE1E8",
headingFont: "Georgia, 'Times New Roman', serif",
bodyFont: "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
monoFont: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
logo: null,
footerNote: "",
pageSize: "A4",
margin: { top: "22mm", bottom: "20mm", left: "18mm", right: "18mm" },
};
/* ------------------------------------------------------------------ markdown */
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
/** Inline spans. Code first, so a backtick span is never re-parsed for emphasis. */
function inline(text) {
const code = [];
let s = text.replace(/`([^`]+)`/g, (_, c) => `\u0000${code.push(`<code>${esc(c)}</code>`) - 1}\u0000`);
// Author notes are not content. Code spans were lifted out above, so a comment
// deliberately being SHOWN inside backticks survives this.
s = s.replace(/<!--[\s\S]*?-->/g, "");
s = esc(s);
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
(_, alt, src, title) => `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ""}>`);
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>');
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
s = s.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
return s;
}
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
/** Pull `key: value` frontmatter if the file opens with a --- fence. */
function frontmatter(raw) {
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!m) return { meta: {}, body: raw };
const meta = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
if (kv) meta[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, "");
}
return { meta, body: raw.slice(m[0].length) };
}
/**
* Block parser. A deliberate subset: headings, paragraphs, fenced code, pipe tables,
* ordered and unordered lists with one level of nesting, blockquotes, rules, images.
* Anything outside the subset passes through as a paragraph rather than failing, so a
* document never silently loses text.
*/
function blocks(md) {
const lines = md.replace(/\r\n/g, "\n").split("\n");
const out = [];
const headings = [];
let i = 0;
/** A sublist belongs INSIDE the <li> it hangs off, not after it. */
const nest = (item, sub) => item.replace(/<\/li>$/, `<ul>${sub.join("")}</ul></li>`);
const listItem = (text) => {
const task = text.match(/^\[([ xX])\]\s+(.*)$/);
if (!task) return `<li>${inline(text)}</li>`;
const done = task[1].toLowerCase() === "x";
return `<li class="task"><span class="box${done ? " on" : ""}"></span>${inline(task[2])}</li>`;
};
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
if (/^(<!--\s*pagebreak\s*-->|\\newpage)\s*$/i.test(line.trim())) {
out.push('<div class="pagebreak"></div>'); i++; continue;
}
if (/^<!--\s*toc\s*-->$/i.test(line.trim())) { out.push("\u0001TOC\u0001"); i++; continue; }
// An author note, possibly spanning several lines. Templates are full of these
// and none of them belong in the rendered document.
if (line.trimStart().startsWith("<!--")) {
while (i < lines.length && !lines[i].includes("-->")) i++;
i++;
continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
const level = h[1].length;
const text = h[2].trim();
const id = slug(text);
if (level === 2 || level === 3) headings.push({ level, text, id });
out.push(`<h${level} id="${id}">${inline(text)}</h${level}>`);
i++; continue;
}
const fence = line.match(/^```\s*([\w-]*)\s*$/);
if (fence) {
const buf = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i])) buf.push(lines[i++]);
i++;
out.push(`<pre class="code"><code>${esc(buf.join("\n"))}</code></pre>`);
continue;
}
if (/^(\*\*\*|---|___)\s*$/.test(line.trim())) { out.push("<hr>"); i++; continue; }
// Pipe table. Needs the delimiter row, otherwise it is just text with pipes in it.
if (line.includes("|") && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1] ?? "")) {
const cells = (r) => r.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
const head = cells(line);
const align = cells(lines[i + 1]).map((d) =>
d.startsWith(":") && d.endsWith(":") ? "center" : d.endsWith(":") ? "right" : "left");
i += 2;
const body = [];
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) body.push(cells(lines[i++]));
const th = head.map((c, n) => `<th style="text-align:${align[n] ?? "left"}">${inline(c)}</th>`).join("");
const tr = body.map((r) =>
`<tr>${r.map((c, n) => `<td style="text-align:${align[n] ?? "left"}">${inline(c)}</td>`).join("")}</tr>`).join("");
out.push(`<table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`);
continue;
}
if (/^\s*>/.test(line)) {
const buf = [];
while (i < lines.length && /^\s*>/.test(lines[i])) buf.push(lines[i++].replace(/^\s*>\s?/, ""));
out.push(`<blockquote>${blocks(buf.join("\n")).html}</blockquote>`);
continue;
}
const bullet = line.match(/^(\s*)([-*+])\s+(.*)$/);
const number = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
if (bullet || number) {
const ordered = Boolean(number);
const tag = ordered ? "ol" : "ul";
const start = ordered ? Number(number[2]) : 1;
const items = [];
let nested = null;
while (i < lines.length) {
const b = lines[i].match(/^(\s*)([-*+])\s+(.*)$/);
const n = lines[i].match(/^(\s*)(\d+)[.)]\s+(.*)$/);
const m = b || n;
if (!m) {
// A plain indented line continues the item it follows.
if (items.length && /^\s{2,}\S/.test(lines[i])) { items[items.length - 1] += ` ${inline(lines[i].trim())}`; i++; continue; }
break;
}
if (Boolean(n) !== ordered && m[1].length === 0) break;
if (m[1].length >= 2) {
nested ??= [];
nested.push(listItem(m[3]));
i++; continue;
}
if (nested) { items[items.length - 1] = nest(items[items.length - 1], nested); nested = null; }
items.push(listItem(m[3]));
i++;
}
if (nested && items.length) items[items.length - 1] = nest(items[items.length - 1], nested);
out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ""}>${items.join("")}</${tag}>`);
continue;
}
const para = [];
while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\s*>|\s*[-*+]\s|\s*\d+[.)]\s)/.test(lines[i])) {
para.push(lines[i++]);
}
const joined = para.join(" ").trim();
if (!joined) { i++; continue; }
// A paragraph that is nothing but an image gets to be a figure, not a text line.
const lone = joined.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/);
out.push(lone
? `<figure><img src="${lone[2]}" alt="${lone[1]}">${lone[1] ? `<figcaption>${inline(lone[1])}</figcaption>` : ""}</figure>`
: `<p>${inline(joined)}</p>`);
}
return { html: out.join("\n"), headings };
}
/* ---------------------------------------------------------------- assembling */
function coverPage(meta, brand) {
if (!meta.title) return "";
const logo = brand.logo ? `<img class="cover-logo" src="${brand.logo}" alt="">` : "";
const rows = [
meta.client && ["Prepared for", meta.client],
meta.author && ["Prepared by", meta.author],
(meta.company || brand.name) && ["Company", meta.company || brand.name],
meta.date && ["Date", meta.date],
meta.version && ["Version", meta.version],
].filter(Boolean);
return `<section class="cover">
${logo}
<div class="cover-body">
<h1 class="cover-title">${inline(meta.title)}</h1>
${meta.subtitle ? `<p class="cover-subtitle">${inline(meta.subtitle)}</p>` : ""}
${rows.length ? `<dl class="cover-meta">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${inline(v)}</dd>`).join("")}</dl>` : ""}
${String(meta.confidential).toLowerCase() === "true" ? '<p class="cover-confidential">Commercial in confidence</p>' : ""}
</div>
</section>
<div class="pagebreak"></div>`;
}
/**
* A contents list, without page numbers. Page numbers would need a second render to
* find out what page each heading landed on, and a contents list that is confidently
* wrong is worse than one that is honestly a list of sections.
*/
function toc(headings) {
if (!headings.length) return "";
const items = headings.map((h) =>
`<li class="toc-h${h.level}"><a href="#${h.id}">${inline(h.text)}</a></li>`).join("");
return `<nav class="toc"><h2 class="toc-title">Contents</h2><ol>${items}</ol></nav>`;
}
async function loadBrand() {
const path = flag("brand");
if (!path) return DEFAULT_BRAND;
let parsed;
try {
parsed = JSON.parse(await readFile(resolve(path), "utf8"));
} catch (e) {
console.error(`Could not read brand file ${path}: ${e.message}`);
process.exit(1);
}
const brand = { ...DEFAULT_BRAND, ...parsed, margin: { ...DEFAULT_BRAND.margin, ...(parsed.margin ?? {}) } };
// A logo path in the brand file is relative to the brand file, not the cwd.
if (brand.logo && !/^(https?:|data:)/.test(brand.logo)) {
brand.logo = pathToFileURL(resolve(dirname(resolve(path)), brand.logo)).href;
}
return brand;
}
async function readCss(brand) {
const bundled = join(HERE, "..", "assets", "document.css");
let css = existsSync(bundled) ? await readFile(bundled, "utf8") : "";
if (!css) console.warn("assets/document.css not found next to the script. Rendering with browser defaults.");
const extra = flag("css");
if (extra) css += `\n${await readFile(resolve(extra), "utf8")}`;
const vars = `:root{
--accent:${brand.accent};--ink:${brand.ink};--muted:${brand.muted};--rule:${brand.rule};
--font-heading:${brand.headingFont};--font-body:${brand.bodyFont};--font-mono:${brand.monoFont};
}`;
return `${vars}\n${css}`;
}
/* ------------------------------------------------------------------- browser */
class Cdp {
#ws; #id = 0; #pending = new Map();
static async connect(url) {
const c = new Cdp();
c.#ws = new WebSocket(url);
await new Promise((res, rej) => {
c.#ws.onopen = res;
c.#ws.onerror = () => rej(new Error("CDP websocket failed to open"));
});
c.#ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
const p = c.#pending.get(msg.id);
if (!p) return; // an event, not a reply
c.#pending.delete(msg.id);
msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
};
return c;
}
send(method, params = {}, sessionId) {
const id = ++this.#id;
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
this.#ws.send(JSON.stringify({ id, method, params, sessionId }));
});
}
close() { try { this.#ws.close(); } catch { /* already gone */ } }
}
async function launch(port) {
const exe = BROWSERS.find((p) => existsSync(p));
if (!exe) throw new Error(`No Chrome or Edge found. Looked in:\n ${BROWSERS.join("\n ")}`);
const profile = await mkdtemp(join(tmpdir(), "render-pdf-"));
const proc = spawn(exe, [
"--headless=new", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
"--no-first-run", "--no-default-browser-check", "--disable-extensions",
"--force-color-profile=srgb", "--allow-file-access-from-files", "about:blank",
]);
proc.on("error", (e) => { console.error("Failed to launch browser:", e.message); process.exit(2); });
const deadline = Date.now() + 20000;
let wsUrl;
while (Date.now() < deadline && !wsUrl) {
try {
const res = await fetch(`http://127.0.0.1:${port}/json/version`);
if (res.ok) wsUrl = (await res.json()).webSocketDebuggerUrl;
} catch { /* not up yet */ }
if (!wsUrl) await new Promise((r) => setTimeout(r, 120));
}
if (!wsUrl) throw new Error(`DevTools never came up on :${port}`);
const cdp = await Cdp.connect(wsUrl);
return {
cdp,
async dispose() { cdp.close(); proc.kill(); await rm(profile, { recursive: true, force: true }).catch(() => {}); },
};
}
const PAGE_SIZES = { // inches, because printToPDF takes inches
A4: [8.27, 11.69],
Letter: [8.5, 11],
Legal: [8.5, 14],
A5: [5.83, 8.27],
};
const mmToIn = (v) => {
const n = parseFloat(v);
if (/mm$/i.test(v)) return n / 25.4;
if (/cm$/i.test(v)) return n / 2.54;
if (/in$/i.test(v)) return n;
if (/pt$/i.test(v)) return n / 72;
return n / 25.4; // bare numbers are millimetres
};
/* ---------------------------------------------------------------------- main */
const raw = await readFile(srcPath, "utf8");
const { meta, body } = frontmatter(raw);
const brand = await loadBrand();
const parsed = blocks(body);
const html = parsed.html.replace("\u0001TOC\u0001", toc(parsed.headings));
const css = await readCss(brand);
const docTitle = meta.title || basename(srcPath, extname(srcPath));
const page = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${esc(docTitle)}</title>
<style>${css}</style></head>
<body class="doc">
${coverPage(meta, brand)}
<main>${html}</main>
</body></html>`;
const htmlPath = join(srcDir, `.${basename(srcPath, extname(srcPath))}.render.html`);
await writeFile(htmlPath, page, "utf8");
if (htmlOnly) {
console.log(`HTML written to ${htmlPath}`);
console.log("Open it in a browser and use Print to preview pagination before rendering the PDF.");
process.exit(0);
}
const [pw, ph] = PAGE_SIZES[brand.pageSize] ?? PAGE_SIZES.A4;
// The header and footer are separate mini-documents with no access to the page CSS,
// so their styling is inline and their font sizes are absolute. This is a Chrome rule,
// not a choice.
const chrome = (content) =>
`<div style="width:100%;font-size:8px;font-family:${brand.bodyFont.replace(/"/g, "'")};color:${brand.muted};
padding:0 ${brand.margin.left} 0 ${brand.margin.right};display:flex;justify-content:space-between;">${content}</div>`;
let browser;
try {
browser = await launch(9339);
const { cdp } = browser;
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
await cdp.send("Page.enable", {}, sessionId);
await cdp.send("Runtime.enable", {}, sessionId);
await cdp.send("Page.navigate", { url: pathToFileURL(htmlPath).href }, sessionId);
// The Cdp client above drops events on the floor, so readiness is polled rather than
// awaited. Poll for the real condition: the document is complete AND every image has
// finished, because a half decoded image prints as a blank box with no error anywhere.
// `complete` goes true when an image finishes loading OR fails. Waiting for
// naturalWidth as well would hang the full 15s on every broken path, which is the
// opposite of useful: a missing logo should be reported instantly, not waited on.
const ready = `document.readyState === "complete" &&
Array.from(document.images).every((i) => i.complete)`;
const deadline = Date.now() + 15000;
for (;;) {
const { result } = await cdp.send("Runtime.evaluate", { expression: ready, returnByValue: true }, sessionId);
if (result.value === true) break;
if (Date.now() > deadline) { console.warn("Page never settled after 15s. Printing it as it stands."); break; }
await new Promise((r) => setTimeout(r, 100));
}
// Name anything that failed to load. A blank box in a PDF with no warning anywhere
// is the single most common way a branded document goes out looking broken.
const { result: broken } = await cdp.send("Runtime.evaluate", {
expression: `JSON.stringify(Array.from(document.images)
.filter((i) => i.naturalWidth === 0 && i.getAttribute("src"))
.map((i) => i.getAttribute("src")))`,
returnByValue: true,
}, sessionId);
for (const src of JSON.parse(broken.value || "[]")) {
console.warn(`Image did not load, it will be blank in the PDF: ${decodeURI(src)}`);
}
// Fonts lay out after load and shift the pagination if printed too early.
await cdp.send("Runtime.evaluate", { expression: "document.fonts.ready", awaitPromise: true }, sessionId).catch(() => {});
const { data } = await cdp.send("Page.printToPDF", {
printBackground: true,
preferCSSPageSize: false,
paperWidth: pw,
paperHeight: ph,
marginTop: mmToIn(brand.margin.top),
marginBottom: mmToIn(brand.margin.bottom),
marginLeft: mmToIn(brand.margin.left),
marginRight: mmToIn(brand.margin.right),
displayHeaderFooter: true,
headerTemplate: chrome(`<span>${esc(brand.name || "")}</span><span>${esc(docTitle)}</span>`),
footerTemplate: chrome(
`<span>${esc(brand.footerNote || "")}</span><span class="pageNumber"></span>`),
}, sessionId);
await writeFile(outPath, Buffer.from(data, "base64"));
console.log(`Wrote ${outPath}`);
if (parsed.headings.length) console.log(`${parsed.headings.length} headings, cover ${meta.title ? "on" : "off"}`);
} catch (e) {
console.error(`Render failed: ${e.message}`);
console.error(`The intermediate HTML is at ${htmlPath}. Open it in a browser to see what the page actually looks like.`);
process.exitCode = 2;
} finally {
await browser?.dispose();
if (!process.exitCode) await rm(htmlPath, { force: true }).catch(() => {});
}
assets/brand.example.json 621 B
{
"name": "Northside Electrical",
"accent": "#0B6EE8",
"ink": "#12161C",
"muted": "#5A6472",
"rule": "#DCE1E8",
"headingFont": "Georgia, 'Times New Roman', serif",
"bodyFont": "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
"monoFont": "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
"_logo": "a path relative to THIS file, e.g. logo.png. Rename to \"logo\" to use it",
"logo": null,
"footerNote": "Northside Electrical Pty Ltd | Commercial in confidence",
"pageSize": "A4",
"margin": { "top": "22mm", "bottom": "20mm", "left": "18mm", "right": "18mm" }
}
assets/document.css 5.7 KB
/*
* Print stylesheet for render_pdf.mjs.
*
* The script injects a :root block above this file with --accent, --ink, --muted,
* --rule and the three font stacks, taken from the brand file. Everything here is
* expressed in those variables, so rebranding a document means editing brand.json
* and not this file.
*
* Page size and margins are set by the script through printToPDF, not by @page.
* Chrome applies printToPDF margins and @page margins to the same box, and having
* both set is the usual cause of a document whose margins mysteriously double.
*/
* { box-sizing: border-box; }
body.doc {
margin: 0;
font-family: var(--font-body);
font-size: 10.5pt;
line-height: 1.55;
color: var(--ink);
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* ------------------------------------------------------------------- cover */
.cover {
display: flex;
flex-direction: column;
justify-content: center;
/* Fills the printable box. 297mm less the 22mm and 20mm the script sets. */
min-height: 250mm;
}
.cover-logo { max-height: 18mm; max-width: 70mm; margin-bottom: 16mm; }
.cover-title {
font-family: var(--font-heading);
font-size: 34pt;
line-height: 1.12;
font-weight: 700;
margin: 0 0 4mm;
color: var(--ink);
}
.cover-subtitle {
font-size: 13pt;
color: var(--muted);
margin: 0 0 14mm;
font-weight: 400;
}
.cover-body::before {
content: "";
display: block;
width: 28mm;
height: 3pt;
background: var(--accent);
margin-bottom: 10mm;
}
.cover-meta {
display: grid;
grid-template-columns: 34mm 1fr;
gap: 2mm 6mm;
margin: 0;
font-size: 10pt;
}
.cover-meta dt { color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; font-size: 8pt; padding-top: 1pt; }
.cover-meta dd { margin: 0; font-weight: 600; }
.cover-confidential {
margin-top: 16mm;
font-size: 8pt;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
/* --------------------------------------------------------------- headings */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
color: var(--ink);
line-height: 1.25;
/* Never leave a heading alone at the foot of a page. */
break-after: avoid-page;
page-break-after: avoid;
break-inside: avoid-page;
}
main h1 { font-size: 22pt; margin: 0 0 6mm; }
h2 {
font-size: 15pt;
margin: 10mm 0 3mm;
padding-bottom: 2mm;
border-bottom: 0.7pt solid var(--rule);
}
h3 { font-size: 12pt; margin: 7mm 0 2mm; }
h4 { font-size: 10.5pt; margin: 5mm 0 1.5mm; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
main > h2:first-child, main > h1:first-child { margin-top: 0; }
/* ------------------------------------------------------------------- text */
p { margin: 0 0 3.2mm; orphans: 3; widows: 3; }
a { color: var(--accent); text-decoration: none; }
strong { font-weight: 650; }
ul, ol { margin: 0 0 3.2mm; padding-left: 6mm; }
li { margin-bottom: 1.4mm; break-inside: avoid; }
li > ul, li > ol { margin-top: 1.4mm; }
li.task { list-style: none; margin-left: -5mm; display: flex; gap: 2.5mm; align-items: baseline; }
li.task .box {
flex: 0 0 auto;
width: 3mm; height: 3mm;
border: 0.7pt solid var(--muted);
border-radius: 0.6mm;
}
li.task .box.on { background: var(--accent); border-color: var(--accent); }
blockquote {
margin: 0 0 3.2mm;
padding: 1mm 0 1mm 5mm;
border-left: 2pt solid var(--accent);
color: var(--muted);
break-inside: avoid;
}
blockquote p:last-child { margin-bottom: 0; }
hr { border: 0; border-top: 0.7pt solid var(--rule); margin: 7mm 0; }
code {
font-family: var(--font-mono);
font-size: 0.88em;
background: #F3F5F8;
padding: 0.3mm 1mm;
border-radius: 0.8mm;
}
pre.code {
font-family: var(--font-mono);
font-size: 8.5pt;
line-height: 1.45;
background: #F3F5F8;
border: 0.7pt solid var(--rule);
border-radius: 1.5mm;
padding: 3mm 4mm;
margin: 0 0 3.2mm;
white-space: pre-wrap; /* a long line wraps rather than being cut off the page */
word-break: break-word;
break-inside: avoid;
}
pre.code code { background: none; padding: 0; font-size: inherit; }
/* ----------------------------------------------------------------- tables */
table {
width: 100%;
border-collapse: collapse;
margin: 0 0 4mm;
font-size: 9.5pt;
break-inside: auto;
}
thead { display: table-header-group; } /* repeat the header on every page */
tr { break-inside: avoid; page-break-inside: avoid; }
th {
text-align: left;
font-weight: 650;
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
border-bottom: 1pt solid var(--ink);
padding: 2mm 2.5mm;
}
td { padding: 2mm 2.5mm; border-bottom: 0.5pt solid var(--rule); vertical-align: top; }
tbody tr:nth-child(even) { background: #FAFBFC; }
/* Right align a column of numbers by putting :--- in the markdown delimiter row. */
td[style*="right"], th[style*="right"] { font-variant-numeric: tabular-nums; }
/* ---------------------------------------------------------------- figures */
figure { margin: 0 0 4mm; break-inside: avoid; text-align: center; }
img { max-width: 100%; }
figcaption { font-size: 8.5pt; color: var(--muted); margin-top: 1.5mm; }
/* -------------------------------------------------------------- contents */
.toc { break-after: page; page-break-after: always; }
.toc-title { border: 0; margin-top: 0; }
.toc ol { list-style: none; padding: 0; }
.toc li { margin-bottom: 2mm; }
.toc a { color: var(--ink); }
.toc-h3 { padding-left: 6mm; font-size: 9.5pt; color: var(--muted); }
.toc-h3 a { color: var(--muted); }
/* ----------------------------------------------------------------- breaks */
.pagebreak { break-after: page; page-break-after: always; height: 0; }
/* Put class="keep" on a heading to start its section on a fresh page. */
.keep { break-before: page; page-break-before: always; }
assets/proposal-template.md 2.7 KB
---
title: <What this is, in their language>
subtitle: <Prepared for Client Name>
client: <Client name>
author: <Your name>
company: <Your business>
date: <Month Year>
version: <1.0>
---
<!-- Render to PDF with the script that ships alongside this template:
node scripts/render_pdf.mjs proposal.md --brand=brand.json
Delete every angle bracket prompt. One left in a sent proposal is the most
damaging typo in this genre. -->
## The situation
<Their problem, in their words, with their numbers on it. No solution, no selling,
no mention of your company. Three to five lines.>
<What is driving the timing. Ideally their deadline, not yours.>
## What you will have when this is done
<The outcome as a state, not as activity. Attach their number to it where you can.>
- <>
- <>
- <>
## How we will do it
<Four to eight lines. Enough detail to prove competence, not enough to be a manual.
Two specifics beat a page of process.>
## Scope
**Included**
- <Deliverables, not activities. Quantify every countable noun>
- <>
- <>
**Not included**
- <Three to six lines, specific to this job, drawn from what has actually bitten you>
- <>
- <>
**This price assumes**
- <The numeric ones matter most. "Fewer than 400 assets" converts a surprise into a line item>
- <>
**What we need from you**
| We need | By when | If it is late |
|---|---|---|
| Signed acceptance | Before scheduling | Dates move |
| <> | <> | <> |
## Timeline
<Dates from acceptance, not calendar dates, so the proposal survives sitting on a desk.>
| When | What |
|---|---|
| Week 1 | <> |
| Weeks 2 to 4 | <> |
| Week 5 | <> |
## Investment
| | What it covers | |
|---|---|---:|
| <Essential> | <> | <$> |
| **<Recommended>** | <> | **<$>** |
| <Complete> | <> | <$> |
<Payment terms. Deposit. What triggers a variation.>
This price is held until <date, usually 30 days>.
**Changes to scope.** Any work outside the scope above is quoted in writing before it starts. Nothing outside this scope is carried out without written approval, and no approved variation is invoiced at more than the quoted amount.
## Why us
<One or two named, similar clients with a one line result each. Then stop.>
<Relevant credentials only. Licences, insurance, accreditations that matter for this job.>
## What happens next
<One action. Not three.>
<Sign below, or reply to this email with "approved", and we will <the specific first
thing that happens> within <timeframe>.>
| | |
|---|---|
| Accepted for <Client name> | |
| Name | |
| Signature | |
| Date | |
<!-- pagebreak -->
## Terms
<Payment terms. Cancellation. Insurance. Ownership of what is produced. Dispute
handling. Keep to one page. Terms that outweigh the substance read as defensive.>
assets/sow-template.md 4.3 KB
---
title: Statement of work
subtitle: <Project name>
client: <Client name>
company: <Your business>
date: <Month Year>
version: <1.0>
---
<!-- A proposal is written to persuade. A statement of work is written to be referred
to during an argument. It is more precise, less warm, and it is the document that
governs the project once the proposal has done its job.
Render it the same way as the proposal:
node scripts/render_pdf.mjs sow.md --brand=brand.json
Use this for anything long or complex. For small jobs, fold the essentials into
the proposal and skip this. -->
## 1. Parties and documents
| | |
|---|---|
| Client | <Legal entity name, not the trading name> |
| Supplier | <Legal entity name> |
| Effective from | <date> |
| This SOW governs | <project name> |
| Order of precedence | This SOW, then the master agreement dated <date>, then the proposal dated <date> |
<Order of precedence matters. When two documents disagree, this line decides which
wins, and without it the argument has no resolution other than goodwill.>
## 2. Objective
<One paragraph. What this project is for, in business terms. Not the method.>
## 3. Deliverables
Each deliverable is a thing that exists at the end and can be pointed at.
| # | Deliverable | Format | Due | Acceptance criteria |
|---|---|---|---|---|
| D1 | <> | <> | <> | <How we know it is done. Objective, testable> |
| D2 | | | | |
**Acceptance.** The Client has <5> working days from delivery to accept a deliverable or to give written notice of what does not meet the criteria. A deliverable not rejected in writing within that period is accepted.
<That last sentence prevents the most common failure mode in project work: a
deliverable that is never formally accepted and never formally rejected, so the
project never ends and the final payment never falls due.>
## 4. Out of scope
<Explicit. Everything a reasonable person might assume is included and is not.>
- <>
- <>
## 5. Assumptions
The fees and dates in this SOW depend on the following being true. If any is not, section 9 applies.
- <>
- <>
## 6. Client obligations
| The Client will | By when |
|---|---|
| <> | <> |
| <> | <> |
Delay in any of the above moves dependent dates day for day.
## 7. Timeline
| Phase | Starts | Ends | Deliverables |
|---|---|---|---|
| <> | <> | <> | <> |
Dates run from <acceptance / the effective date>.
## 8. Fees and payment
| Item | Amount |
|---|---:|
| <> | <$> |
| **Total** | **<$>** |
| | |
|---|---|
| Payment schedule | <Deposit on signature, balance on acceptance of D3> |
| Terms | <14 days from invoice> |
| Expenses | <At cost, pre-approved in writing above $X> |
| Tax | <Amounts are exclusive of applicable sales tax / GST> |
| Late payment | <Interest rate, and the right to suspend work> |
## 9. Change control
1. Either party may request a change in writing.
2. The Supplier responds within <5> working days with the impact on scope, fees and timeline.
3. No change takes effect until both parties sign a change order.
4. No approved change order is invoiced at more than the amount stated on it.
5. Work continues on the unchanged scope while a change order is under discussion.
<Point 5 is worth keeping. Without it, a change request can stall the whole project.>
## 10. Intellectual property
<Who owns what is produced. What the Supplier retains, typically their pre-existing
tools and methods. What licence the Client gets, and whether it survives non payment.>
## 11. Confidentiality
<Or a reference to the master agreement, if one covers it.>
## 12. Insurance and compliance
| | |
|---|---|
| Public liability | <$ and insurer> |
| Professional indemnity | <$ and insurer> |
| Workers compensation | <> |
| Licences | <Numbers and jurisdiction> |
## 13. Termination
<Notice period. What is payable on termination: work completed to date plus
committed costs. What is handed over. What survives.>
## 14. Signatures
| | Client | Supplier |
|---|---|---|
| Name | | |
| Title | | |
| Signature | | |
| Date | | |
<!-- This template is a starting structure, not legal advice. Laws on contract terms,
liability limits and consumer protection vary by jurisdiction, and the parts that
matter most in a dispute are exactly the parts that vary. Have a lawyer review the
first one, then reuse it. -->