Skip to main content

Proposal Templates That Won't Let You Paste "[client's first name]": Bracket Placeholders, Generated Fields and an Honest Copy Button

Updated 11 min readMarkdown
Proposal Templates That Won't Let You Paste "[client's first name]": Bracket Placeholders, Generated Fields and an Honest Copy Button

Every freelancer who uses a proposal template has a version of the same nightmare: the proposal went out, the client replied, and the reply quoted your opening line back to you. "Hi [client's first name]". Or the price line still said $[amount]. A template saves time right up to the moment a forgotten bracket tells the reader exactly how much attention you paid them.

The template I have in mind is the one on Digital Dialogue's Upwork proposal template for a website project. It's deliberately written with square-bracket placeholders: [client's first name], [number of pages], [you send the text and photos / I write the text], and a question line with an example inside it. The instruction is simple: "Copy it, replace everything in [brackets], and delete any line that does not fit the job."

This post turns that kind of template into a small, accessible fill-in form. It reads the placeholders out of the text, generates one labelled field per placeholder, shows a live preview, and has a copy button that refuses to copy while anything is still in brackets, and says why in a way a screen reader user hears. The logic is a plain ES module with tests for Node's built-in runner, and the DOM layer was checked in headless Chrome.

The syntax we're parsing

Three shapes of placeholder show up in real templates:

[client's first name]                                   a free-text field
[you send the text and photos / I write the text]       a choice between options
[One question that changes the price, e.g. "Do you already have hosting and a domain?"]
                                                        a field with a hint

And one thing that looks like a placeholder but isn't: a Markdown link, [my demo](https://example.com/demo). If your template lives in a notes app that uses Markdown, you don't want "my demo" turning into a form field.

Finding placeholders

One regular expression does most of the work:

// [label]  [a / b]  [label, e.g. "example"]   but not Markdown links: [text](url)
const RE = /\[([^\[\]\n]{1,120})\](?!\()/g;

The negative lookahead (?!\() skips anything immediately followed by an opening parenthesis, which is what makes Markdown links safe. The character class excludes brackets and newlines, so a stray [ on one line doesn't swallow the rest of the template, and the 120-character cap stops a badly formed template from producing one enormous "field".

Each match is parsed into a label, an optional hint and optional options:

export function parsePlaceholder(raw) {
  let label = raw.trim(), hint = '';
  const eg = label.match(/^(.*?),?\s+e\.g\.\s+(.+)$/i);
  if (eg) { label = eg[1].trim(); hint = eg[2].trim().replace(/^["“]|["”]$/g, ''); }
  const options = label.includes(' / ') ? label.split(' / ').map((s) => s.trim()).filter(Boolean) : [];
  return { raw, label, hint, options };
}

The slash rule needs spaces on both sides (' / '), so a placeholder like [TCP/IP setting] stays a text field.

The same placeholder often appears twice, the client's name in the greeting and again later, so the finder de-duplicates and counts:

export function findPlaceholders(text) {
  const seen = new Map();
  for (const m of String(text).matchAll(RE)) {
    const key = m[1].trim();
    if (!key) continue;
    if (seen.has(key)) seen.get(key).count++;
    else seen.set(key, { ...parsePlaceholder(m[1]), count: 1, index: m.index });
  }
  const used = new Set();
  return [...seen.values()].map((p) => {
    let id = slug(p.label) || 'ph', n = 2;
    while (used.has(id)) id = `${slug(p.label)}-${n++}`;
    used.add(id);
    return { ...p, id };
  });
}

Fields keep the order they first appear in, which is also the order they'll be read in the proposal. That matters for keyboard and screen reader users: tabbing through the form follows the letter from top to bottom.

IDs come from the label and are made unique, because two labels can slug to the same thing ([Name] and [name!]):

export function slug(label) {
  return 'ph-' + label.toLowerCase().normalize('NFKD').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 40);
}

The Unicode property escapes \p{L} and \p{N} keep letters from any script, so an Urdu label like [نام] gets the id ph-نام instead of an empty string. HTML ids can contain those characters, and label for works with them.

Filling, and knowing what's missing

export function fill(text, values) {
  const missing = new Set();
  const out = String(text).replace(RE, (whole, raw) => {
    const v = values[raw.trim()];
    if (typeof v === 'string' && v.trim()) return v.trim();
    missing.add(raw.trim());
    return whole;
  });
  return { text: out, missing: [...missing] };
}

Two details matter here. A field containing only spaces counts as empty, because a space where the client's name should be is worse than a bracket: at least the bracket is visible. And unfilled placeholders stay as brackets in the preview, so you can see exactly where the gaps are.

The copy decision, without the DOM

The copy button has three possible outcomes, and I find it much easier to test them as data than as clicks:

export async function copyFinished(text, values, clip) {
  const { text: out, missing } = fill(text, values);
  if (missing.length) return { status: 'blocked', missing, text: out };
  if (clip && typeof clip.writeText === 'function') {
    try { await clip.writeText(out); return { status: 'copied', missing, text: out }; }
    catch { /* permission denied, insecure context, no focus: fall through */ }
  }
  return { status: 'select', missing, text: out };   // caller selects the output so the user can press Ctrl+C
}
  • blocked: something is still in brackets. Nothing touches the clipboard.
  • copied: navigator.clipboard.writeText resolved.
  • select: the Clipboard API isn't there or refused (it needs a secure context, and browsers can reject it for other reasons), so the UI selects the finished text and tells the person to press Ctrl+C. No document.execCommand('copy'); it's deprecated, and selecting the text is a fallback that works everywhere.

The messages are also plain functions, so they're testable and consistent:

export function statusMessage(result, labelOf = (r) => r) {
  if (result.status === 'copied') return 'Copied. Paste it into the proposal box and read it once before you send.';
  if (result.status === 'select') return 'Copy is blocked in this browser. The text is selected: press Ctrl+C (Cmd+C on a Mac).';
  const n = result.missing.length;
  return `Not copied: ${n} ${n === 1 ? 'field is' : 'fields are'} still empty: ${result.missing.map(labelOf).join(', ')}.`;
}

"Not copied" leads the sentence on purpose. A screen reader user hears the outcome before the detail, and a sighted user who glances at it gets the same.

Building the form

The DOM layer is about 80 lines. The important parts are the accessibility ones.

Every field has a real label. Not a placeholder attribute pretending to be one:

const label = doc.createElement('label');
label.htmlFor = f.id;
label.textContent = f.label;

Choices become a select. [you send the text and photos / I write the text] turns into a dropdown with an empty "Choose one" first option, so a choice is never made silently on the person's behalf:

if (f.options.length) {
  control = doc.createElement('select');
  control.append(new Option('Choose one', ''), ...f.options.map((o) => new Option(o, o)));
}

Hints are attached with aria-describedby. The "e.g." text and "Used 2 times" become a paragraph under the field that's announced with it:

if (f.hint || f.count > 1) {
  const hint = doc.createElement('p');
  hint.className = 'hint';
  hint.id = f.id + '-hint';
  hint.textContent = [f.hint && `For example: ${f.hint}`, f.count > 1 && `Used ${f.count} times.`].filter(Boolean).join(' ');
  control.setAttribute('aria-describedby', hint.id);
  wrap.append(hint);
}

The example goes in a hint, not in the field. If you pre-fill the example question, people send the example question.

The preview is a read-only textarea with its own label, and a line under it shows the word count and how many fields are left. It updates on input and change, so it works for both text fields and selects.

The button is a submit button inside the form. That way Enter in any text field triggers the same check as clicking, which is what keyboard users expect:

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const r = await copyFinished(template, values(), clipboard);
  const labelOf = (raw) => fields.find((f) => f.raw.trim() === raw)?.label ?? raw;
  for (const [k, el] of inputs) el.toggleAttribute('aria-invalid', r.missing.includes(k));
  if (r.status === 'blocked') inputs.get(r.missing[0]).focus();
  if (r.status === 'select') { out.focus(); out.select(); }
  status.textContent = '';                       // clear first so a repeated message is announced again
  setTimeout(() => { status.textContent = statusMessage(r, labelOf); }, 50);
  form.dataset.last = r.status;
});

When the copy is blocked, three things happen together: every empty field gets aria-invalid="true" (styled with a thick border, not only a colour change), focus moves to the first empty field, and the status line names them all. The status element has role="status", which is a polite live region, so the message is read without interrupting whatever the screen reader is saying about the newly focused field.

Clearing the live region and setting it again after a short delay is a small trick worth keeping. If someone presses the button twice and gets the identical message, some screen readers won't announce a change from one string to the same string. Emptying it first makes the second press audible too.

aria-invalid is removed as soon as a field gets a non-empty value, on the next input event. Leaving fields marked invalid after they've been fixed is a common bug, and it makes people distrust the whole form.

What the tests cover

The pure module has seven tests. A few of them:

test('finds unique placeholders in order and ignores Markdown links', () => {
  const ph = findPlaceholders(TPL);
  assert.deepEqual(ph.map((p) => p.label), [
    "client's first name", 'number of pages', 'type of site', 'their business',
    'you send the text and photos / I write the text', 'amount', 'number', 'one link',
    'One question that changes the price']);
  assert.equal(ph[0].count, 2);
  assert.ok(!ph.some((p) => p.label === 'my demo'));
});

test('fill replaces every occurrence and reports what is missing', () => {
  const { text, missing } = fill(TPL, { "client's first name": 'Sara', amount: '450', 'number of pages': '   ' });
  assert.match(text, /^Hi Sara,/);
  assert.match(text, /Thanks, Sara$/);
  assert.match(text, /\$450, fixed/);
  assert.ok(missing.includes('number of pages'));       // whitespace does not count as filled
  assert.match(text, /\[my demo\]\(https:/);             // the link survives
});

test('copy is blocked while anything is empty, and says what', async () => {
  let wrote = null;
  const clip = { writeText: async (t) => { wrote = t; } };
  const r = await copyFinished('Hi [name], $[amount]', { name: 'Ali' }, clip);
  assert.equal(r.status, 'blocked');
  assert.equal(wrote, null);
  assert.equal(statusMessage(r), 'Not copied: 1 field is still empty: amount.');
});

test('copy writes the finished text, or falls back to select when the clipboard refuses', async () => {
  const denied = await copyFinished('Hi [name]', { name: 'Ali' }, { writeText: async () => { throw new DOMException('no', 'NotAllowedError'); } });
  assert.equal(denied.status, 'select');
});

The clipboard is passed in as a parameter, so tests use a stub and never need a browser. node --test runs them.

For the DOM layer I used a small demo page with a ?test mode that fills the form programmatically and checks the results, run in headless Chrome. It checks that there's one label per field, that the slash placeholder became a select, that a premature copy is blocked, moves focus to the first empty field, marks all six empty fields invalid and announces the count, and that a complete form copies text with no brackets left and clears every invalid mark. All nine checks passed. It's still worth a manual pass with a real screen reader; a script can confirm the attributes are there, not how they sound.

Things to decide for your own version

  • Saving values. It's tempting to remember your own name and portfolio link in localStorage. Fine for those. Don't remember the client fields, or tomorrow's proposal starts with yesterday's client.
  • A length nudge. The word count is informational. DD's template suggests keeping the short version for small fixes under 80 words; you could add a gentle note above a threshold you choose, but don't block the copy for it.
  • Lines to delete. The template's own instruction is to delete any line that doesn't fit the job. A form can't make that judgement. The preview is where you do it: copy, paste into the proposal box, and cut.

The form only solves the mechanical half. The words around the brackets still have to be about the client's job, which is what DD's guide on how to write an Upwork proposal that wins jobs is about. But never again sending "Hi [client's first name]" is a decent return on a hundred lines of JavaScript.


I'm Abid Ali, a freelance full-stack developer in Karachi. I write practical guides on freelancing platforms, proposals and fees, and build small free tools, on Digital Dialogue.

About the author

  • I'm Abid Ali, a full stack developer from Karachi with 3+ years of experience in TypeScript, Next.js, Gatsby, React and React Native. I build web apps, mobile apps and interactive UIs with solid API integration. Side projects: OG Scope (ogscope.pk), a free Open Graph checker; Digital Dialogue (digitaldialogue.pk), freelancing guides for Upwork and Fiverr beginners; and Meme Tap (memetap.pk), a free browser meme soundboard.

No comments yet. Be the first to comment.