Skip to content
Skip to main content
Novel Systems home
Decision log
D-105August 9, 2026

The LinkedIn page carries no location, and its cover is rebuilt from the OG card's own pixels rather than cropped from it

Affects: the LinkedIn company page (id 143066613, linkedin.com/company/novelsystems-ca), public/og/linkedin-cover.png, scripts/build-linkedin-cover.py, docs/linkedin-company-page.md.

The location is empty on purpose

LinkedIn's Locations form marks City as required. It stays required with "This location has no street address" ticked — that checkbox suppresses the street line, not the locality. So the page can carry a city or carry nothing.

lib/constants.ts removed baseCity on 4 August 2026 and says why at length: a locality in schema.org markup asserts a place of business, asserting one we do not staff is what gets a listing suspended, and province and country are the truthful granularity for two entities that are service-area businesses with no published street address. docs/linkedin-company-page.md had already written the stop condition for exactly this case — "if LinkedIn refuses to save without a locality, stop and establish the registered office rather than guessing."

The alternative was to type "Toronto" into a field no visitor reads closely. It was rejected because the syndicated profile is precisely where a wrong city acquires a second life: LinkedIn feeds knowledge panels and aggregators, and a city entered here would come back as a contradiction of the site rather than staying a local error. The standing rule is that when supplied copy conflicts with a published site claim, the copy moves and the site does not; here the copy had nowhere to move to, so it was withheld.

This is wrong the moment a registered office is established. Then the city is a fact, the site can publish it, and this field can be filled from the site rather than instead of it.

The cover is a re-layout, not a crop

LinkedIn renders a page cover at roughly 1128 × 191, about 5.9:1. public/og/novel-systems-og.png is 1200 × 630, about 1.9:1. Cropping one to the other decapitates the wordmark, which the source document rules out in terms: an empty cover is unremarkable, a decapitated logo is the first thing a visitor sees.

Re-setting the type at the new aspect was the obvious alternative and is not available. app/layout.tsx loads Plus Jakarta Sans through next/font/google at runtime; there is no copy of the face in this repository and no network access in the build sandbox. Setting the wordmark in a substitute face would put a second, subtly different lockup into circulation — the failure scripts/build-logo.mjs was written to prevent, and the reason that script draws a square mark instead of the wide lockup.

So the cover reuses the card's *rendered pixels*. Each element is lifted from the card by subtracting the card's own local background under it, and the difference is added onto the new background, which carries the glyph colour and its antialiasing across without punching a rectangle through the destination's gradient. The one element that sits on the bright violet glow — "novelsystems.ca" — is extracted as coverage against its known flat teal instead, because there the background's red and blue exceed the text's and a subtractive extraction clips them to zero.

The generator is not wired into npm run verify. It needs Pillow and numpy, neither of which is a dependency of this project, and it should not make them one for a file that changes when the brand changes and at no other time. The committed PNG is the artifact; the script is the record of how it was derived, and its docstring carries the two failed background estimators so nobody re-derives them.

This is wrong once the cover concept in docs/linkedin-company-page.md — a wide crop of the quote line as the engine renders it — is actually built. This one is the site's own artwork rather than a placeholder, but it is still the OG card rearranged, not a cover designed for the shape.