Skip to content
Skip to main content
Novel Systems home
Decision log
D-100August 7, 2026

The organization logo, and one name for the parent relationship

Affects: scripts/build-logo.mjs, public/logo.png, package.json, lib/engineering-practice.ts, config/site.ts, app/layout.tsx, lib/legal-documents.ts, app/about/page.tsx, docs/company-profiles.md

Decision. The logo node in the site's Organization structured data now points at a real square brand mark instead of the Open Graph card, the mark is generated from the same geometry the site renders and guarded byte-for-byte, and the two surfaces that described the parent relationship in their own words now read it from CONTRACTING_ENTITY like everything else.

Why the OG card was the wrong asset. logo used to resolve to /og/novel-systems-og.png — a 1200×630 landscape banner carrying a headline and a paragraph. It was there because it was the only committed raster in the repository, which is a reason about this codebase and not a reason about the field. Google reads logo as the mark that stands for the company beside a brand search, and a banner in that slot is letterboxed into illegibility or dropped. /logo.png is 512×512 and contains the mark and nothing else.

Why the logo is committed when `app/icon.tsx` is not. The favicon is drawn per request by a route handler, and that is right for a browser tab: it is fetched by a client that will retry. A crawler resolving structured data wants a stable, cacheable, static file at a URL that does not depend on a function cold-starting. So the PNG is a build artefact that lives in the tree.

Why it is generated rather than drawn. A hand-exported PNG is a copy of geometry that has a source of truth elsewhere — NsLogomark in components/brand/ns-brand.tsx — and copies rot silently. build-logo.mjs re-renders the mark through next/og's Satori, which is deterministic, so check:logo can compare the rendered bytes against the file and fail the build when they differ. That is the twenty-third check, and VERIFY_CHECK_COUNT moved 22 → 23 to match. Without it the brand mark could move in the components and leave a stale square in every search result quoting it.

Satori has no document and no stylesheet, so rgb(var(--brand-teal)) resolves to nothing and the four colours are hex literals copied from :root in app/globals.css. The script imports "next/og.js", not "next/og" — node's ESM resolver will not add the extension for a subpath the package does not declare. Both are recorded as comments in the file so neither gets "corrected".

Square, not the wide lockup. The identity brief specified width: 600, height: 60. That is below Google's own 112 px floor on the short edge and would have failed validation, and rendering the wordmark would mean embedding Plus Jakarta Sans — a logo that silently falls back to a different typeface is worse than no logo. 512×512, declared in the graph from SITE_CONFIG.logoSize so the declaration cannot disagree with the file.

The `<img>` is on `/about`, not in the header. The brief asked for the header logo to become an <img>. NsWordmark is deliberately one text node, because splitting it made the visible text "NovelSystems", which does not contain the accessible name "Novel Systems home" — a WCAG 2.5.3 label-in-name failure Lighthouse flagged on every page. Replacing the header lockup would reintroduce it. The one raster <img> sits at the top of the "How to reach us" panel on /about instead, above the entity's name, address and registration, which is the block where the mark actually means something to a crawler.

Two phrasings, one source. The name "Novel Systems" was already consistent. The *relationship* was not: alternateName in the Organization graph read "a division of" and the privacy-policy contact line read the same, while the footer badge, the terms and the MSA all read "an operating division of". Both now read CONTRACTING_ENTITY. alternateName is the string a search engine may print beside the brand, so it was the one place the site could be seen describing itself in words it uses nowhere else.

`sameAs` is deliberately absent. The brief supplied two URLs — linkedin.com/company/novel-systems and crunchbase.com/organization/novel-systems — and neither page exists. sameAs is not a hint; it asserts that the entity described here *is* the entity at that address, a crawler that follows it to a 404 discounts the node that made the claim, and both platforms mint their own vanity slugs at creation time, so the odds those exact paths are the real ones are poor. scripts/check-social-identity.mjs already fails the build on an identity-host link in shipped source with no verified profile behind it.

The commitment, so it is not left vague: the moment those profiles exist, their real URLs go into ORGANIZATION_JSON_LD.sameAs in app/layout.tsx and into the record table at the bottom of docs/company-profiles.md, in one commit. That edit is blocked on nothing but the accounts.

The profile copy is drafted, not submitted. docs/company-profiles.md carries full LinkedIn and Crunchbase field-by-field drafts. No account exists on either platform and none was created. Two things the brief asked for were rewritten rather than dropped, because they contradicted what this site publishes: the Crunchbase description was to cite "real deployments with named operators (as on the homepage)" — the homepage names no operators, and /corrections C-001 is the withdrawal of five case studies that did, so the copy now says that Novel Blinds Inc. runs its own commercial quoting and dispatch on this platform in production, one deployment, named, and the only one. And the LinkedIn size band "2–10 employees" does not exist in LinkedIn's own vocabulary; the lowest band is 1–10.

What would make this wrong. If the brand mark is redesigned, check:logo fails until npm run build:logo is re-run — that is the guard working, not a bug. If Google's minimum dimension changes, SITE_CONFIG.logoSize and LOGO_SIZE in the generator are the two numbers to move together. And if either profile is created without the same commit updating sameAs and the record table, this decision has been half-applied, which is worse than not applied.