The chart's bars did not match its numbers. The three rows were drawn as left-aligned fills on separate scales: +48% took 88.6% of its track and +23% took 56.2%, so a figure twice as large rendered only 1.6 times as long. The -51% row encoded nothing at all — a full-width hatch, whose length said 100%.
All three rows now share one axis with zero in the middle. The side a bar falls on carries the sign, its length carries the size, and the three are finally comparable: +48% renders 2.09x the +23% bar, matching the figures. It also puts the section's argument in the shape of the chart — one bar leaving, two arriving.
The axis runs to 60% each way rather than to the largest value, so the -51% bar stops short of the edge instead of filling it; a bar that runs out of track reads as clipped rather than as its own value. The bound lifts automatically if the data ever outgrows it.
Texts, legends, arrows and the palette are unchanged, except that the grey rail is dimmed: at full strength the half sitting on the empty side of zero read as a bar running the other way, which is the one thing a diverging chart must not do. The zero line carries the weight instead.
Each row is now a single signed number — `change: -51` — with the printed value, the arrow and the bar length all derived from it, so the number and the bar cannot drift apart. A test pins bar widths to the figures they carry.
- The first chart row reads "Search for the site that complied". The parenthetical it drops — "(mostly by blocking the state)" — also contradicted the copy above the chart, which now says that site complied with the age verification.
- Scopes the IEEE study to how age verification is deployed "across the web in US states".
- The first CTA card reads "Explore the Tech" instead of "Build the Parallel".
- The market leader "complied with the age verification" rather than blocking or pulling out.
- The measurement is described as comparing web search activity, dropping the counterfactual framing.
- The compliant site lost half its *search* traffic — the study only ever measured searches.
- Cuts "The proposal fails to help and crowds out what works", turns the second "Meanwhile" into "At the same time", and softens "penalty" to "sanction".
"who invented the public-key cryptography that secures the modern internet" becomes "pioneers of" — the same claim without overstating either authorship.
Renames the route from /catch-no-one to /chatcontrol, the slug the page will ship on.
The rename runs through everything that referenced it: the route constant becomes `chatControl`, the directory and its contract test are renamed, the page component follows, and so do the typewriter's arming class and the global its inline script sets — leaving those on the old name would have been a stale reference to a page that no longer exists.
Verified against the static export: canonical, og:url, and the Article JSON-LD `url` and `@id` all resolve to logos.co/chatcontrol, the sitemap carries the new path and not the old one, and the arming class still matches the Tailwind variants that key off it.
Nine edits from the review doc, mostly tightening claims to what the papers actually support:
- The scanner paragraph names the defeat mechanism — poisoning and false-positive attacks — instead of the pixel-altering example.
- The behaviour-shift study is attributed to the US and hedged to "may be", rather than asserting what the movement was.
- The study result carries its figures: a 51% fall for the complying site, 48.1% and 23.6% rises for the rival and for VPNs.
- The age-check finding becomes what the study found — data reaching "fourth parties" less visible to users — rather than the leak framing.
- Section 04 is now "The Logos approach: Private tech and civic resilience".
- Ross Anderson's claim about family violence is attributed to the paper.
- Adds the Lang et al. credit under the search-demand chart, in the same face the quote cards use.
Three passages are dropped: the "machinery doesn't stay in the box" paragraph, and the chokepoint paragraph with the pull quote and closing paragraph that followed it.
The source credit is now a shared `SourceNote`, so the chart and the quote cards cannot drift apart. `PullQuote` goes with the pull quote it existed for. The heading test reads its expectations off the copy rather than repeating it, so an editorial change no longer reads as a broken page.
The structured data still carried the page's opening paragraph as its `description` while the meta tags carried the search copy, so a crawler could read two different descriptions of the same page.
Both now come from a single `SEO` constant, which moves next to the rest of the page's copy. A test pins the metadata and the Article JSON-LD to the same string so they cannot drift apart again.
Replaces the meta description with the copy the team wrote. 134 characters, so search results still show it whole, and it now leads on the trade-off rather than on the mechanism.
Feeds og:description and twitter:description as well, since they all come from the same constant.
Removes the small numbered headers above each section heading — `[00 / The promise]` through `04 / The work` — and the bold `Exhibit 01`–`Exhibit 04` labels above the source credits. The credit lines themselves stay: each card still names the work it quotes.
`02 / Broken promise` goes with the rest. Keeping it would have left the sequence as a lone "02".
Knock-on cleanups:
- Reverts the Fira Code 700 weight. It was pulled in for the exhibit labels alone, and nothing on the page renders bold monospace any more.
- Drops the now-unused `eyebrow` and `label` fields, the eyebrow props on `SectionHeading` and the light-panel dim constant. `SectionHeading` is a heading again.
- The WCAG AA shortfall goes with the eyebrows — those were the 3.11:1 / 2.97:1 / 2.25:1 measurements. The one dimmed run left is the case-file note at 4.67:1, which passes.
A word could start at the end of one line and hop to the next mid-run. Only the typed prefix was in the DOM, so the browser re-broke the lines on every character: "with" fitted at the end of line one until "with e…" no longer did, and the whole word dropped.
The quote now always holds its full text — the untyped tail is merely invisible, which still occupies its space — so the line breaks are the finished ones from the first frame and no glyph ever moves. Replaying every character of all four cards at 375 and 1440 gives exactly one distinct card height and one distinct position per probe word, against three heights and nine positions before.
That also removes the copy that existed only to reserve height, and with it the overlay: the quote is a single in-flow paragraph again, so the cap-height trim reaches it directly rather than needing to be repeated on an out-of-flow copy. Padding stays 40/40 at desktop and 24/24 at mobile, and a finished quote still copies exactly once.
The quote cards read with more space above the text than below. `text-box-trim` is not inherited and does not reach out-of-flow descendants, so the copy that types itself in — absolutely positioned over the one reserving the height — kept the half-leading its in-flow twin had trimmed and sat about 6px low. Every motion-enabled visitor saw that copy, so the cards rendered roughly 46/27 instead of Figma's 40/40. All four now measure 40/40 at desktop and 24/24 at mobile, with the typed copy landing exactly on the reserved one.
Three more text boxes were missing the same trim, each measured against its Figma node:
- The hero headline used `min-h-[1em]` per line, inflating the block to 112px against Figma's 97. The guard exists so the animated copy does not collapse while a line is empty, and that copy is absolutely positioned, so it now applies there only. The block measures 96.88.
- The CTA card title had no trim, pushing its button 7.6px down. Title and button now sit at 195.05 and 259.94 against Figma's 195 and 260.
- The study sub-heading likewise: 18.98 against Figma's 19.
The hero highlight also tracked the CSS line box rather than the fixed em-tall rect Figma draws, so it sat high and left no yellow margin under the text. Anchoring it at `0.118em` puts it at 52.98–108.98 against Figma's 53–109.
Also from review: copying a quote yielded the text twice, once from the screen-reader copy — which matters on a page built to be cited. Guarded `isArmed()` so an empty class name cannot throw out of `classList.contains`, which the comment already claimed. Added the `image` the Article rich result needs.
The page had no schema.org data and a meta description roughly twice the length search results display.
- Emits an `Article` block carrying the page's subjects and, more usefully, all six case-file sources as `citation` entries, so the sourcing survives outside the rendered list. Emits a `BreadcrumbList` alongside it.
- Cuts the meta description from 281 to 157 characters and leads with the terms people actually search for — Chat Control, age checks.
- Adds page-level keywords instead of falling back to the generic site list.
- Sets `og:type` to `article`, matching the other campaign page rather than the site default of `website`.
- Wraps the page in `<article>`: it is one long sourced argument, not a set of unrelated panels.
The structured data lives beside the route rather than in `lib/structured-data.ts`, because those shared helpers arrive with the site-wide SEO work still in review and the citation list is campaign-specific either way. Worth folding together once that lands.
`datePublished` is deliberately absent — Article rich results want one, but a guessed date is worse than none. It needs the real launch date.
Verified against the static export: canonical, hreflang, both JSON-LD blocks parseable, one h1, six h2s, and the route in the sitemap.
The typewriter belongs to the quote cards, so the hero headline renders outright again. The machinery is kept rather than deleted: `TypewriterHeadline` becomes `HeroHeadline` with a required `animate` prop, and `hero.tsx` holds a single `ANIMATE_HEADLINE` constant to flip it back on.
The prop is required and the component renamed because `TypewriterHeadline animate={false}` reads as a contradiction, and a required flag makes the intent visible at the only call site.
With `animate` off the component skips the two-copy structure entirely and emits a plain <h1> — no reserved copy, no overlay, no aria-label. That matters because the arming class still lands on <html> for the quote cards: had the hero kept rendering both copies, its static copy would have been hidden and the headline would have typed itself in anyway. The hook takes an `enabled` flag so no timers or observers run either.
`TypewriterArmingScript` moves from the hero to the page root, which is what it actually arms now.
The typewriter on scroll was meant for the quoted exhibit cards, not the section headings. Headings go back to plain <h2>; each of the four light-blue quote cards now types itself in when the card scrolls into view. The hero headline is unchanged.
Both visual copies of a quote are hidden from assistive tech, and `role="paragraph"` cannot carry an accessible name, so the quote reaches screen readers through a dedicated screen-reader-only copy.
Also picks up the Copilot review:
- The CTA cards are clickable and were missing `cursor-pointer`.
- CTA hrefs were looked up positionally with a non-null assertion, so a new card would have silently rendered an undefined href. Cards now carry a key and the lookup is an exhaustive Record.
- The typewriter tests asserted Tailwind class strings, which AGENTS.md rules out. The two copies are now tagged with `data-typewriter` and the tests read their text instead. The class-drift test is gone: the armed class name is read back out of the Tailwind variant that uses it, so the script and the CSS cannot drift apart in the first place.
- Recorded the eyebrow contrast sign-off next to the constant, with the measured ratios, so it stops being re-raised.
The last refactor moved the type scale off the hero <h1> and never passed it back in, so the headline rendered at body size. `Hero` now hands `text-h2` and the cap-height trim to `TypewriterHeadline`, the same way `SectionHeading` feeds `TypewriterHeading`.
Also closes the last two measured gaps against the design. The CTA cards are laid out as Figma has them, two fixed 460px cards running 960px from the left gutter rather than a pair stretched across the content width, and the case file closes on Figma's 46px spacer.
The card column chain had to move onto `min-[…]` throughout: named breakpoint variants are emitted after arbitrary ones in this Tailwind build, so `md:grid-cols-2` was beating the 1440 rule.
The headline's two copies are now blocks rather than inlines wrapping block-level lines.
The hero headline was gated on a sessionStorage flag, so a refresh showed it already complete. That gate is gone: it now types itself in on every page load, and still runs only once per load.
Each section heading now types itself in as it comes into view. A heading already on screen at mount starts straight away; the rest wait for an IntersectionObserver, triggered once its top rises past 85% of the viewport.
Both share the pre-paint arming script, so the server-rendered text never flashes before an animation starts. The static copy stays in the flow but invisible while armed, reserving the height a heading will end up at so a two-line heading does not shove the page down as it types.
Arming now carries a failsafe: if the bundle has not run within four seconds the class is dropped and the static text is shown, so a heading can never be left blank by a script that fails to load.
Also applies the cap-height trim to the case-file heading, which had been rendering untrimmed while every other heading was trimmed.
Implements the campaign landing page at /catch-no-one from Figma: a hero with a one-shot typewriter headline, five content bands, the search-demand bar chart, two CTA cards, and the source list.
Copy lives in a colocated _content.ts, transcribed verbatim from the design. Styling is Tailwind over the logos-tokens design system, falling back to Figma's exact values only where the token ramp has no match: the #2f2f2f CTA cards, the neutral chart greys, and the #fafaf7 dark-panel ink.
Text primitives carry text-box-trim so the 18/60/112px rhythm is measured cap-to-cap the way Figma measures it, rather than line-box to line-box.
The headline types itself in once per browser session, is skipped under prefers-reduced-motion, and is armed by a pre-paint script so the server-rendered text never flashes before the animation starts.
Also adds Fira Code 700, which the exhibit labels need and which the browser was previously synthesising.
* refactor(web): dedupe funnel form options across the 3 forms
`skills`, `chatService` and `country` were byte-identical in all three
form definitions. They move to `packages/funnel/src/form-options.ts` and
reach the pages through `AFFORM_OPTIONS`; the form files now describe
rendering only.
Lists verified ordered-identical to the originals, entry for entry.
Closes#140
* refactor(civi-crm): derive notion id/label maps from shared funnel options
`SKILLS_MAP`, `CHAT_SERVICE_MAP` and `COUNTRY_MAP` were a fourth copy of
the funnel option lists. They are now `toLabelMap()` over the shared
arrays, the way `HEAR_ABOUT_MAP` already was, so an id cannot mean one
thing in the dropdown and another in Notion.
All 275 derived entries verified identical to the previous literals, so
the Notion and n8n writes are unchanged. The one exception is country
1054, normalised to `Côte d'Ivoire` -- the spelling Notion already stored
and the one the other apostrophised country names use. The dropdown label
and the Ghost member note now match it.
* feat(funnel): add per-form required field list
Single source for which funnel answers are required, plus the check the
intake endpoint runs a submission against.
* refactor(web): build the funnel form schema from the shared list
Form definitions no longer carry a `required` flag, they describe
rendering only. The three pages pass REQUIRED_FIELDS_BY_FORM into
ConnectFormSection, which derives the zod schema and the required markers
from it.
* fix(civi-crm): validate required funnel fields on afform-submit
Only formName, the hear-about answer and the captcha were checked, so a
direct POST could create a record with no name, email or country. A
payload missing a required answer now gets a 400 naming the fields.
* docs(funnel): document the required-field validation
The sitemap fetched RFPs strictly while generateStaticParams and the RFP
listing used a lenient wrapper that degraded to an empty array. Under
`output: 'export'` there is no runtime fallback, so a GitHub hiccup during
generateStaticParams shipped a site whose RFP detail routes all 404 while
the sitemap still advertised them.
Every caller now uses the throwing fetch, so the page set and the sitemap
always agree. Transient failures (403/429/5xx and network errors) retry
with exponential backoff — including the directory listing, which was
single-shot — while a 404 falls straight through to the next fallback URL
instead of burning two pointless retries.
Sitemap RFP entries now come from fetchGithubRfpsStrict, which throws when the Github listing or any individual RFP file cannot be read. Previously a failed fetch silently returned an empty list, so a Github outage shipped a sitemap with no RFP URLs while CI stayed green. The RFP pages keep the lenient loader and still degrade to an empty listing.
The static export smoke test derives RFP routes from the exported HTML instead of making a second Github call, which removes the build-time vs check-time race and makes the check fully offline.
Technology stack pages read the parent /technology-stack page copy for the parent breadcrumb name, instead of deriving it from the child page's own hero eyebrow.
Organization @id and url use the normalised site origin, and the structured data tests derive their base URL from siteConfig rather than hardcoding logos.co.
* refactor(civi-crm): remove case management pages and API
The Circle Case UI, its API routes and the CiviCRM query layer they used
are gone. Keeps the app shell out entirely: no pages remain, so the
layout, styles and UI dependencies go with them.
* refactor(civi-crm): drop CiviCRM write from the intake endpoint
Submissions now go to Notion and, for the steward form, n8n. The legacy
afformCircleContactForm name was CiviCRM-only and is no longer accepted;
the fields[] payload is ignored.
* chore(web): remove CiviCRM afform generator scripts
They fetched form definitions from the CiviCRM API, which is gone. The
generated files under lib/civicrm are hand-maintained from now on.
* refactor(web): trim funnel form definitions and move to lib/funnel-forms
Fields keep only what renders the form: formKey, label, inputType,
required, repeatable. The CiviCRM entity/join/fieldName plumbing and the
fields[] POST payload are gone, along with the unused circle contact
form.
country was marked required but exempt from validation through its
CiviCRM Address join, so it is now enforced like every other required
select.
* docs: rewrite civi-crm and funnel docs for the intake-only app
Drops the CiviCRM API guide and the case management architecture, and
updates the funnel reference to the Notion + n8n flow.
The hero is a fixed 760px, which is taller than most phone viewports, so
copy centred within the section still sat low on screen — on a 690px
viewport there was 114px above the block and only 36px below it.
Below `lg`, centre the copy in min(100svh, 760px) instead, floored at
560px so a landscape phone cannot clip it. From `lg` up nothing changes:
the same absolute offsets as the homepage, which keeps rendering
byte-identical markup because the new layout is opt-in per page.
Same problem as /build-the-parallel: the whole card is the link, so the
Umami click tracker was naming the event after the card's entire text —
heading, CTA label and body run together — which made the three cards
indistinguishable in the dashboard.
Attributes only; the rendered markup is otherwise unchanged and the page
is pixel-identical to develop.
Page views, bounce rate and time on site already come from the site-wide
Umami script, and UmamiButtonTracker already fires on every link click —
but it names the event after the element's text. That breaks on a copy
edit, and for the path cards the whole card is the link, so the event name
was the heading, CTA label and body run together.
Give the hero and path-card sections an optional event-name prop and set a
stable name for all seven CTAs. Both props are optional, so the homepage
keeps rendering byte-identical markup.
The centred flex layout spaced the three hero blocks evenly, but the
homepage's offsets are not even (52px above the headline, 78px below), so
the headline landed 22px lower than on the homepage.
Go back to the shared offsets layout on /build-the-parallel. Both pages now
render the hero from the same component with the same absolute positions,
and both overlay their header, so the section starts at page y=0 on each.
The hero placed its three copy blocks at hand-tuned absolute offsets, so
the group drifted a little below the section's centre and the drift moved
with the viewport and the length the copy wrapped to.
Add a `centred` content layout that stacks the same blocks in a flex
column with an even 70px rhythm, centred in the hero, and use it on
/build-the-parallel. The homepage keeps the existing offsets layout and
renders byte-identical markup.
The header reserved its 40/42px bar on this page, so a beige strip sat
above the hero. Register /build-the-parallel with the existing hero-tone
header list so the nav floats over the hero image the way Figma draws it.
Figma leaves 224px between the path cards and the footer; the shared
choose-your-path section only contributes 56px, so the page adds the
rest and scales it down below `lg`.
Compose the Figma landing page entirely from existing sections: the
shared hero (still image + its own copy), the statement heading with two
CTAs, the Movement page's centred lead-in above the Circles map, and the
choose-your-path cards.
Copy lives in content/pages/en/build-the-parallel.json using existing
section schemas, so no schema changes were needed. British spelling
follows the rest of the site rather than the mixed spelling in Figma.
Move the hero and choose-your-path card sections out of sections/home so
pages other than the homepage can compose them, extract the two-line
statement heading, and lift CenterCtaSection out of the Movement page's
private atoms module.
The hero now takes an optional `background` node, defaulting to the same
Logos background video the homepage has always rendered.
Rendered output is unchanged: the homepage's hero, social proof, and
choose-your-path markup and the Movement page body are byte-identical to
develop, and the generated stylesheet loses no rules.
* chore(web): add a debug level to the logger
Logs in every build but tests, for output a deployed preview needs.
* feat(web): forward submitted form data on newsletter signup
Every field a form collects is sent to the subscribe endpoint, select
values as their labels rather than CiviCRM option ids. Logs the payload.
* docs(funnel): document the forwarded subscribe payload
* fix(web): build the funnel newsletter payload from answers only
Keeps the intake captcha token and field defs out of the newsletter
service whatever a caller puts in extraPayload.
* fix: increase small text to 12px
* fix: use initiative CTA label
* refactor: use Tailwind text-xs utility
* fix: make homepage banner dismissible
* fix: update Discord links and banner text
* fix: refresh Discord invite links