Skip to content

Gated downloads, HubSpot marketing opt-ins, and the Tricorder page (on hold) - #269

Merged
digibrander merged 27 commits into
mainfrom
development
Sep 18, 2026
Merged

digibrander merged 27 commits into
mainfrom
development

Conversation

@jadhavgaurav

@jadhavgaurav jadhavgaurav commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Eighteen commits from three workstreams.

Tricorder is still linked in the Products menu. Its page is now noindex and out of the sitemap ("on hold pending sign-off"), but the nav entry in apps/web/src/lib/nav-config.ts remains, so after merging every visitor can reach it from the menu. The hold note lists only the noindex flags and the sitemap as launch steps, so the nav link looks like an oversight. Remove it before merging if the page should stay hidden.

1. Gated resource downloads (2 commits)

Marking a resource gated in the CMS has never gated anything on the live site. This makes it work, and restores the two gates lost in the Webflow migration.

Restored gates. Webflow gated by resource type in its page template, not with a CMS field, so the import wrote gated: false for all 27. Wayback snapshots of the live Webflow pages show exactly two were gated, the only ebook and the only report:

  • containing-vulnerabilities-in-your-containers
  • securing-the-software-supply-chain-in-2026

Both are already gated in production. Until this merges, they still download freely.

Why the gate never worked:

  1. The popup never opened: the page loaded the gate form without credentials, forms is readable by admins and editors only, so visitors got a 403 and the button fell back to an open link.
  2. Every submit would have failed: the server validates required consent as fields.consent === true, which the old renderer never sent.
  3. The file URL was public anyway, through the listing payload, the share image, the structured data and the button's props.

Fix. A coded popup (GatedDownloadForm) on the shared fields. The listing no longer selects asset; share and structured-data images use heroImage instead of the PDF (for every resource). A gated page now contains the file URL zero times.

Flow: Download → popup → HubSpot → download starts → thank-you email with the link as a backup.

The popup's field names must match the CMS content-gated form, because the API validates against that stored definition.

2. HubSpot marketing opt-ins (1 commit)

Ticking "Keep me updated" on book-a-demo, contact or the gate form never subscribed anyone. The relay sent a subscription whenever the form had a type, and ignored the visitor's choice, so every such form was left without a type. It now subscribes only when the visitor opted in. The newsletter is unchanged, since signing up is the opt-in.

Subscription types, verified in HubSpot: Marketing Information 2258674941, One to One 2258674944, Newsletter 3005083821. The gate form's own HubSpot checkbox is wired to One to One (HubSpot's type for 1:1 sales email), which is wrong for a marketing opt-in.

⚠️ Post-deploy step

After this is live, run in the production CMS container:

/app/node_modules/.bin/tsx scripts/set-form-marketing-subscriptions.ts --dry-run
/app/node_modules/.bin/tsx scripts/set-form-marketing-subscriptions.ts

It points book-a-demo and content-gated at Marketing Information. It must not run before the deploy, because against the old relay it would subscribe everyone. It enforces this itself: run against the current production relay, it refused with does not provide an export named 'hubspotLegalConsent' and wrote nothing. Contact is excluded because its HubSpot form has no consent configuration.

3. Other sessions' work

  • Tricorder product page, redesigned and held from indexing (13 commits, see the note at the top)
  • fix(web): redirect bare busybox blog URL to /blogs/ post

Verification

  • apps/web: lint, typecheck, 477 tests, build (against production CMS data).
  • apps/cms: lint, typecheck, 2,023 tests, build, and the emails:check drift gate.
  • Built HTML: gated pages contain the PDF URL 0 times and render a button; listing pages contain 0 PDF URLs; open resources keep their link.
  • Locally against the production build: the popup opens, validates inline, accepts personal email, and the title wraps at 375px.

Not yet tested: a real submission end to end (HubSpot, download, email). The production CMS rejects submissions from any origin but www.cleanstart.com, so that test has to wait for the deploy.

Deploy

This touches apps/cms, so CMS CI and Deploy CMS run. There is no migration.

claude and others added 18 commits September 16, 2026 06:13
Build /tricorder from "The Intelligence Layer" copy doc on the site's own
design language: the product-hero shell with a scan console drawn in code
(signal meters, version chain, verdict) on a radar sweep; three evidence
cards for "Not Every Threat Has a CVE" (version timeline, behaviour diff,
relationship graph); a dark context scene where history, behavior and
relationships converge on the component and resolve to a verdict; the
Analyze → Compare → Correlate → Enrich rail ending in a verdict terminal;
and the substrate scene fanning the Tricorder core out to Clean Images,
Clean Libraries and CleanSight. Footer CTA card with a verdict ledger.

Every scene is SVG/CSS on the --fs-* tokens; motion reuses the cs-lep-*
keyframes plus a small cs-tri-* set for the console, all off under
prefers-reduced-motion. Registered in the Products nav (new `lens` glyph),
the footer Product column, STATIC_ROUTES, llms.txt and WEB-PAGES.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198mhURhZhARAmAHoDNoCmQ
Replace the orb-and-branches diagram with the layer drawn literally: the
three products stand on glass pedestals carrying the site's own hexagonal
product art (shared with the homepage factory), each dropping a flowing
current into a gridded floor that recedes in perspective, with a flare
where it lands. The Tricorder emblem — a bevelled hex in the product-art
palette with the lens mark — is set into the floor, with the four analysis
stages laid along it. Decision chips now carry lucide glyphs and align to
the card bottoms. Below lg the pedestals stack and the floor becomes the
homepage-style hatched layer panel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198mhURhZhARAmAHoDNoCmQ
The section diagrammed the claim instead of making it. Replace the floating
tiles and dotted beams with one panel holding the same package twice: a flat,
desaturated "seen in isolation" readout where every fact is reassuring
(signed, zero CVEs, license OK, nothing to flag), then a lit "seen in
context" half where three lenses each carry real evidence — a version
timeline with the fresh release flagged, a capability profile with network
and shell live, a relationship graph sharing one host — and their currents
converge on a Malicious verdict. The two halves are separated by tone rather
than a rule, so the eye travels from a dead readout into a live one.

The left column drops the gradient divider bar under the heading and becomes
a quiet accent-bar legend, since the panel opposite now carries the density.
Below sm the identity row stacks (no truncated package name), the connectors
are dropped and the verdict reads as an icon-led status banner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198mhURhZhARAmAHoDNoCmQ
An audit diffing every rendered text run against the copy doc turned up
four gaps, all now closed:

- The doc sets the pipeline out as `Analyze → Compare → Correlate → Enrich
  → Verdict`. Only the last hop carried its arrow; every hop carries one
  now, and the arrow columns collapse below lg where the cards stack.
- The hero console labelled two of its rows "Correlation" and "Enrichment",
  near-misses of the doc's own "Correlate" and "Enrich". All four rows now
  use the doc's stage names and the pipeline's accent colours, so the hero
  shows the same sequence the page explains further down.
- The hero carried a second CTA, "How It Powers CleanStart", that appears
  nowhere in the doc. Removed; "Talk to an Expert" is the doc's only call
  to action and now the page's only one. The substrate anchor stays.
- The rail keyframes were injected through a <style> tag inside the
  section, which put raw CSS into the page's text content. Moved to
  globals.css alongside the page's other keyframes.

Every headline, sub-head and body line now matches the doc verbatim. The
sample data inside the visualisations is illustrative and is flagged as
such in the page inventory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198mhURhZhARAmAHoDNoCmQ
Take the structure and the vocabulary straight from the copy doc's own
mock: three signal groups, each showing what it actually reads, their
currents converging into one component that resolves into the verdict.
Every label is the doc's — the 1.4.1 → 1.4.2 → 1.4.3 version chain,
Network / Shell / File System / Code, Maintainer → Packages →
Infrastructure → Endpoints, package-name@1.4.3, and "Evidence-backed
verdict." That replaces the invented sample data this section carried.

Three things are translated rather than copied, because the doc's mock is
styled for a white page:

- The group panels become glass on the section gradient, the way the Clean
  Libraries scenes are built. The section sits between two light sections,
  so building it light would run three pale bands together and flatten the
  page's alternation.
- The doc's outline-in-a-circle icons become GlassIcon gem tiles, the icon
  vocabulary the rest of the site uses. Keeping the circles would put two
  icon languages on one page.
- The doc's green becomes the teal already sanctioned as a card accent.
  Plain green is not in the CleanStart palette.

The verdict card reuses the homepage Intelligence Center treatment, which
is the site's existing way of saying "this is the Tricorder layer". The
heading stays left-aligned so the section still reads differently from the
centre-headed sections either side of it. Fixed 1320px canvas scaled to
fit; below lg the groups stack and the four-across rows wrap to 2x2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198mhURhZhARAmAHoDNoCmQ
…design-p9wsvc

feat(web): add the Tricorder product page
Drop the outline rings on every card and build depth from fill, a top
sheen and shadow. Move each group header inside its panel, set the item
glyphs in accent tiles, turn History into a release timeline and join the
Relationships chain with dashed links. The verdict card becomes the
brightest object in the scene and loses the hatch that pooled at its
bottom edge.
The elbow currents from History/Behavior/Relationships into the bus were
visual noise the panels didn't need. Tighten the vertical rhythm now that
they're gone and keep the single Component to Verdict drop as the one
line in the scene.
Replace the History/Behavior/Relationships GlassIcon gems with a small
accent dot next to the label. The colour identity stays; the icon that
was competing with the item tiles below it is gone.
Match the site's standard CTA layout (heading left, body + button right,
as in DeveloperCTA/VulnCTA) instead of the two-column heading/ledger
split. Removes the now-unused VERDICT palette import and MONO const.
items-start left the title and body/button block pinned to the top of
the taller footer slot with dead space below. Center them as one row
instead, and drop the stray justify-center that was fighting the
left-aligned two-column layout.
Marking a resource gated in the CMS has never gated anything on the live site.
Three independent faults, none noticed because no resource was gated until
today.

The popup never opened. The page loaded the gate form with getFormById, but
fetchCMS only sends the CMS API key in draft mode, and the forms collection is
readable by admins and editors only, so a visitor's render got a 403, the
error was swallowed, and the button fell back to an open download link. It
worked in preview, where the key is sent, which is how it hid. Sending the key
would not have fixed it either: the key belongs to preview-bot, an author.

Had it opened, every submit would have failed. Server validation reads a
required consent field as `fields.consent === true`, and FormRenderer never
puts consent into `fields`.

And the file was public regardless. The listing query selected `asset`, which
serialised every resource's file URL into the client payload on every page
that lists resources; the detail page used the PDF as og:image, twitter:image
and JSON-LD image; and the download button received the URL as a prop.

The popup is now a coded GatedDownloadForm, like Book a Demo and Contact, on
the shared fields with inline errors. It posts formSlug content-gated with the
resource id as context and a boolean consent, through submitLead, which now
carries context and returns the signed download link made absolute to the CMS.
The HubSpot relay drops non-string fields, so that boolean never reaches the
CRM form. Its field names must match the CMS form, because the API validates
against that stored definition.

The listing no longer selects `asset`, share and structured-data images use
`heroImage` (a PDF is not an image for any resource; the three without a hero
fall back to the generated share card), and a gated button gets "#" instead of
the file URL. A gated page now contains the file URL zero times.

getFormById had no other caller and is removed. The popup title wraps instead
of truncating mid-word.
…flow gates

The gated-download email only handed over a file ("Here is your copy of..."),
and it arrives after the download has already started in the browser. It now
leads with thanks and keeps the signed link as the way back for anyone who
closed the tab. No follow-up promise, per 9f0defc. The registry named
resource-capture as its trigger; gated downloads go through content-gated, the
only form the Resources gate picker allows.

gate-webflow-gated-resources.ts restores the two gates the migration lost, and
is kept as the record of that production change. Webflow gated in its page
template by resource type, not with a CMS field, so the export had nothing to
read and the import wrote gated:false for all 27. Wayback snapshots of the
live Webflow pages show exactly two gated: the only ebook and the only report,
whose PDF links sat inside the form's success block. Every other resource page
exposed an open link. The script sets gated, gateForm and accessLevel through
payload.update, is idempotent, and was run in production on 2026-09-17.
Pre-migration URL /busybox-container-security-risk (76 referring
domains, no /blogs/ prefix) 404s. 301 it to the live post.
Rebuild the page on the Financial Services / SaaS design system, with
every word taken from the copy doc.

- Hero: the doc's brain-in-a-glass-cube render replaces the scan
  console and its invented findings.
- Not Every Threat Has a CVE: one joined white panel with blue icon
  spheres; the invented mini illustrations are gone.
- From Signals to Verdicts: a scan line through four stations into a
  Verdict card that selects an outcome each pass. Static and fully lit
  before hydration and under reduced motion.
- One Intelligence Layer: the Tricorder cube on top, branching into
  Clean Images, Clean Libraries and CleanSight, as the doc draws it.

The page is noindex,nofollow and out of the sitemap until the redesign
is signed off.
A visitor who ticked "Keep me updated" on book-a-demo, contact or the gated
download form was never subscribed. The relay sent a marketing subscription
whenever the form named a subscription type, and ignored the visitor's
`consent.categories` entirely, so the only safe setting was to leave the type
empty on every form that has an optional marketing box. The opt-in reached the
server and was stored, and was then dropped on the way to HubSpot.

The consent block is now a pure `hubspotLegalConsent`, which always records
consent to process and adds the subscription only when the categories include
`marketing` and the form names a type. The newsletter is unchanged, because
signing up is itself the opt-in and it always sends that category. The old
test asserted a subscription for a consent with no categories at all, which is
the behaviour being removed.

set-form-marketing-subscriptions.ts then points book-a-demo and content-gated
at Marketing Information (2258674941). It must run after this change is live,
since a type set against the old relay would subscribe everyone, so it imports
the new helper and checks an unticked visitor gets nothing before writing.
Against the current production relay it refuses with "does not provide an
export named 'hubspotLegalConsent'" and writes nothing.

IDs were verified in HubSpot rather than read off the forms. The portal has
three subscription types: Marketing Information 2258674941, One to One
2258674944 and Newsletter 3005083821. The gated form's own HubSpot checkbox is
wired to One to One, HubSpot's type for 1:1 sales email, so copying its ID
would have filed marketing opt-ins under the wrong subscription. Contact is
left out because its HubSpot form has no consent configuration.
@jadhavgaurav jadhavgaurav changed the title Gated resource downloads, and the Tricorder product page Gated downloads, HubSpot marketing opt-ins, and the Tricorder page (on hold) Sep 17, 2026
The financial-services header still said its sibling stayed
noindex,nofollow and unlisted. That page has launched: production serves
index,follow and the sitemap lists it.
The leads list printed the syncedTo row count under the plural label, so
a fully delivered lead read as "4 Sync attempts", which looks like
retries. The column now shows "4/4 synced", counting skipped steps as
done, turns red when a step failed, and lists each handler on hover.
FormRenderer rendered a form from its CMS definition. Its only caller, the
gated resource download popup, now uses the coded GatedDownloadForm: the CMS
`forms` collection is readable by admins and editors only, so a public page
could never load a definition to render, and the gate fell back to an open
link. Nothing else imports it.

lib/forms.ts held the form definition types and went with it; FormRenderer was
its only importer.

The CSP comment on the Turnstile frame source now names the forms that render
the widget. CLAUDE.md's 16px input rule now points at the shared field surface,
which sets it through --fs-input (1rem), instead of FormRenderer's style.
Successful signups from the newsletter forms now emit newsletter_signup
with form_name newsletter. It is a separate event rather than
generate_lead so subscribers do not inflate the sales-lead count.
Replace the old five-section layout with Lifecycle, Build With What You Can
Verify and Verification You Can Trust. The hero moves to the split layout of
the other product heroes, with an animated SVG scene on the right: CVE chips
dissolve at a CleanStart gate and feed a verified image stack. The trust
section becomes a source-to-signature chain using the site's 3D icon set.
The footer CTA now points to CleanStart Images.
Add a light variant to NewsletterConsent and use it on the Blog Detail and
Guides CTAs, where the light-on-dark copy was unreadable.
New headings, card copy and CTA text across the hero, Bloated, Approach,
Modern and CTA sections. The hero CTA now opens the product video.
gsc-url-audit.ts reports index status and 91-day search performance for a
fixed URL list; gsc-single-inspect.ts inspects one URL. Both run inside the
prod CMS container and write nothing back.
@digibrander
digibrander merged commit 4fa7735 into main Sep 18, 2026
12 checks 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.

3 participants