601 Commits
Author SHA1 Message Date
jinhojang6 57a689fb68 refactor(web): reuse normalizeLabel for each rendered tracking line 2026-08-29 00:30:37 +09:00
jinhojang6 2ede0d4a8c fix(web): separate stacked copy in button tracking event names
Button labels were built from textContent, so a card that stacks a heading
above its CTA produced run-together names like "Explore the TechGet Started".
Build the label from innerText instead and join its rendered lines with the
" - " separator already used by hand-written event names.
2026-08-29 00:30:37 +09:00
jinhojang6 df278cde49 feat(web): draw the search-shift chart as a diverging bar chart
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.
2026-08-27 23:37:50 +09:00
jinhojang6 12e9989163 content(web): pluralise the first chart row label
"Search for the site that complied" becomes "Searches for…", matching the two rows below it.
2026-08-27 23:37:50 +09:00
jinhojang6 82b306e0a2 content(web): apply the third set of Chat Control copy revisions
- 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".
2026-08-27 23:37:50 +09:00
jinhojang6 b186ab324f content(web): apply the second set of Chat Control copy revisions
- 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".
2026-08-27 23:37:50 +09:00
jinhojang6 1577c7c3e2 content(web): credit Rivest and Diffie as pioneers of public-key cryptography
"who invented the public-key cryptography that secures the modern internet" becomes "pioneers of" — the same claim without overstating either authorship.
2026-08-27 23:37:50 +09:00
jinhojang6 fce9a94bdb refactor(web): move the campaign page to /chatcontrol
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.
2026-08-27 23:37:50 +09:00
jinhojang6 a85f2a54ea content(web): apply the Chat Control copy revisions
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.
2026-08-27 23:37:50 +09:00
jinhojang6 d90aa93bb0 refactor(web): describe the campaign page once, not twice
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.
2026-08-27 23:37:50 +09:00
jinhojang6 8cc5151c2b chore(web): use the supplied SEO description for the campaign page
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.
2026-08-27 23:37:50 +09:00
jinhojang6 42bae7c238 feat(web): drop the section eyebrows and the exhibit labels
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.
2026-08-27 23:37:50 +09:00
jinhojang6 bceb169f0c fix(web): stop words jumping lines while a quote types itself in
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.
2026-08-27 23:37:50 +09:00
jinhojang6 858989c51c fix(web): trim the text boxes Figma trims, and fix the quote card padding
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.
2026-08-27 23:37:50 +09:00
jinhojang6 2a6f10a8a6 feat(web): add structured data and tighten SEO for the campaign page
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.
2026-08-27 23:37:50 +09:00
jinhojang6 15d9d9e932 refactor(web): render the hero headline statically, keep the typewriter behind a prop
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.
2026-08-27 23:37:50 +09:00
jinhojang6 f15cdecb10 feat(web): type the quote cards in, not the section headings
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.
2026-08-27 23:37:50 +09:00
jinhojang6 7d6b7614c7 fix(web): restore the hero type scale and match Figma's CTA row
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.
2026-08-27 23:37:50 +09:00
jinhojang6 acef64d359 feat(web): replay the hero typewriter and type section headings on scroll
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.
2026-08-27 23:37:50 +09:00
jinhojang6 4ab68c20cb feat(web): add "Check everyone to catch no one" landing page
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.
2026-08-27 23:37:50 +09:00
Jules 8a70822731 refactor(funnel): single source of truth for funnel form options
* 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.
2026-08-26 00:35:44 +09:00
Jules 4a32420ddf fix(web): build through turbo instead of rebuilding shared deps
* fix(web): build through turbo instead of rebuilding shared deps

The app build re-ran the logos-ui build, whose `tsup --clean` wiped
packages/ui/dist while logos-crm compiled against it in parallel. The deploy
now runs the same turbo build as CI, so only the ui task writes that dist.
`out/**` joins the build outputs since turbo owns the export now.

* docs(web): build the app through turbo

* fix(web): build on Vercel through turbo

Vercel ran the app build directly, which no longer builds
@acid-info/logos-ui and failed to resolve it. vercel.json pins the deploy to
the same turbo command Jenkins runs.
2026-08-25 00:19:01 +09:00
Jules dfe8cf0451 fix(civi-crm): validate required funnel fields on afform-submit
* 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
2026-08-24 23:24:13 +09:00
jinhojang6 7af2285712 fix(web): fail the build when the RFP source is unreachable
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.
2026-08-20 23:10:38 +09:00
jinhojang6 e65454efc1 fix(web): harden sitemap RFP sourcing and breadcrumb names
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.
2026-08-20 23:10:38 +09:00
jinhojang6 f7186c294a fix(web): remove inaccurate sitemap lastmod 2026-08-20 23:10:38 +09:00
jinhojang6 5fcb7c5fa5 feat(web): add structured SEO data 2026-08-20 23:10:38 +09:00
jinhojang6 130a8e029b fix(web): align RFP sitemap with live source 2026-08-20 23:10:38 +09:00
jinhojang6 50be39f982 feat(web): add museum anchor links 2026-08-14 00:34:14 +09:00
jinhojang6 a7571936db fix(web): prevent ambiguous Umami events 2026-08-13 23:02:42 +09:00
Jules bc30c4cede refactor(civi-crm/web): drop CiviCRM integration
* 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.
2026-08-13 22:44:23 +09:00
jinhojang6 dca300f059 fix(web): distinguish repeated analytics events 2026-08-13 21:09:28 +09:00
jinhojang6 67c2ad1c92 feat(web): track past present future interactions 2026-08-13 20:55:51 +09:00
jinhojang6 a37549abad fix: replace blocked museum videos 2026-08-13 02:06:48 +09:00
jinhojang6 1473e0806e fix: use CSP-compatible YouTube embeds 2026-08-12 22:53:41 +09:00
Jules 10f539e406 fix(web): exclude vendored static export from lint
ESLint parsed the minified SvelteKit bundles in public/past-present-future, failing test:release.
2026-08-12 22:23:17 +09:00
jinhojang6 f46bf901eb fix: correct bottom museum CTA link 2026-08-12 22:14:45 +09:00
jinhojang6 bab7ff38a0 fix: update museum CTA links 2026-08-12 22:14:45 +09:00
jinhojang6 f6a5d1d919 perf: optimise past present future background video 2026-08-12 04:27:46 +09:00
jinhojang6 2e97fe2b7e fix: self-host past present future background 2026-08-12 03:14:29 +09:00
jinhojang6 6413c74b81 fix: rename civil liberties museum route 2026-08-12 00:26:16 +09:00
jinhojang6 aad422b89c fix: add missing Wikileaks museum image 2026-08-12 00:26:16 +09:00
jinhojang6 4a8097da49 feat: add past present future static site 2026-08-12 00:26:16 +09:00
Jules aeac2e1a4c fix(tech-stack): replace fixed section heights with min-heights
TechDetailHero and TechTextSplitSection were locked to fixed heights, so
content spilled into the next section once text rewrapped. The hero's
mobile block also positioned its children absolutely inside an h-full
container, which cannot grow with its content at all.

Move that block to normal flow with the same 21/60/122px offsets, and
convert every fixed height to a minimum across both primitives and all
call sites. Section margins are untouched, so the gaps are unchanged.
2026-08-11 20:14:30 +09:00
jinhojang6 23d5f6a691 fix(web): keep roadmap release image height equal across tabs
The feature image follows the copy column, so it resized on every tab
switch (639/592/481/481px at the 1440 canvas). Stack all four releases
into the same desktop grid cell instead: the cell is always as tall as
the tallest release, and only the active one is visible while the rest
hold the height open. Every tab now renders the same image height and
the module table stays flush with the bottom of the image.

A fixed min-height would not work here because the tallest release
changes with the column width - at 1280px the spread is 717/639/496/481.
Mobile still renders the active release alone.
2026-08-11 04:00:54 +09:00
jinhojang6 a1f94f84dc fix(web): match roadmap release columns to Figma
Equalise the release panel columns on desktop: the feature image dropped
its fixed 702/521 aspect and now stretches to the grid row, so its bottom
edge lands on the last module row and the module table sits flush with the
bottom of the image. Mobile keeps the 702/521 crop since it stacks.

Cap the release copy at 92ch instead of 548px so the desktop line breaks
land exactly where Figma has them (storage. / stack / making / through).
The cap sits on the element that owns font-mono-body because ch resolves
against the element's own font, and using ch rather than px keeps the
breaks stable across the Fira Mono fallback stack. Mobile is uncapped and
wraps naturally.
2026-08-11 04:00:54 +09:00
jinhojang6 10b0267f73 fix(web): align roadmap release content 2026-08-11 04:00:54 +09:00
jinhojang6 c1ff22f864 fix(web): keep roadmap release images fixed 2026-08-11 04:00:54 +09:00
jinhojang6 2d498487bd fix(web): prevent roadmap image flicker 2026-08-11 04:00:54 +09:00
jinhojang6 3e2dfbd08f feat(web): switch roadmap images by release tab 2026-08-11 04:00:54 +09:00