Skip to content

OG wordmark fix, LinkedIn Page assets, mobile comparison table - #42

Merged
Abuubkar merged 30 commits into
mainfrom
dev
Sep 11, 2026
Merged

Abuubkar merged 30 commits into
mainfrom
dev

Conversation

@Abuubkar

Copy link
Copy Markdown
Owner

Four commits of content since #41. GitHub will list 29 commits and a large
diff on this PR — see the note at the bottom, that is an artefact of #41 being
squash-merged, not new work. The real change against main is ten files:

 .gitignore                             |   4 ++
 public/brand/README.md                 |  16 ++++++
 public/brand/social/linkedin-cover.jpg | Bin 0 -> 61115 bytes
 public/brand/social/linkedin-logo.png  | Bin 0 -> 13509 bytes
 public/logo.png                        | Bin
 public/og.png                          | Bin
 scripts/_brand.py                      |  89 ++++++++++++
 scripts/og-image.py                    |  77 +++-------
 scripts/social-images.py               | 100 ++++++++++++++
 src/base/Table.tsx                     |  34 ++++---

The link preview badge

The badge sat wrong on the "i" in WhatsApp previews: its knockout ring bit a
chunk out of the "l", which the real wordmark's does not.

The cause was that og.png contained a second implementation of the logo,
redrawn in Pillow, which had drifted three times. Pillow has no Raqm here, so
its layout dropped the kerning and the glyph pen positions were listed by hand.
The knockout ring sampled its colour after the letters were drawn and came out
black. Then the ring landed in the wrong place. Each fix addressed a symptom.

og.png and logo.png now rasterise
public/brand/cross-dot/execil-wordmark-notag.svg through headless Chrome and
composite it, so the lockup is the artwork the site renders rather than a copy
kept in step by hand. That deleted the pen table, the badge geometry and the
colour sampling from the script. It costs a headless Chrome dependency in a
run-once generator, which is a real trade: correctness here is only checkable by
eye, and by eye caught it late three times.

LinkedIn Page assets

scripts/social-images.py writes public/brand/social/. Sizes come from
LinkedIn's spec page:
logo 400x400 with a 268x268 floor, cover 1512x256. The 300x300 and 1128x191
figures in most guides are stale.

The logo is the badge rather than the wordmark, because LinkedIn renders it at
roughly 60px in the feed where "Execil" across 400px would be about 50px wide.
Opaque white behind it, since LinkedIn puts the logo on both light and dark
surfaces.

I could not verify whether LinkedIn crops covers horizontally, so the copy is
inset 112px rather than sitting on the padding. If it clips after upload, that
is one constant.

Mobile comparison table

The stacked layout printed both column names above every value, so "Typical
billing vendor" and "Execil" each appeared eight times in eight rows. They now
appear once, on the same two-column grid as the pairs below. What ties a value
to its heading after the heading scrolls away is the teal on the last column,
which the cells already had; the heading takes the same teal.

Verified at 375px: one occurrence of each heading, and no horizontal overflow —
the card spans 24 to 351 in a 375 viewport and the widest cell's scrollWidth
equals its width.

Also

scripts/__pycache__ had been committed alongside _brand.py. Removed, and
__pycache__/ is now ignored.

Note on the commit count

#41 was squash-merged: its merge commit ad98d88 has a single parent, so
none of dev's commits are ancestors of main even though their content is.
The merge base is still 0b1bac1, which is why this PR lists 29 commits.

Merging this one with a merge commit rather than a squash fixes it — dev
becomes an ancestor of main and the next PR will show only its own work.
Squashing again keeps the two histories diverging and every future release PR
will look like it is re-landing everything.

Checked

pnpm check and pnpm build pass.

The header and footer carried a placeholder: a teal rounded square with a
green square inside, next to the name in live text. Both are replaced by
the real wordmark — "Execil" in Satoshi Black with the tittle of the "i"
swapped for a cross badge, drawn from a client reference image.

The letterforms are outlines, not <text>. The badge has to land exactly
where the dot was at every size, which live text cannot promise across
fallback fonts. Wordmark carries the paths inline rather than pointing at
the file in public/brand/, so the letters can take currentColor and the
badge can take theme tokens; an <img> could do neither.

Two token changes fall out of that:

  textOnBrand, because the cross sits on the brand fill and needs a colour
  that survives the Dark theme. Button currently uses surfaceRaised for
  this, which the Dark theme turns translucent — a latent bug, left alone
  here.

  surfaceBrand overridden to teal600 on the inverse surface, because
  teal700 on ink900 leaves the badge reading as an unstyled dot. Nothing
  else on a dark subtree uses surfaceBrand today.

public/brand/ also holds the second direction the client asked to keep.
Nothing references it; see the README there.
The badge was surfaceBrand, teal700. Against the ink50 page that measures
5.97:1, which looks fine in isolation — but the badge does not sit on the
page, it sits against the ink900 letterforms, and there it separates by
only 2.74:1. At header size the mark stopped reading as a badge and became
part of the "i": one dark mass with a speck in it.

teal600 lifts that adjacency to 4.13:1 and still holds the white cross
inside the badge at 4.18:1. Brighter trades one for the other — teal500
would reach 5.72:1 against the letters but drop the cross to 3.02:1, mush
at 16px. teal600 is the balance point, and it is already the accent the
hero uses on this ground.

That makes the badge one colour on both grounds, so surfaceMark carries it
rather than surfaceBrand, and the Dark theme no longer needs the
surfaceBrand override this branch added.

The tagline keeps teal700. It is real text at a small size and needs the
6.31:1.
Colour was the wrong lever. The badge replaces the tittle of the "i", so
it does not sit against the page — it sits against the letterforms, and
teal700 separates from ink900 by only 2.74:1. Reaching for a brighter teal
fixed that number and cost the white cross inside the badge, which is the
trade the previous attempt lost.

Space works where value did not. A ring 20% of the badge radius is masked
out of the letterforms, so the ground shows through around the badge. A
mask rather than a filled circle, because the ring then shows whatever the
mark is placed on — the same drawing works on the light page and on the
inverse footer with no second colour. Narrower than ~15% stops registering
at 28px; wider than ~25% starts eating the stem of the "i".

Every colour stays where it was.

The component needs a unique mask id per instance, since the header and
footer both render one, so this is the first hook in Wordmark. Verified in
the prerendered HTML: two distinct ids, each matching its reference, and
the same values after hydration.
Below the nav breakpoint the square source is cover-cropped into a 4:3
band. Centred, the crop took the top of her head; anchored to the top, it
showed the empty band above her hair instead. "right 18%" sits between:
the head clears with a small margin, and the same anchor still serves
desktop because a box taller than the image only crops sideways.

The band itself is now capped at 480px through layout.heroBandMax. Just
under the breakpoint it reached 700px, which is most of a tablet's first
screen spent on the picture.
The Worker's own workers.dev subdomain serves the whole site and stays
enabled by choice (#34), and the preview aliases from non-production
branch builds live on the same domain. Both are public, and robots.txt
says Allow: /. The prerendered canonical already points at execil.net,
which Google usually honours; X-Robots-Tag is the signal that does not
depend on it being honoured.

_headers matches host-first when the rule is an absolute URL, and `:name`
is a placeholder for one label, so a single rule covers
execil.<subdomain>.workers.dev and every <alias>-execil.<subdomain>
preview. No Worker change and no run_worker_first: these are asset
responses, which is what every prerendered page is.

nosniff and Referrer-Policy come along while the file is open. Not HSTS —
that one binds the apex and every subdomain for as long as browsers
remember it, so it is a decision, not a commit.
logo.png is new. Organization.logo pointed at og.png, which is a
1200x630 social card with a headline across it — Google reads that field
for the knowledge panel and wants the logo itself, on white, 112px
minimum in both axes. The new file is the wordmark on white at 1024x406.

The OG card's lockup was still the placeholder mark, a teal square with a
green square inside it. Both now draw the real wordmark from the same
geometry as public/brand/cross-dot/.

Two things about drawing it in Pillow. Glyph pen positions are listed
rather than laid out, because this Pillow has no Raqm: its own layout
drops the kerning, which would set the word 25 units wide and land the
badge 78 units late. And the knockout ring samples the canvas before a
letter is drawn — after the text, that point is the tittle, and the ring
came out black.
The meta description was 215 characters against Google's ~155, so it was
being cut mid-sentence in results. Now 155.

The 404 inherited the home page's title and its `index, follow`. Its 404
status is what actually keeps it out of the index, but the tab said
"Execil | Medical Billing & RCM" and a crawler reading markup before
status saw an invitation.

It cannot set a route head — nothing matched, so there is no route to
hang one on. React 19 hoists a rendered <title> into <head>, but the root
head's title is emitted first and wins, which left the document with two.
So the title moves to the home route, and the root keeps none. The robots
meta is deleted outright rather than moved: `index, follow` is the
default, so it only existed to collide.

Sitemap gains lastmod, the one field there Google reads.
The picture is the LCP element on narrow screens, where it sits above the
copy. Markup alone is not enough there: the preload scanner finds the
<img> only once the CSS that sizes it has arrived, and fetchpriority
cannot start a request nobody has discovered yet. Verified on the built
site at 375px — one request for the hero, initiator "link", the 900w
candidate, and no "preloaded but not used".

That needs the same src, srcset and sizes in two files, so they move to
hero-image.ts. A mismatch there is not a no-op: the browser resolves a
different candidate and downloads the picture twice.

FAQPage covers the eight questions. Expectations first: since 2023 Google
shows FAQ rich results only for government and health *authority* sites,
and Execil is neither. It is valid, cheap, and read by other consumers of
structured data. It sits on the home route rather than beside
organizationLd in the root, because the questions render on one page.
Two separate things, both visible as "too much space at the sides".

The header put its gutter inside the container rather than outside it,
which is the reverse of Section and SiteFooter. That inset the brand by a
gutter relative to every section below, so the wordmark sat 24px right of
the hero copy it sits directly above — close enough to read as a mistake
rather than a margin.

And containerWide was the canvas's 1160. At 1440 and up that left the
header's three groups closer to each other than to the edges of the
screen: 244px of nothing at each side against 110px between the groups,
which reads as a narrow strip floating in a wide page. 1280 puts the
outer margins and the inner gaps in the same range.

The container is shared, so sections widen too. Prose is unaffected —
it has its own containerText — and the grids simply get their columns
back.
The badge sits on surfaceAccent over the hero's wash, which ends at
surfaceAccentSoft — near enough that the 1px border was doing all the
separating and not much of it. shadow.sm lifts it.

The dot gets a glow: a tight ring plus a soft bloom, alpha doing the
work, because a solid ring at 8px just looks like a bigger dot. It is a
Dot prop rather than a Badge style, so it stays keyed to tone — a brand
dot glows brand, and `subtle` does not glow at all, having nothing to
announce. Badge is the only caller today.

Eyebrow moves from base to md. At 13px, uppercase and letterspaced, it
was reading as a caption under the heading rather than a label over it.
The provider range moves from 1-20 to 35-40 in the three places it is
user-facing — the meta description, the hero lead and the Fit list — plus
the OG card, which is regenerated, and the glossary entry for Practice
that defines the term.

The last trust point was "US-facing team · 24-hour processing": one list
item carrying its own separator, so it rendered a text middle dot where
the other four have a teal Dot. Now two items, six in the row.

"13 wks" was the only abbreviation among four stat cards ("60%", "13.5%",
"25 min"), and the one whose unit a reader has to expand. Now "13 weeks".
Six changes from a review of the first pass.

The desktop header was appearing at 940px, but the nav links, the phone
number and the CTA need 1000px before they all stop wrapping — so between
940 and 1000 the "desktop" header rendered three lines deep. The
breakpoint moves to 1040, a gutter of slack past where it fits. Hero
shares that token for its side-by-side layout, and HERO_SIZES repeats the
number because a `sizes` attribute cannot read a token; both move
together, and the token comment now says so.

The comparison table used auto layout, so each column sized to its
longest cell and the surplus went to whichever asked for most. That left
the Execil column half again as wide as the vendor column it is compared
against, with its text stopping well short of filling it. Fixed layout,
22% for the labels and the value columns even, so the comparison does not
look weighted before it is read.

The hero's top padding drops to the bottom's value. The canvas drew it
taller, which made sense when the hero was centred text; the picture now
supplies that height on the right.

The badge dot only had a static bloom, which reads as a slightly fuzzy
dot rather than a live one. It gains a travelling ring on ::after,
animating opacity and transform only — no colour named, composited not
repainted, and the global prefers-reduced-motion rule in app.css already
covers ::after.

Stat sources go from xs to base. At 11px they were the smallest text on
the page by two steps; one step would not have been visible.

Provider bands gain 21-34, 35-40 and 40+ in place of 20+, so the range the
copy now names is selectable rather than folded into a catch-all.
The observer only ever called setCurrent when it found a section in the
band, so with nothing there the last match stayed highlighted. Scrolling
back over the hero left Services lit.
Sources were xs in Results and base in Problem; both are sm now. The
assessment findings line was sm, which is caption size for a sentence
that carries the offer — md. Pulse 2400ms to 1300ms.
Long block comments explaining what was tried and why, across the files
this session touched. Replaced with one line where a constraint is worth
recording and nothing where the code already says it. The Wordmark
emitter in the scratchpad produces the shorter docstring too, so a
regenerate does not undo this.
A link is current from its section's top to the next linked section's
top, so Systems reads as Specialties and Fit and Results as Why Us. The
last chapter ends at its own bottom, leaving the form unlit. Replaces the
10% intersection band, which went blank over every unlinked section.
Two crisp 2px rings on ::before and ::after, half a period apart, easing
out to three times the dot's size, and the dot breathes to 1.18x on the
same beat. Replaces the single blurred ring. New motion.easeOut token.
og.png and logo.png are drawn at 3x and downsampled: PIL draws shapes
without antialiasing, so the badge circle was a staircase and the cross a
blob. The favicon cross is heavier so it holds at 16px. The header
wordmark goes from 28px to 32px, which takes the badge from 9px to 10px
and the cross past a pixel on 1x screens.
Chrome rasterises masked SVG content through an intermediate surface, so
the header wordmark rendered soft while the nav text beside it was crisp.
The ring is now subtracted from the glyph outlines with skia-pathops in
the generator, leaving plain filled paths: no <mask>, no useId, and the
same drawing in public/brand/cross-dot/.
Six of nine suggested changes. The breach clause deferred entirely to the
business associate agreement; 45 CFR 164.410(b) sets a ceiling the
agreement cannot exceed, so the statutory 60 calendar days is now stated
alongside it.

The rest are additions to existing wording, each checked against what the
site does: transport is TLS 1.2 or higher and older versions are refused
(measured against execil.net); no advertising and no profiling by health
condition; email is named as an insecure channel for patient data; the
under-18 clause now says what happens if we find such data.

Turnstile keeps the existing hedge about browser storage rather than
taking the suggested "no persistent cookies" — Cloudflare's own docs say
Turnstile can issue a cf_clearance cookie, so that claim would be false.
What Cloudflare does state, and what is now quoted, is that the signals
are not used to identify, profile or target, and that the cookie is
partitioned to one site.

Both changed pages get today's date, as the Changes section promises.
Retention periods, at-rest encryption and a 30-day response deadline are
left out: each asserts an operational fact no one has confirmed.
Squashed from seven commits on feat/header-utility-bar. Prototype G,
chosen from eight header variants explored on prototype/header-layouts.

Header. The old row carried three groups, and space-between could never
centre the nav between a 104px wordmark and a 380px cluster of phone plus
CTA. The phone moves to a contact strip, 32px on the Inverse surface,
right-justified in line with the CTA below it; below the nav breakpoint
the email address drops and the phone stays. The main row is full-bleed,
so the wordmark and the CTA each sit a gutter from the viewport edge
rather than 160px in at 1600. Nav links are 32 apart, 40 above
screen.navWide, and the CTA is held 32 off the last link, 64 above it, so
the button reads as an action and not a sixth nav item.

Also fixed on the way: the header applied its gutter inside the
container where Section and SiteFooter apply theirs outside, which inset
the wordmark by a gutter against every section below it.

Behaviour is unchanged. The scroll-spy highlight, the pin through smooth
scroll, and nav_clicked / phone_clicked / cta_clicked all carry over; the
prototype had none of them. The email link is untracked, since the typed
event union has no event for it.

Hero. The strip made the header 101px while the hero kept its 80px top
padding, so its first line fell from 149px down the page to 181px.
heroTop is now 32px lighter than sectionMd, exactly the strip, and the
badge sits back at 149. The mobile bleed derives from the same token, so
the picture still meets the header with no seam.

The trust row is a grid rather than a wrapped flex row, which broke
wherever content ran out and left the last item unaligned. Three equal
columns on desktop, two on mobile.

Copy. The provider range is 20+ in all four user-facing places, the
glossary entry defining Practice, and the OG card, which is regenerated.
Form bands lose 21-34 and 35-40. "Specialty-assigned" is rephrased
rather than shortened so the claim survives: coders assigned by
specialty is the thing being said, and "specialty billing" would read as
billing for a specialty clinic. The hero lead is rewritten to drop a
subjectless opening fragment and three parallel promise-clauses.

New tokens: space.s64, screen.navWide. Link gains a `utility` variant
for the strip, so its small size lives in the variant rather than being
pushed through `style`.
The badge sat wrong on the "i" in link previews: its knockout ring bit a
chunk out of the "l", which the real wordmark's does not.

Reimplementing the wordmark in Pillow had already drifted twice before
this. Pillow has no Raqm here, so its layout dropped the kerning and the
glyph pen positions had to be listed by hand. The ring sampled its colour
after the letters were drawn, so it came out black. Now the ring lands in
the wrong place. Each fix was local to a symptom.

Both files now rasterise public/brand/cross-dot/execil-wordmark-notag.svg
through headless Chrome and composite it, so the lockup is the same
artwork the site renders rather than a second drawing that has to be kept
in step. That drops the pen-position table, the badge and cross geometry,
and the ground sampling.

It does add headless Chrome as a dependency of the script, which is a
real cost for a run-once generator. Worth it: correctness here is only
verifiable by eye, and this is the third time by eye caught it late.
scripts/social-images.py writes public/brand/social/. Sizes are from
LinkedIn's own spec page: logo 400x400 with a 268x268 floor, cover
1512x256. The 300x300 and 1128x191 numbers in most guides are stale.

The logo is the badge rather than the wordmark, because LinkedIn renders
it at roughly 60px in the feed, where "Execil" across 400px would be
about 50px wide. Opaque white behind it, since LinkedIn puts the logo on
both light and dark surfaces.

The cover is a strip: wordmark and two lines of copy on the left, the
hero photograph fading into the wash on the right. The copy is inset
112px rather than hugging the padding, so a modest horizontal crop does
not eat the wordmark.

The SVG rasteriser moves to scripts/_brand.py, shared with og-image.py
instead of copied. Verified by hash that og.png and logo.png are
unchanged by that refactor.
The stacked layout repeated both column names above every value, so
"Typical billing vendor" and "Execil" each appeared eight times in a
table of eight rows.

The names now sit once at the top, on the same two-column grid as the
pairs below so they line up, with a rule under them. What ties a value
back to its heading after the heading has scrolled away is the teal on
the last column, which the cells already had; the heading takes the same
teal so the pairing is stated rather than inferred.
scripts/__pycache__ was committed alongside _brand.py.
@Abuubkar
Abuubkar merged commit d506fb0 into main Sep 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant