<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tim Keen</title>
    <description>The latest articles on DEV Community by Tim Keen (@timkeen).</description>
    <link>https://dev.to/timkeen</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4219775%2Ffea1a131-9fc4-4804-a570-592ce346e79f.png</url>
      <title>DEV Community: Tim Keen</title>
      <link>https://dev.to/timkeen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/timkeen"/>
    <language>en</language>
    <item>
      <title>Character limits have two numbers: building an ad preview around warnings and blocks</title>
      <dc:creator>Tim Keen</dc:creator>
      <pubDate>Sat, 31 Oct 2026 23:49:48 +0000</pubDate>
      <link>https://dev.to/timkeen/character-limits-have-two-numbers-building-an-ad-preview-around-warnings-and-blocks-153d</link>
      <guid>https://dev.to/timkeen/character-limits-have-two-numbers-building-an-ad-preview-around-warnings-and-blocks-153d</guid>
      <description>&lt;p&gt;Disclosure up front: I run Maestro, a B2B paid media company, and this post is about a free tool we built, the &lt;a href="https://maestrogtm.com/linkedin-ad-mockup/" rel="noopener noreferrer"&gt;Maestro LinkedIn ad mockup&lt;/a&gt;. Nothing here needs you to use it. The problems are the same for anyone building a form, an editor or a preview against a platform's published limits.&lt;/p&gt;

&lt;p&gt;The tool does one narrow job. You enter a LinkedIn single-image ad (company name, intro text, image, headline, optional description, CTA and destination URL), compare desktop and mobile views, and export a mockup plus a production brief for whoever approves the ad. It runs in the browser, needs no sign-in, and doesn't upload your creative.&lt;/p&gt;

&lt;p&gt;Most of the interesting engineering wasn't in drawing the card. It was in deciding what the tool is allowed to say. This post walks through those decisions: how to model a limit, how to count characters when the platform doesn't tell you how it counts, how to show truncation you can't reproduce exactly, and how to test all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A field has two limits, not one
&lt;/h2&gt;

&lt;p&gt;LinkedIn's &lt;a href="https://www.linkedin.com/help/lms/answer/a426534/" rel="noopener noreferrer"&gt;single image ad specifications&lt;/a&gt; publish two numbers for most text fields: a shorter length to avoid truncation and a hard maximum.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Avoid truncation&lt;/th&gt;
&lt;th&gt;Hard maximum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Introductory text&lt;/td&gt;
&lt;td&gt;150 characters&lt;/td&gt;
&lt;td&gt;3,000 characters (up to 10 emojis)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headline&lt;/td&gt;
&lt;td&gt;70 characters&lt;/td&gt;
&lt;td&gt;200 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description&lt;/td&gt;
&lt;td&gt;100 characters&lt;/td&gt;
&lt;td&gt;300 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Destination URL&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;must start with &lt;code&gt;http://&lt;/code&gt; or &lt;code&gt;https://&lt;/code&gt;; up to 2,000 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ad name (optional)&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;255 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A single &lt;code&gt;maxLength&lt;/code&gt; can't represent that. Set it to 3,000 and people ship intro text that gets cut off after the first line. Set it to 150 and you block copy LinkedIn would accept, which is worse, because the writer now has a tool telling them something false.&lt;/p&gt;

&lt;p&gt;So the tool treats the two numbers as different kinds of rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Past the recommendation:&lt;/strong&gt; show a warning. The copy may be fine. A longer intro can be a deliberate choice, and the writer decides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Past the published maximum:&lt;/strong&gt; block the review export until the field is fixed. This copy can't work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That split, "you should look at this" versus "this can't ship", is the single most useful idea in the tool. It applies to any field with a soft and a hard limit: meta descriptions, SMS segments, push notification titles, app store subtitles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the limit as data, with its source
&lt;/h2&gt;

&lt;p&gt;Constants scattered through components break the first time the platform changes a number. Limits are facts with a source and a date, so they belong in one table that the UI, the validator and the export all read. Here's a simplified sketch of that shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Severity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warning&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;introText&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;headline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;destinationUrl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;adName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recommendation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;maximum&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;characters&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;emoji&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// URL of the published spec&lt;/span&gt;
  &lt;span class="nl"&gt;checkedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ISO date the spec was last read&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Finding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;field&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nl"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Severity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CountMethod&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// see the Unicode section&lt;/span&gt;
  &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;severityFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rule&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Severity&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;maximum&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warning&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than they look.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;kind&lt;/code&gt; drives severity, not the number. Nobody has to remember that 150 is a warning and 3,000 is an error. A recommendation can never block, by construction.&lt;/p&gt;

&lt;p&gt;Second, every rule carries &lt;code&gt;checkedAt&lt;/code&gt;. Our page states that the limits were checked against LinkedIn's specs on September 27, 2026, and the exported brief carries the same date. If a refresh of the source fails, the honest state is "Unknown currency", not a silent pass on old numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  One green check is a lie: keep the states separate
&lt;/h2&gt;

&lt;p&gt;The most common failure in review tools is a single status that quietly means several things. "Ready" might mean the preview renders, or the copy is within limits, or someone approved it, or the platform accepted it. Those are four different questions with four different owners.&lt;/p&gt;

&lt;p&gt;The tool keeps them apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Preview:&lt;/strong&gt; can the draft be pictured, with any missing fields labeled?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Technical check:&lt;/strong&gt; do the supplied copy, URL and image pass the supported rules?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production handoff:&lt;/strong&gt; is the record complete enough for a named reviewer to check?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; has the intended Campaign Manager account supplied dated approval and native-rendering evidence?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sketched as types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PreviewState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Preview available&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Preview incomplete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TechnicalState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pass&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Needs changes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unsupported&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;HandoffState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Ready for review&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hold&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PlatformState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not checked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Externally verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ReviewStates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PreviewState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;technical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TechnicalState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;handoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HandoffState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PlatformState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing inside the tool can set &lt;code&gt;platform&lt;/code&gt; to &lt;code&gt;"Externally verified"&lt;/code&gt;. That value only arrives with dated, account-scoped evidence from someone who looked in Campaign Manager. A mockup can be ready for production review while LinkedIn is still Not checked, and the UI says exactly that instead of collapsing it into a checkmark.&lt;/p&gt;

&lt;p&gt;If you build anything that previews a third-party platform, I'd start here. Write down which states your tool can know and which it can only record from someone else, and make the second group impossible to set from local code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts as a character?
&lt;/h2&gt;

&lt;p&gt;This is where most counters quietly go wrong. In JavaScript, &lt;code&gt;"ðŸ‘ðŸ½".length&lt;/code&gt; is 4. A user sees one thing.&lt;/p&gt;

&lt;p&gt;I ran a handful of strings through Node 20 to show how far the common methods drift apart:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;.length&lt;/code&gt; (UTF-16 units)&lt;/th&gt;
&lt;th&gt;Code points&lt;/th&gt;
&lt;th&gt;Grapheme clusters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Hello&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ðŸ‘&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ðŸ‘ðŸ½&lt;/code&gt; (thumbs up + skin tone)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ðŸ‘©â€ðŸ’»&lt;/code&gt; (ZWJ sequence)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ðŸ‡ºðŸ‡¸&lt;/code&gt; (regional indicator pair)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Ã©&lt;/code&gt; (precomposed)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Ã©&lt;/code&gt; (e + combining accent)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;1ï¸âƒ£&lt;/code&gt; (keycap)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two &lt;code&gt;Ã©&lt;/code&gt; rows look identical on screen and count differently under two of the three methods. Normalizing to NFC first collapses the second one to length 1, which is worth doing before you count anything.&lt;/p&gt;

&lt;p&gt;Grapheme clusters are the closest match to what a person thinks of as a character. Browsers expose them through &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter" rel="noopener noreferrer"&gt;&lt;code&gt;Intl.Segmenter&lt;/code&gt;&lt;/a&gt;, which MDN lists as available across browsers since April 2024:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segmenter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Segmenter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grapheme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;countCharacters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NFC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;segmenter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;segmenter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grapheme (Intl.Segmenter)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;code point fallback&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the return shape. The count travels with the method that produced it. If an older runtime falls back to code points, the export says so.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part you can't know
&lt;/h3&gt;

&lt;p&gt;Here's the honest problem: LinkedIn doesn't publish every detail of its Unicode counting logic. It says 150 characters "including spaces, emojis, and punctuation" and a limit of 10 emojis, but not whether a skin-toned thumbs up is one character or two.&lt;/p&gt;

&lt;p&gt;So our count is a local review count, and the tool labels it that way. Copy that lands near a boundary gets a note to test it in Campaign Manager before launch. I'd rather show an honest "approximately 150, check natively" than a confident number that turns out to be wrong at exactly the point where it matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Counting emoji is its own trap
&lt;/h3&gt;

&lt;p&gt;The 10-emoji limit needs an emoji detector, and the obvious regex doesn't agree with itself. Testing the same strings against two Unicode properties in Node 20:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/\p{Extended_Pictographic}/u&lt;/code&gt; matched &lt;code&gt;ðŸ‘&lt;/code&gt; and &lt;code&gt;ðŸ‘©â€ðŸ’»&lt;/code&gt;, missed &lt;code&gt;ðŸ‡ºðŸ‡¸&lt;/code&gt; and &lt;code&gt;1ï¸âƒ£&lt;/code&gt;, and matched a plain &lt;code&gt;Â©&lt;/code&gt; with no emoji presentation selector.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/^\p{RGI_Emoji}$/v&lt;/code&gt; (the newer &lt;code&gt;v&lt;/code&gt; flag) matched &lt;code&gt;ðŸ‘&lt;/code&gt;, &lt;code&gt;ðŸ‘ðŸ½&lt;/code&gt;, &lt;code&gt;ðŸ‘©â€ðŸ’»&lt;/code&gt;, &lt;code&gt;ðŸ‡ºðŸ‡¸&lt;/code&gt; and &lt;code&gt;1ï¸âƒ£&lt;/code&gt;, did not match a bare &lt;code&gt;Â©&lt;/code&gt;, and did match &lt;code&gt;Â©ï¸&lt;/code&gt; with the variation selector.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;v&lt;/code&gt;-flag version is closer to what a person would call an emoji, but neither is guaranteed to match LinkedIn's rule. The approach I'd use: segment into graphemes first, test each grapheme, record the counting and emoji-sequence method in the export, and treat an emoji count near 10 as a boundary to check natively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Truncation you can't reproduce exactly
&lt;/h2&gt;

&lt;p&gt;The point of the 150 and 70 recommendations is truncation, so people want the preview to show where the "â€¦see more" lands. You can't get that exactly right from outside the platform. Truncation depends on fonts, viewport width, line height and rules the platform doesn't publish.&lt;/p&gt;

&lt;p&gt;What we did instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick fixed, documented card widths.&lt;/strong&gt; The preview uses a 552 CSS-pixel desktop card and a 358 CSS-pixel phone card inside a 390px model, and stays responsive down to 320px. Those are product decisions, written down, not claims about LinkedIn's layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clamp by lines in CSS, not by slicing characters.&lt;/strong&gt; Cutting the string at character 150 is wrong for two reasons: you might split a grapheme cluster, and the platform truncates visually, by lines. A &lt;code&gt;line-clamp&lt;/code&gt; with a More/Less toggle is closer to the real behaviour and never corrupts text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the text selectable and complete.&lt;/strong&gt; The collapsed state is presentation. The exported brief always contains the full copy, even when the preview is collapsed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label every export.&lt;/strong&gt; Mockup images carry the caption "Approximate feed preview â€” check the final ad in Campaign Manager", plus the mode and variant. Production artwork is exported separately, with no card chrome or crop guides on it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a stakeholder screenshots the mockup and forwards it, the caption goes with it. That's cheaper and more honest than chasing pixel-perfect truncation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't fake unsupported states
&lt;/h2&gt;

&lt;p&gt;LinkedIn's spec says paid vertical (4:5) images serve on mobile devices only. A desktop/mobile switch could squeeze a portrait image into the desktop frame anyway, and it would look plausible. It would also show a placement that doesn't exist.&lt;/p&gt;

&lt;p&gt;So the desktop view renders an explicit unsupported state for portrait creative. The same thinking applies to the description field. LinkedIn says the description shows on LinkedIn mobile and desktop when the image is less than 200 pixels wide, or when the ad runs beyond the feed through the LinkedIn Audience Network. For a normal large feed image, the tool doesn't promise the description will be visible.&lt;/p&gt;

&lt;p&gt;A rule of thumb I took from this: if the honest answer is "this doesn't render here", render that answer. An empty state that explains itself is better than a convincing picture of something that won't happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate images against published ranges, and stop there
&lt;/h2&gt;

&lt;p&gt;The image rules come from the same LinkedIn page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;th&gt;Recommended&lt;/th&gt;
&lt;th&gt;Minimum&lt;/th&gt;
&lt;th&gt;Maximum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Landscape 1.91:1&lt;/td&gt;
&lt;td&gt;1200 Ã— 628&lt;/td&gt;
&lt;td&gt;640 Ã— 360&lt;/td&gt;
&lt;td&gt;7680 Ã— 4320&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Square 1:1&lt;/td&gt;
&lt;td&gt;1200 Ã— 1200&lt;/td&gt;
&lt;td&gt;360 Ã— 360&lt;/td&gt;
&lt;td&gt;4320 Ã— 4320&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vertical 4:5&lt;/td&gt;
&lt;td&gt;720 Ã— 900&lt;/td&gt;
&lt;td&gt;360 Ã— 640&lt;/td&gt;
&lt;td&gt;2430 Ã— 4320&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;LinkedIn accepts JPG, PNG or GIF up to 5 MB. Our first version takes JPG and PNG only. Animated GIFs are rejected with a clear message rather than silently frozen on their first frame, because a frozen frame would misrepresent the ad.&lt;/p&gt;

&lt;p&gt;Two things we deliberately did not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infer a ratio tolerance.&lt;/strong&gt; LinkedIn publishes rows, not a tolerance. Is 1200 Ã— 630 "close enough" to 1.91:1? The spec doesn't say, so the tool doesn't guess. It offers the recommended presets and Fit/Crop controls, and records the crop rectangle and focal point in the export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pretend our caps are LinkedIn's.&lt;/strong&gt; The tool has its own limits, 5,000,000 bytes in and out and 24,000,000 decoded pixels, to keep the browser responsive. The UI calls them tool limits, not platform rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  URLs: validate syntax, keep everything
&lt;/h2&gt;

&lt;p&gt;Destination checks are simple on paper: an &lt;code&gt;http://&lt;/code&gt; or &lt;code&gt;https://&lt;/code&gt; prefix and up to 2,000 characters. LinkedIn also warns that some special characters in the query string, such as &lt;code&gt;&amp;lt; &amp;gt; # % { } [ ]&lt;/code&gt;, can cause errors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkDestination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not a valid URL.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Must start with http:// or https://.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Over 2,000 characters (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;).`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&amp;gt;{}&lt;/span&gt;&lt;span class="se"&gt;\[\]]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warning&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Query string contains characters LinkedIn says can cause errors.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the query check reads the raw string. &lt;code&gt;new URL("https://x.com/?a=&amp;lt;b&amp;gt;").search&lt;/code&gt; comes back as &lt;code&gt;?a=%3Cb%3E&lt;/code&gt; in Node 20, so testing &lt;code&gt;url.search&lt;/code&gt; would miss the characters the user actually typed.&lt;/p&gt;

&lt;p&gt;Two cautions. Store the raw string exactly as entered, including every tracking parameter. Normalizing it through &lt;code&gt;URL&lt;/code&gt; and writing back &lt;code&gt;url.href&lt;/code&gt; can re-encode characters and change what gets launched. And syntax validity proves nothing about the page. The brief records the destination headline and next action separately, so a reviewer can judge whether the ad's promise matches the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edits invalidate checks, presentation doesn't
&lt;/h2&gt;

&lt;p&gt;Once there are reviews in the record, every edit raises a question: which checks does this edit invalidate?&lt;/p&gt;

&lt;p&gt;We split inputs into two groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content:&lt;/strong&gt; image, crop, copy, CTA, destination and evidence. Changing any of these invalidates the checks and exports that depend on it, while unrelated evidence survives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presentation:&lt;/strong&gt; desktop/mobile mode, collapsed/expanded copy, theme, crop overlay. Changing these changes what you see and nothing else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One simple way to implement it: store, with each review, a hash of the content fields it covered. On edit, recompute the hashes and drop any review whose hash no longer matches. That's easier to reason about than event-driven invalidation, and it makes "why did my approval disappear?" answerable: the field it covered changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Import is all or nothing
&lt;/h2&gt;

&lt;p&gt;Users can export a structured JSON record and import it later. The record embeds the original, delivered and logo image bytes with hashes, so a file that only contains a path isn't treated as lossless.&lt;/p&gt;

&lt;p&gt;Import validates the whole record first: schema, embedded assets, hashes and size caps. Only then does it replace the current work, in one step. If anything fails, the current work stays as it was. A half-imported record is worse than a failed import, because it looks fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to test it
&lt;/h2&gt;

&lt;p&gt;These are the tests I'd write for any tool like this. Each one maps to a behaviour our tool's page commits to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Boundary triplets for every rule.&lt;/strong&gt; 149, 150 and 151 characters of intro text should give no finding, no finding and a warning. 2,999, 3,000 and 3,001 should give a warning, a warning and an error. Same pattern for 70/200 and 100/300. A 151-character intro must never produce an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Unicode fixture file.&lt;/strong&gt; The strings in the table above, plus the two forms of &lt;code&gt;Ã©&lt;/code&gt;, run through &lt;code&gt;countCharacters&lt;/code&gt;, with expected counts per method. When a runtime changes its segmentation data, this file is where you'll find out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State-separation tests.&lt;/strong&gt; Load the worked example, and no real approvals come with it. The sample ships with clearly labeled simulated review records, and loading it into the editor clears them, so it opens on Production handoff: Hold. Assert that no code path sets the platform state to verified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalidation tests.&lt;/strong&gt; Approve, change the headline, assert the copy review is gone and the asset review survives. Toggle desktop/mobile, assert nothing is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import round-trips.&lt;/strong&gt; Export, import, and compare byte-for-byte. Then corrupt one embedded image hash and assert the import fails with the current work intact.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building one of these
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put limits in data, with the source and the date they were checked.&lt;/li&gt;
&lt;li&gt;Give recommendations and maximums different kinds, so severity follows from type.&lt;/li&gt;
&lt;li&gt;Count graphemes, say which method you used, and label the number as local.&lt;/li&gt;
&lt;li&gt;Clamp visually, never slice strings, and caption every exported image as approximate.&lt;/li&gt;
&lt;li&gt;Render unsupported states instead of plausible fakes.&lt;/li&gt;
&lt;li&gt;Keep "we checked it" and "the platform accepted it" in separate fields that local code can't merge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool isn't an official LinkedIn preview and can't predict approval, delivery or performance. It's a review step before Campaign Manager, and the final check still happens there. If you want to poke at the decisions above, the &lt;a href="https://maestrogtm.com/linkedin-ad-mockup/" rel="noopener noreferrer"&gt;Maestro LinkedIn ad mockup&lt;/a&gt; is free and runs entirely in your browser.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I'm Tim Keen and I run Maestro, the B2B paid media company that makes the tool. LinkedIn limits in this post were taken from LinkedIn's single image ad specifications page; the tool's page records them as checked on September 27, 2026. Unicode results come from Node 20.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ux</category>
      <category>javascript</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
