DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Six places a copy rule has to reach, and not one of them is a page

Notifio has a house rule about punctuation: no em dashes in anything a user reads. It is a narrow rule, and the kind that is easy to believe you have finished enforcing, because the obvious place to enforce it is the page.

So the first pass was the pages. Eighteen files: headings, body copy, the FAQ data, the privacy and terms pages, the three demo components on the landing page, and the content catalogues that every generated page is built from. You can read the result on any of them, for example the Kamernet alerts page, the Visualping comparison or the help page. That felt like the end of it.

Then I grepped the rest of the repository, expecting a few code comments. What came back was ten more files, in six groups, and not one of them is page content. Every one of them is read by a user.

1. A Stripe line item

// lib/pricing/constants.ts
export const LICENSE_PRODUCT_NAME = "Notifio — Lifetime License";
Enter fullscreen mode Exit fullscreen mode

That constant is passed straight into price_data.product_data.name on the Checkout Session. It is rendered on Stripe's hosted checkout page, on the emailed receipt, and in the Stripe dashboard afterwards. It is the one string a customer reads at the exact moment they decide whether to pay, and it lives in a module whose job is arithmetic, so it had never been near a copy review.

It is now "Notifio Lifetime License". The pricing page and that line item are the same product name in two places, and only one of them was ever going to be proofread.

2. An email subject line

subject: `Notifio: ${totalCount} new ${totalCount === 1 ? "listing" : "listings"} on ${siteNames}`,
Enter fullscreen mode Exit fullscreen mode

It used to say ... listings — ${siteNames}. A subject line is read in a list of forty other subject lines, in whatever font the mail client picked, usually truncated. Replacing the dash with "on" made it shorter as well as plainer, which is worth something in a truncated inbox preview.

3. The plain-text part of an email

The contact endpoint builds an HTML body and a text body. The HTML part is what nearly everybody sees, so it got reviewed. The text part is the one that renders in a plain-text client, and it is also what some clients mine for the preview snippet, so it had a dash in a heading nobody had looked at:

- const text = `New contact message — Notifio\n\nFrom: ...`;
+ const text = `New Notifio contact message\n\nFrom: ...`;
Enter fullscreen mode Exit fullscreen mode

If you send html and text, you have written two pieces of copy, not one with a fallback.

4. An OS window title

The desktop app opens a real browser window when you need to sign in to a listing site, because the session has to be yours:

loginWindow = new BrowserWindow({
  title: `Log in to ${hostname}`,   // was: `Log in — ${hostname}`
  ...
});
Enter fullscreen mode Exit fullscreen mode

An Electron window title is not in the page. It is in the title bar, the window switcher and the taskbar, rendered by the operating system. It is also the shortest string in the product, which is exactly where a stray character is most visible.

5. Error messages, because a catch block puts them on screen

The auto-reply recorder throws when a recorded flow filled nothing in:

throw new Error(
  'Nothing was filled in, so there is no reply to copy. If the contact form sits ' +
    'in an embedded panel, Notifio cannot see inside it, so reply to this site by hand.'
);
Enter fullscreen mode Exit fullscreen mode

That message is caught upstream and rendered in the app. An Error message is copy whenever a catch displays it, and in a desktop app most of them do. The rewrite was not a character swap either: the original used the dash as a sentence break, so it became a second clause with "so" in front of it.

6. The log lines, because the log is a pane in the window

This is the one that changed how I think about the rule.

The monitor and the reply engine do not call console.log. They take an injected logFn, and that function ends at:

// app/src/server.ts
broadcast({ type: 'log', line });
Enter fullscreen mode Exit fullscreen mode

which is an SSE message, consumed by a useSSE hook, rendered by a component called ActivityLog that the user is looking at the whole time the app is running. So these are not developer logs:

log(`[reply] "${label}" accepts ${limit} characters, so the message was shortened to fit`);
log(`[notify] Skipping reply email, app not activated`);
Enter fullscreen mode Exit fullscreen mode

The distinction I had been carrying in my head was "page copy versus logs". The real distinction is "strings the user reads versus strings they do not", and a log line rendered in a visible pane is on the first side of it.

Why this is not a lint rule

The tempting fix is a rule that bans the character in string literals. It does not work, because the boundary is not syntactic. Two lines in the same file:

console.error('[ledger] Failed to compact ...');  // devtools, nobody opens it
log('[reply] ... message was shortened to fit');  // a pane in the app
Enter fullscreen mode Exit fullscreen mode

Both are a template literal passed to a function. No regex over the source separates them. The same file also keeps its dashes in the JSDoc above those lines, on purpose, because a comment is not something a user reads.

What can be automated is the typed content. Every /alerts and /compare page is an object of a declared type, so a test can walk every prose field on every entry and assert on it. That is the same shape as the test that reads the <title> of every generated page, which I wrote about in Our house style is a 63-line unit test. Arbitrary strings scattered through an Electron main process are not that, and pretending otherwise would have produced a lint rule with a dozen inline disables in it.

The list is the deliverable

The rule was never the hard part. The hard part is that "user-visible copy" in a product with a desktop app, a payment provider, two email bodies and a live log pane means six surfaces that have nothing in common with a page template:

  1. a line item on somebody else's checkout page
  2. a subject line in an inbox
  3. the plain-text alternative of a multipart email
  4. an operating system title bar
  5. the message on an Error that something renders
  6. a log stream that is also a UI

If you have a house style, that is the list worth writing down, not the rule. The rule fits in a sentence. The list is where the work is.

The app those last four surfaces live in is on the download page, and what it actually does is on the help page.

Top comments (0)