diff --git a/apps/cms/scripts/seed-website-forms.ts b/apps/cms/scripts/seed-website-forms.ts
index e1f8a46a..d665dc93 100644
--- a/apps/cms/scripts/seed-website-forms.ts
+++ b/apps/cms/scripts/seed-website-forms.ts
@@ -114,7 +114,7 @@ const FORMS: readonly SeedForm[] = [
slug: 'cleansight-campaign',
hubspotFormGuid: 'cf85e3d4-8982-4799-9051-0429c9947866',
name: 'CleanSight Scan Request',
- submitLabel: 'Get my free scan',
+ submitLabel: 'Get My Exposure Report',
fields: [
{ name: 'firstname', type: 'text', label: 'First name', required: true },
{ name: 'lastname', type: 'text', label: 'Last name', required: true },
diff --git a/apps/cms/src/payload/lib/page-registry-seed.ts b/apps/cms/src/payload/lib/page-registry-seed.ts
index 75e166ea..dadd819b 100644
--- a/apps/cms/src/payload/lib/page-registry-seed.ts
+++ b/apps/cms/src/payload/lib/page-registry-seed.ts
@@ -113,6 +113,8 @@ export const PAGE_REGISTRY_SEED: readonly PageRegistrySeedRow[] = [
// brings this file back to being the real source of truth.
{ path: '/industries/financial-services', title: 'Financial Services', kind: 'static', order: 50, webPageType: 'WebPage' },
{ path: '/industries/software-applications', title: 'Software', kind: 'static', order: 51, webPageType: 'WebPage' },
+ { path: '/industries/telecom', title: 'Telecom', kind: 'static', order: 63, webPageType: 'WebPage' },
+ { path: '/resources/state-of-software-dependency-trust', title: 'State of Software Dependency Trust 2026', kind: 'static', order: 64, webPageType: 'WebPage' },
{ path: '/compare/cleanstart-vs-docker-hardened-images', title: 'Docker Hardened Images vs CleanStart', kind: 'static', order: 52, webPageType: 'WebPage' },
// Launched 2026-09-22.
{ path: '/tricorder', title: 'Tricorder', kind: 'static', order: 53, webPageType: 'WebPage' },
diff --git a/apps/web/public/images/resources/dependency-trust/report-page-methodology.webp b/apps/web/public/images/resources/dependency-trust/report-page-methodology.webp
deleted file mode 100644
index ec95d30d..00000000
Binary files a/apps/web/public/images/resources/dependency-trust/report-page-methodology.webp and /dev/null differ
diff --git a/apps/web/public/images/resources/dependency-trust/report-page-part2.webp b/apps/web/public/images/resources/dependency-trust/report-page-part2.webp
deleted file mode 100644
index 47159ad9..00000000
Binary files a/apps/web/public/images/resources/dependency-trust/report-page-part2.webp and /dev/null differ
diff --git a/apps/web/public/images/resources/dependency-trust/report-page-summary.webp b/apps/web/public/images/resources/dependency-trust/report-page-summary.webp
deleted file mode 100644
index e7566d90..00000000
Binary files a/apps/web/public/images/resources/dependency-trust/report-page-summary.webp and /dev/null differ
diff --git a/apps/web/public/images/telecom/hero-telecom-network-v2.webp b/apps/web/public/images/telecom/hero-telecom-network-v2.webp
new file mode 100644
index 00000000..81a14848
Binary files /dev/null and b/apps/web/public/images/telecom/hero-telecom-network-v2.webp differ
diff --git a/apps/web/public/images/telecom/icon-cloud-native.webp b/apps/web/public/images/telecom/icon-cloud-native.webp
new file mode 100644
index 00000000..cc027cc0
Binary files /dev/null and b/apps/web/public/images/telecom/icon-cloud-native.webp differ
diff --git a/apps/web/public/images/telecom/icon-distributed.webp b/apps/web/public/images/telecom/icon-distributed.webp
new file mode 100644
index 00000000..bbbc67d7
Binary files /dev/null and b/apps/web/public/images/telecom/icon-distributed.webp differ
diff --git a/apps/web/public/images/telecom/icon-network-functions.webp b/apps/web/public/images/telecom/icon-network-functions.webp
new file mode 100644
index 00000000..ad1ec12a
Binary files /dev/null and b/apps/web/public/images/telecom/icon-network-functions.webp differ
diff --git a/apps/web/public/images/telecom/icon-open-source.webp b/apps/web/public/images/telecom/icon-open-source.webp
new file mode 100644
index 00000000..602497a0
Binary files /dev/null and b/apps/web/public/images/telecom/icon-open-source.webp differ
diff --git a/apps/web/public/images/telecom/journey/discover-lens.webp b/apps/web/public/images/telecom/journey/discover-lens.webp
new file mode 100644
index 00000000..86d780a0
Binary files /dev/null and b/apps/web/public/images/telecom/journey/discover-lens.webp differ
diff --git a/apps/web/public/images/telecom/journey/discover-platform.webp b/apps/web/public/images/telecom/journey/discover-platform.webp
new file mode 100644
index 00000000..95cb350f
Binary files /dev/null and b/apps/web/public/images/telecom/journey/discover-platform.webp differ
diff --git a/apps/web/public/images/telecom/journey/gear.webp b/apps/web/public/images/telecom/journey/gear.webp
new file mode 100644
index 00000000..4ca5328b
Binary files /dev/null and b/apps/web/public/images/telecom/journey/gear.webp differ
diff --git a/apps/web/public/images/telecom/journey/standardize-base.webp b/apps/web/public/images/telecom/journey/standardize-base.webp
new file mode 100644
index 00000000..bdb3bde4
Binary files /dev/null and b/apps/web/public/images/telecom/journey/standardize-base.webp differ
diff --git a/apps/web/public/images/telecom/journey/standardize-tile.webp b/apps/web/public/images/telecom/journey/standardize-tile.webp
new file mode 100644
index 00000000..7dbf9631
Binary files /dev/null and b/apps/web/public/images/telecom/journey/standardize-tile.webp differ
diff --git a/apps/web/src/app/cleansight-campaign/page.tsx b/apps/web/src/app/cleansight-campaign/page.tsx
index 0fdb8659..8fe58753 100644
--- a/apps/web/src/app/cleansight-campaign/page.tsx
+++ b/apps/web/src/app/cleansight-campaign/page.tsx
@@ -4,6 +4,9 @@ import { JsonLdGraph } from "@/components/JsonLdGraph";
import { BookDemoBody } from "@/components/sections/book-a-demo/BookDemoBody";
import { DemoHero } from "@/components/sections/book-a-demo/DemoHero";
import { ScanBody } from "@/components/sections/cleansight-scan/ScanBody";
+import { ScanFaq } from "@/components/sections/cleansight-scan/ScanFaq";
+import { ScanHowItWorks } from "@/components/sections/cleansight-scan/ScanHowItWorks";
+import { FadeUp } from "@/components/ui/FadeUp";
import { buildPageMetadata } from "@/lib/seo/canonical";
import { getPageGraph } from "@/lib/seo/compose-page";
import { breadcrumbSchema } from "@/lib/seo/jsonld";
@@ -12,10 +15,10 @@ import { breadcrumbSchema } from "@/lib/seo/jsonld";
// noindex until the team signs it off: it overlaps /cleansight and should not
// compete with it in search.
export const metadata = buildPageMetadata({
- title: "Free Container Security Scan | CleanSight",
+ title: "Are You Exposed to PHP2Shell, Brotli2Shell or LiteLLM? | CleanSight",
absoluteTitle: true,
description:
- "Find hidden container risk across your hybrid environment. Request a free CleanSight scan to see inherited vulnerabilities, where they run, and how to fix them.",
+ "PHP2Shell, Brotli2Shell and the LiteLLM compromise hid several layers deep in the components your applications are built on. Request a free CleanSight scan to see where they run and how to fix them.",
path: "/cleansight-campaign",
noindex: true,
});
@@ -35,9 +38,16 @@ export default async function CleanSightScanRequestPage(): Promise
-
+
+
+
+
+
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index f980758d..e1e81e52 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -7042,3 +7042,344 @@ body {
opacity: 0;
}
}
+
+/* Telecom "Need More" backbone (TelecomNetwork): a light pulse runs the signal
+ rail left to right and each node pings as it passes. The pulse layer is the
+ rail's full width with the spot at its centre, so translating it by +/-60%
+ carries the spot from -10% to 110% of the rail. Ping delays in the component
+ are derived from this 4.8s linear timing; change both together.
+ Transform/opacity only; both hidden under reduced motion (static rail stays). */
+@keyframes cs-tel-signal {
+ from {
+ transform: translateX(-60%);
+ }
+
+ to {
+ transform: translateX(60%);
+ }
+}
+
+@keyframes cs-tel-ping {
+ 0% {
+ opacity: 0;
+ transform: scale(0.55);
+ }
+
+ 4% {
+ opacity: 0.9;
+ }
+
+ 30%,
+ 100% {
+ opacity: 0;
+ transform: scale(1.35);
+ }
+}
+
+.cs-tel-signal {
+ animation: cs-tel-signal 4.8s linear infinite;
+}
+
+.cs-tel-ping {
+ opacity: 0;
+ animation: cs-tel-ping 4.8s ease-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cs-tel-signal,
+ .cs-tel-ping {
+ display: none;
+ }
+}
+
+/* Telecom journey art (TelecomJourneyArt): Discover, Standardize, Operate
+ assembled from separate layers. Intros run once on reveal (`[data-on]`),
+ loops are slow, hover is driven by the card (`.cs-tj-card`). Transform and
+ opacity only; everything settles to its final state under reduced motion. */
+.cs-tj-layer {
+ position: absolute;
+ display: block;
+}
+
+/* Discover: the lens drops in, then sweeps; the spot on the platform follows. */
+.cs-tj-lens-in {
+ opacity: 0;
+}
+
+[data-on] .cs-tj-lens-in {
+ animation: cs-tj-drop 0.9s cubic-bezier(0.2, 0.8, 0.2, 1) 0.1s both;
+}
+
+.cs-tj-lens-hover {
+ transition: transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+/* Hover only where a pointer can hover: touch browsers leave :hover stuck after a tap. */
+@media (hover: hover) {
+ .cs-tj-card:hover .cs-tj-lens-hover {
+ transform: translateY(-7%) scale(1.04);
+ }
+}
+
+[data-on] .cs-tj-lens-scan {
+ animation: cs-tj-scan 7s ease-in-out 1s infinite;
+}
+
+.cs-tj-spot {
+ opacity: 0;
+ background: radial-gradient(closest-side, rgba(110, 200, 255, 0.55), rgba(124, 79, 240, 0.18) 60%, rgba(124, 79, 240, 0) 100%);
+}
+
+[data-on] .cs-tj-spot {
+ animation: cs-tj-spot 7s ease-in-out 1s infinite;
+}
+
+@keyframes cs-tj-drop {
+ from {
+ opacity: 0;
+ transform: translateY(-16%);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes cs-tj-scan {
+ 0%,
+ 100% {
+ transform: translate(0, 0) rotate(0deg);
+ }
+
+ 25% {
+ transform: translate(-10%, 5%) rotate(-5deg);
+ }
+
+ 50% {
+ transform: translate(2%, 10%) rotate(2deg);
+ }
+
+ 75% {
+ transform: translate(11%, 4%) rotate(5deg);
+ }
+}
+
+/* Same path as the lens, in the spot's own units (it is ~half the lens's
+ width and a quarter of its height). */
+@keyframes cs-tj-spot {
+ 0%,
+ 100% {
+ opacity: 0.55;
+ transform: translate(0, 0);
+ }
+
+ 25% {
+ opacity: 0.9;
+ transform: translate(-18%, 18%);
+ }
+
+ 50% {
+ opacity: 0.7;
+ transform: translate(4%, 34%);
+ }
+
+ 75% {
+ opacity: 0.9;
+ transform: translate(20%, 14%);
+ }
+}
+
+/* Standardize: scattered and askew, then straightened, then stacked. */
+.cs-tj-tile-in {
+ opacity: 0;
+}
+
+[data-on] .cs-tj-tile-in {
+ animation: cs-tj-standardize 2.6s cubic-bezier(0.22, 0.8, 0.24, 1) var(--d, 0s) both;
+}
+
+.cs-tj-tile-lift {
+ transition: transform 0.65s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+/* Hover only where a pointer can hover: touch browsers leave :hover stuck after a tap. */
+@media (hover: hover) {
+ .cs-tj-card:hover .cs-tj-tile-lift {
+ transform: translateY(var(--lift));
+ }
+}
+
+@keyframes cs-tj-standardize {
+ 0% {
+ opacity: 0;
+ transform: translate(var(--jx), calc(var(--lift) - 30%)) rotate(var(--jr));
+ }
+
+ 24%,
+ 42% {
+ opacity: 1;
+ transform: translate(var(--jx), var(--lift)) rotate(var(--jr));
+ }
+
+ 68% {
+ opacity: 1;
+ transform: translate(0, var(--lift)) rotate(0deg);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+/* Operate: an upright gear turning in its own plane. */
+.cs-tj-gear-in {
+ opacity: 0;
+}
+
+[data-on] .cs-tj-gear-in {
+ animation: cs-tj-rise 1s cubic-bezier(0.2, 0.8, 0.2, 1) 0.15s both;
+}
+
+.cs-tj-gear-plane {
+ transform-style: preserve-3d;
+ transform: rotateY(-34deg) rotateX(4deg);
+}
+
+.cs-tj-gear-boost,
+.cs-tj-gear-spin {
+ transform-style: preserve-3d;
+}
+
+[data-on] .cs-tj-gear-spin {
+ animation: cs-tj-spin 18s linear infinite;
+}
+
+[data-on] .cs-tj-gear-boost {
+ animation: cs-tj-spin 3.2s linear infinite paused;
+}
+
+/* Hover only where a pointer can hover: touch browsers leave :hover stuck after a tap. */
+@media (hover: hover) {
+ .cs-tj-card:hover [data-on] .cs-tj-gear-boost {
+ animation-play-state: running;
+ }
+}
+
+.cs-tj-gear-depth {
+ transform: translateZ(var(--z));
+ filter: brightness(var(--b));
+}
+
+.cs-tj-glow {
+ background: radial-gradient(closest-side, rgba(110, 200, 255, 0.5), rgba(124, 79, 240, 0) 100%);
+ opacity: 0.4;
+}
+
+[data-on] .cs-tj-glow {
+ animation: cs-tj-glow 3.6s ease-in-out infinite;
+}
+
+@keyframes cs-tj-rise {
+ from {
+ opacity: 0;
+ transform: translateY(14%);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes cs-tj-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes cs-tj-glow {
+ 0%,
+ 100% {
+ opacity: 0.35;
+ }
+
+ 50% {
+ opacity: 0.8;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cs-tj-lens-in,
+ .cs-tj-tile-in,
+ .cs-tj-gear-in,
+ [data-on] .cs-tj-lens-in,
+ [data-on] .cs-tj-tile-in,
+ [data-on] .cs-tj-gear-in {
+ animation: none;
+ opacity: 1;
+ transform: none;
+ }
+
+ [data-on] .cs-tj-lens-scan,
+ [data-on] .cs-tj-spot,
+ [data-on] .cs-tj-gear-spin,
+ [data-on] .cs-tj-gear-boost,
+ [data-on] .cs-tj-glow {
+ animation: none;
+ }
+
+ .cs-tj-lens-hover,
+ .cs-tj-tile-lift {
+ transition: none;
+ }
+}
+
+/* Telecom journey fibre links (TelecomJourney): dashes flow toward the next
+ station, and a light packet crosses each link in turn (the second link is delayed
+ half a cycle). The dash strip is one period (12px) wider than the link and
+ slides by exactly one period, so the loop is seamless. Transform/opacity
+ only; static under reduced motion. */
+.cs-tj-link-flow {
+ animation: cs-tj-link-flow 0.9s linear infinite;
+}
+
+.cs-tj-link-packet {
+ opacity: 0;
+ animation: cs-tj-link-packet 3.2s cubic-bezier(0.45, 0, 0.55, 1) var(--d, 0s) infinite;
+}
+
+@keyframes cs-tj-link-flow {
+ to {
+ transform: translateX(12px);
+ }
+}
+
+@keyframes cs-tj-link-packet {
+ 0% {
+ opacity: 0;
+ transform: translateX(0);
+ }
+
+ 8%,
+ 42% {
+ opacity: 1;
+ }
+
+ 50%,
+ 100% {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cs-tj-link-flow {
+ animation: none;
+ }
+
+ .cs-tj-link-packet {
+ display: none;
+ }
+}
diff --git a/apps/web/src/app/industries/telecom/page.tsx b/apps/web/src/app/industries/telecom/page.tsx
new file mode 100644
index 00000000..1c9cfad5
--- /dev/null
+++ b/apps/web/src/app/industries/telecom/page.tsx
@@ -0,0 +1,83 @@
+import type React from 'react';
+import { Header } from '@/components/nav/Header';
+import { Footer } from '@/components/sections/Footer';
+import { FadeUp } from '@/components/ui/FadeUp';
+import { TelecomHero } from '@/components/sections/telecom/TelecomHero';
+import { TelecomNetwork } from '@/components/sections/telecom/TelecomNetwork';
+import { TelecomRisks } from '@/components/sections/telecom/TelecomRisks';
+import { TelecomFoundation } from '@/components/sections/telecom/TelecomFoundation';
+import { TelecomJourney } from '@/components/sections/telecom/TelecomJourney';
+import { TelecomPillars } from '@/components/sections/telecom/TelecomPillars';
+import { TelecomOutcomes } from '@/components/sections/telecom/TelecomOutcomes';
+import { TelecomCTA } from '@/components/sections/telecom/TelecomCTA';
+import { buildPageMetadata } from '@/lib/seo/canonical';
+import { breadcrumbSchema } from '@/lib/seo/jsonld';
+import { JsonLdGraph } from '@/components/JsonLdGraph';
+import { getPageGraph } from '@/lib/seo/compose-page';
+
+/*
+ * /industries/telecom
+ *
+ * Third page under /industries, built from the client's Telecom brief. The H1
+ * is the brief's slogan and carries "Telecom" but not the head term, so the
+ * title follows the siblings' shape ("Container Security for ...") and holds
+ * the keyword for the SERP. Title and description are ours, not the SEO team's:
+ * get them signed off before launch.
+ *
+ * LIVE BUT DRAFT FOR SEARCH (released 2026-11-06): reachable at its URL, but
+ * noindex, absent from the sitemap's STATIC_ROUTES and from the
+ * Solutions > By industry nav. The pageRegistry row exists already (it gates
+ * CI). To launch: drop `noindex`, add the path to STATIC_ROUTES and add the
+ * nav item in nav-config.ts.
+ *
+ * Breadcrumb is Home > Telecom with no Industries crumb, because /industries
+ * has no page (see financial-services/page.tsx).
+ *
+ * Band rhythm: dark hero, white, tinted, DARK, white, tinted, DARK, the same
+ * as the software-applications page, so only one dark run reaches the footer.
+ */
+export const metadata = buildPageMetadata({
+ title: 'Container Security for Telecom Networks | CleanStart',
+ absoluteTitle: true,
+ description:
+ 'Secure cloud-native telecom networks with hardened container images, verified open source libraries, SBOMs, SLSA Level 3 provenance and FIPS 140-3 support.',
+ path: '/industries/telecom',
+ eyebrow: 'Solutions',
+ noindex: true,
+});
+
+export const revalidate = 21600; // 6h ISR fallback; on-demand publish revalidation keeps this fresh
+
+export default async function TelecomPage(): Promise {
+ const graph = await getPageGraph('/industries/telecom', [
+ breadcrumbSchema([{ name: 'Home', path: '/' }, { name: 'Telecom' }]),
+ ]);
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+ >
+ );
+}
diff --git a/apps/web/src/app/resources/[slug]/page.tsx b/apps/web/src/app/resources/[slug]/page.tsx
index 1618e48a..1b426b40 100644
--- a/apps/web/src/app/resources/[slug]/page.tsx
+++ b/apps/web/src/app/resources/[slug]/page.tsx
@@ -5,7 +5,6 @@ import { Footer } from "@/components/sections/Footer";
import { FadeUp } from "@/components/ui/FadeUp";
import { ResourceDetailHero } from "@/components/sections/resource/ResourceDetailHero";
import { ResourceDetailContent } from "@/components/sections/resource/ResourceDetailContent";
-import { BESPOKE_RESOURCE_LANDINGS } from "@/components/sections/resource/bespoke-landings";
import {
getResourceBySlug,
getResourceBySlugDraft,
@@ -122,16 +121,6 @@ export async function renderResourceDetail({
/>
);
- const Landing = BESPOKE_RESOURCE_LANDINGS[resource.slug];
- if (Landing) {
- return (
- <>
- {jsonLd}
-
- >
- );
- }
-
const highlightedBody = await highlightLexical(resource.body ?? null).then(resolveLexicalVideos);
const resourceWithHighlighted = { ...resource, body: highlightedBody ?? null };
diff --git a/apps/web/src/app/resources/state-of-software-dependency-trust/page.tsx b/apps/web/src/app/resources/state-of-software-dependency-trust/page.tsx
new file mode 100644
index 00000000..04d7d76f
--- /dev/null
+++ b/apps/web/src/app/resources/state-of-software-dependency-trust/page.tsx
@@ -0,0 +1,62 @@
+import { JsonLdGraph } from "@/components/JsonLdGraph";
+import { DependencyTrustLanding } from "@/components/sections/resource/dependency-trust/DependencyTrustLanding";
+import type { ReportGateConfig } from "@/components/sections/resource/dependency-trust/ReportGate";
+import { buildPageMetadata, SITE_URL } from "@/lib/seo/canonical";
+import { getPageGraph } from "@/lib/seo/compose-page";
+import { articleSchema, breadcrumbSchema, breadcrumbTrail } from "@/lib/seo/jsonld";
+
+const PATH = "/resources/state-of-software-dependency-trust";
+const REPORT_TITLE = "State of Software Dependency Trust 2026";
+const COVER_PATH = "/images/resources/dependency-trust/report-cover.webp";
+const DESCRIPTION =
+ "Research on open source dependency risk: transitive sprawl, aging components, upstream attacks, AI-selected packages and verified software foundations.";
+
+/**
+ * The report's record and the form that gates its download, as they exist in
+ * the production CMS. Only these two ids and the slug cross over: the page copy,
+ * design and metadata are all in code. The download link itself is still minted
+ * server-side from the record when the form is submitted.
+ */
+const GATE: ReportGateConfig = {
+ resourceId: "37",
+ resourceTitle: REPORT_TITLE,
+ resourceSlug: "state-of-software-dependency-trust",
+ gateFormId: 11,
+};
+
+/**
+ * Live but noindex: the page is reachable and its form works, but it is kept out
+ * of search and out of the sitemap until the title and description are signed
+ * off and the report's CMS record is published. To launch for search, drop
+ * `noindex` here.
+ */
+export const metadata = buildPageMetadata({
+ title: `${REPORT_TITLE} Report`,
+ description: DESCRIPTION,
+ path: PATH,
+ eyebrow: "Resources",
+ type: "article",
+ noindex: true,
+});
+
+export const revalidate = 21600;
+
+export default async function DependencyTrustReportPage(): Promise {
+ const graph = await getPageGraph(PATH, [
+ breadcrumbSchema(breadcrumbTrail("resource", { title: REPORT_TITLE })),
+ articleSchema({
+ title: REPORT_TITLE,
+ description: DESCRIPTION,
+ path: PATH,
+ imageUrl: `${SITE_URL}${COVER_PATH}`,
+ type: "Report",
+ }),
+ ]);
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/web/src/components/sections/book-a-demo/DemoHero.tsx b/apps/web/src/components/sections/book-a-demo/DemoHero.tsx
index a1839877..5eb6553f 100644
--- a/apps/web/src/components/sections/book-a-demo/DemoHero.tsx
+++ b/apps/web/src/components/sections/book-a-demo/DemoHero.tsx
@@ -6,7 +6,13 @@ import { HeroReveal } from "@/components/ui/Reveal";
* Dark gradient hero band for the Book a Demo page. A centred "Book a Demo"
* title (with a gradient "Demo" word) flanked by two iridescent cube images.
*/
-export function DemoHero({ title = "Book a Demo" }: { title?: ReactNode }): React.ReactElement {
+export function DemoHero({
+ title = "Book a Demo",
+ subtitle,
+}: {
+ title?: ReactNode;
+ subtitle?: ReactNode;
+}): React.ReactElement {
return (
{/* Background gradient — bleeds beyond the 1440 viewport on each side. */}
@@ -92,6 +98,22 @@ export function DemoHero({ title = "Book a Demo" }: { title?: ReactNode }): Reac
>
{title}
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
diff --git a/apps/web/src/components/sections/cleansight-scan/ScanBody.tsx b/apps/web/src/components/sections/cleansight-scan/ScanBody.tsx
index de49c471..3a07bbb0 100644
--- a/apps/web/src/components/sections/cleansight-scan/ScanBody.tsx
+++ b/apps/web/src/components/sections/cleansight-scan/ScanBody.tsx
@@ -1,152 +1,148 @@
-import type { ReactNode } from "react";
-
import { ShieldCheckIcon } from "@/components/sections/book-a-demo/WhatsSetsUsApart";
import { ScanRequestForm } from "@/components/sections/forms/ScanRequestForm";
-/*
- * Body of the CleanSight campaign page. The copy is the old Webflow page's,
- * unchanged apart from the em-dash the old copy used. Lists use the shielded
- * bullet from Book a Demo; the form sits beside the copy, as it did in Webflow.
- */
+import { CONTAINER_STYLE, SectionHeading, TERM_STYLE, TEXT_STYLE } from "./scan-ui";
-const IDENTIFIES: readonly string[] = [
- "Container inventory",
- "Inherited vulnerabilities",
- "Dependencies",
- "Unsupported images",
- "License exposure",
- "Remediation paths",
+const INCIDENTS: readonly { name: string; detail: string }[] = [
+ {
+ name: "PHP2Shell",
+ detail: "A flaw in the PHP runtime itself. Updating your website or plugins leaves it in place.",
+ },
+ {
+ name: "Brotli2Shell",
+ detail:
+ "A flaw in a compression library that comes built into other software, including Node.js and Python tools. You can be affected without ever installing it directly.",
+ },
+ {
+ name: "LiteLLM",
+ detail:
+ "A tampered release of a popular AI library that stole credentials, even from applications that never used it.",
+ },
];
-const RECEIVES: readonly { term: string; detail: string }[] = [
+const ANSWERS: readonly { number: string; title: string; detail: string }[] = [
{
- term: "Complete Runtime Visibility:",
- detail: "Know exactly what's running in production: every container across every cluster.",
+ number: "01",
+ title: "Where affected software is running",
+ detail: "Trace vulnerable images to the applications, workloads and environments using them.",
},
{
- term: "Risk Visibility Reports:",
+ number: "02",
+ title: "What’s live in production",
detail:
- "Prioritize risks by severity with added runtime context, including how many running containers are affected.",
+ "See which affected images are actually running across cloud and data center environments.",
},
{
- term: "Vulnerable Image Identification:",
+ number: "03",
+ title: "What to do next",
detail:
- "Identify which container images contain vulnerabilities, where they're deployed, and which teams own them.",
+ "Get clear remediation guidance and access to hardened, patched images that are ready to deploy.",
+ },
+];
+
+const REPORT: readonly { term: string; detail: string }[] = [
+ {
+ term: "A full container inventory",
+ detail: "Every image running across AWS, GCP, Azure and your data centers, in one view.",
},
{
- term: "Actionable Remediation Recommendations:",
+ term: "Vulnerabilities, ranked by real risk",
+ detail: "Prioritized by severity and by how many running containers each one affects.",
+ },
+ {
+ term: "Complete Runtime Visibility",
+ detail: "Know exactly what's running in production: every container across every cluster.",
+ },
+ {
+ term: "Actionable Remediation Recommendations",
detail: "Get immediate remediation guidance to eliminate risks from vulnerable containers.",
},
];
-const DIFFERENTIATION: readonly string[] = [
- "Trusted replacement images",
- "Built from source",
- "Reproducible builds",
- "Minimal attack surface",
- "Verifiable software foundations",
-];
-
-const TEXT_STYLE: React.CSSProperties = {
- fontFamily: "var(--font-sans), 'Sora', sans-serif",
- fontWeight: 400,
- fontSize: "var(--fs-body)",
- lineHeight: 1.5,
- letterSpacing: "-0.04em",
- color: "#111111",
- opacity: 0.8,
-};
-
-function Heading({ children }: { children: ReactNode }): React.ReactElement {
- return (
-
- Containers don't get built from scratch, they inherit layers of open source
- packages, base images, and dependencies. Most organizations have no clear view into
- what vulnerabilities came along for the ride, where they're running, or who's
- responsible for fixing them.
+ Why “Are we exposed?” is hard to answer
+
+ Containers are assembled from base images, open source packages and dependencies, each
+ with its own layers. A vulnerability can arrive through any of them, which makes it
+ hard to know what you’re actually running.
+
- Knowing you have vulnerable containers is not the same as being able to do something
- about it. Many organizations have invested heavily in visibility tooling only to find
- themselves with dashboards full of findings and no clear path to resolution.
-
- Security teams need more than a list. They need{" "}
- remediation guidance, specific,
- prioritized actions that tell them which vulnerable containers to remove first, how to
- reduce exposure quickly, and how to fix it. Without that, findings pile up, fatigue
- sets in, and real risk goes unaddressed.
+ Finding these by hand means checking every image, in every environment, one at a time.
diff --git a/apps/web/src/components/sections/cleansight-scan/ScanFaq.tsx b/apps/web/src/components/sections/cleansight-scan/ScanFaq.tsx
new file mode 100644
index 00000000..645a353e
--- /dev/null
+++ b/apps/web/src/components/sections/cleansight-scan/ScanFaq.tsx
@@ -0,0 +1,49 @@
+import { PlusIcon } from "lucide-react";
+
+import { CONTAINER_STYLE, SectionHeading, TERM_STYLE, TEXT_STYLE } from "./scan-ui";
+
+const FAQS: readonly { question: string; answer: string }[] = [
+ { question: "Is the scan really free?", answer: "Yes." },
+ { question: "Do I need to install anything?", answer: "No agents are required." },
+ {
+ question: "What happens after the scan?",
+ answer:
+ "You keep the report. If you’d like help acting on it, our team can connect with you to assist further.",
+ },
+ {
+ question: "Does the scan fix a LiteLLM credential leak?",
+ answer:
+ "No. If any environment ran the compromised LiteLLM release, rotate every secret it could reach. Replacing the image removes the malicious code, but it can’t undo credentials that were already stolen.",
+ },
+];
+
+export function ScanFaq(): React.ReactElement {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/sections/cleansight-scan/ScanHowItWorks.tsx b/apps/web/src/components/sections/cleansight-scan/ScanHowItWorks.tsx
new file mode 100644
index 00000000..41d4c2c8
--- /dev/null
+++ b/apps/web/src/components/sections/cleansight-scan/ScanHowItWorks.tsx
@@ -0,0 +1,96 @@
+import { CalendarCheckIcon, ClipboardCheckIcon, FileSearchIcon } from "lucide-react";
+import { Fragment, type ComponentType } from "react";
+
+import { FlowArrow } from "@/components/ui/FlowArrow";
+import { Reveal } from "@/components/ui/Reveal";
+
+import { CONTAINER_STYLE } from "./scan-ui";
+
+type Step = {
+ title: string;
+ detail: string;
+ Icon: ComponentType<{ className?: string; strokeWidth?: number; "aria-hidden"?: boolean }>;
+};
+
+const STEPS: readonly Step[] = [
+ { title: "Request your scan", detail: "Fill in the short form.", Icon: ClipboardCheckIcon },
+ {
+ title: "We set it up with you",
+ detail: "Our security experts contact you to arrange the scan.",
+ Icon: CalendarCheckIcon,
+ },
+ {
+ title: "Review your results together",
+ detail: "We walk you through where you’re exposed and how to fix it.",
+ Icon: FileSearchIcon,
+ },
+];
+
+/*
+ * Dark purple band in the style of the home page's ProcessBand, without the
+ * photo: white line icons, white titles, dotted FlowArrow between steps.
+ */
+export function ScanHowItWorks(): React.ReactElement {
+ return (
+
+
+ );
+}
+
+export const TERM_STYLE: CSSProperties = {
+ fontFamily: "var(--font-display), 'Manrope', sans-serif",
+ fontWeight: 600,
+ fontSize: "var(--fs-body)",
+ lineHeight: 1.3,
+ letterSpacing: "-0.03em",
+ color: "#0F123E",
+};
diff --git a/apps/web/src/components/sections/financial-services/FinanceCTA.tsx b/apps/web/src/components/sections/financial-services/FinanceCTA.tsx
index 66c204b2..4cc9870f 100644
--- a/apps/web/src/components/sections/financial-services/FinanceCTA.tsx
+++ b/apps/web/src/components/sections/financial-services/FinanceCTA.tsx
@@ -1,235 +1,20 @@
+import type React from 'react';
+import { IndustryCTA } from '@/components/sections/industries/IndustryCTA';
+
/*
- * Financial services CTA — the white card rendered in the Footer's locked CTA
- * slot, on the site's shared CTA treatment: decorative purple grid, corner glow
- * ellipses, a violet cube, dark text and a solid blue button.
- *
- * Layout matches FipsCTA and VulnCTA: headline left, supporting line and button
- * stacked on the right, both columns vertically centred in the slot.
- *
- * NOTE — DESCRIPTION is the one string on this page not taken from the client
+ * DESCRIPTION is the one string on this page not taken from the client
* proposal, which supplies a headline and a button for this card and nothing
- * else. Without it the content block came to 89px in a 260px card, so
- * everything hugged the top and left 164px of dead space with the decorative
- * cube stranded in it. It is written to set up the conversation the button
- * promises rather than to restate the headline. Flag it for client sign-off.
+ * else. Without it the content block came to 89px in a 260px card and left the
+ * cube stranded in dead space. Flag it for client sign-off.
*/
-
-'use client';
-
-import Link from 'next/link';
-import { Reveal } from '@/components/ui/Reveal';
-
-const HEADLINE = 'Build Trusted Financial Software with CleanStart';
-const DESCRIPTION =
- 'Talk through your compliance requirements and see how verified components fit your existing stack.';
-const BUTTON_LABEL = 'Talk to a Security Expert';
-const BUTTON_HREF = '/contact-us';
-
export function FinanceCTA(): React.ReactElement {
return (
-
- {/* Decorative radial-faded purple grid. */}
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
- {/* Ellipse glow — top-left. */}
-
-
-
- {/* Ellipse glow — bottom-right. */}
-
-
-
- {/* Decorative violet cube — bottom-RIGHT corner. It used to sit
- bottom-left, where it ran under the headline's last line. The right
- side of the card is clear below the button, so the cube reads as
- decoration there instead of as something behind the text. */}
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
- {/* Smaller cube for the stacked layout. `lg:hidden` rather than
- `md:hidden`: the large cube above is `lg:block`, so gating this one at
- md left 768-1023px with no cube at all. */}
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
- {/*
- * One content block across all widths, on the same grid LibrariesCTA and
- * the other product CTAs use: stacked and centred below lg, two columns
- * above it.
- *
- * `lg:items-start` is the point of the grid. The previous flex row centred
- * each column on its own axis, and the right column is taller (three lines
- * plus a button), so the headline settled below the supporting line's
- * first line and the two read as misaligned. Top-aligning the columns and
- * centring the grid as a whole (`lg:content-center`) puts their first
- * lines on one baseline.
- *
- * This also replaces a duplicated mobile block that repeated the headline,
- * description and button in the DOM at every width.
- */}
-
- Get your free container security scan
+ Find Out If You’re Exposed
- See your risks. Get expert insights. Take action with confidence.
+ Know where you’re affected, what’s running in production, and what to do next.
@@ -285,7 +285,7 @@ export function ScanRequestForm(): React.ReactElement {
- Get my free scan
+ Get My Exposure Report
diff --git a/apps/web/src/components/sections/industries/IndustryCTA.tsx b/apps/web/src/components/sections/industries/IndustryCTA.tsx
new file mode 100644
index 00000000..7b8eb826
--- /dev/null
+++ b/apps/web/src/components/sections/industries/IndustryCTA.tsx
@@ -0,0 +1,237 @@
+/*
+ * The industry pages' CTA card, rendered in the Footer's locked CTA slot on the
+ * site's shared CTA treatment: decorative purple grid, corner glow ellipses, a
+ * violet cube, dark text and a solid blue button. Headline left, supporting
+ * line and button stacked on the right (same layout as FipsCTA and VulnCTA).
+ *
+ * Shared by every /industries/* page; each page passes only its own copy.
+ */
+
+'use client';
+
+import Link from 'next/link';
+import { Reveal } from '@/components/ui/Reveal';
+
+interface IndustryCTAProps {
+ /** Value for `data-section`, so each page's card stays addressable in tests and screenshots. */
+ section: string;
+ headline: string;
+ description: string;
+ buttonLabel: string;
+ buttonHref: string;
+}
+
+export function IndustryCTA({
+ section,
+ headline,
+ description,
+ buttonLabel,
+ buttonHref,
+}: IndustryCTAProps): React.ReactElement {
+ return (
+
+ {/* Decorative radial-faded purple grid. */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ {/* Ellipse glow, top-left. */}
+
+
+
+ {/* Ellipse glow, bottom-right. */}
+
+
+
+ {/* Decorative violet cube, bottom-RIGHT corner. It used to sit
+ bottom-left, where it ran under the headline's last line. The right
+ side of the card is clear below the button, so the cube reads as
+ decoration there instead of as something behind the text. */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ {/* Smaller cube for the stacked layout. `lg:hidden` rather than
+ `md:hidden`: the large cube above is `lg:block`, so gating this one at
+ md left 768-1023px with no cube at all. */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ {/*
+ * One content block across all widths, on the same grid LibrariesCTA and
+ * the other product CTAs use: stacked and centred below lg, two columns
+ * above it.
+ *
+ * `lg:items-start` is the point of the grid. The previous flex row centred
+ * each column on its own axis, and the right column is taller (three lines
+ * plus a button), so the headline settled below the supporting line's
+ * first line and the two read as misaligned. Top-aligning the columns and
+ * centring the grid as a whole (`lg:content-center`) puts their first
+ * lines on one baseline.
+ *
+ * This also replaces a duplicated mobile block that repeated the headline,
+ * description and button in the DOM at every width.
+ */}
+
+
+
+ {headline}
+
+
+
+
+
+ {description}
+
+
+
+ {buttonLabel}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/resource/bespoke-landings.ts b/apps/web/src/components/sections/resource/bespoke-landings.ts
deleted file mode 100644
index 1dedc0b0..00000000
--- a/apps/web/src/components/sections/resource/bespoke-landings.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { ResourceDetail } from "@/lib/resources";
-
-import { DependencyTrustLanding } from "./dependency-trust/DependencyTrustLanding";
-
-/**
- * Resources that get a designed landing page in place of the standard detail
- * template, keyed by slug. The record stays in the CMS (listing card, SEO,
- * gate form, download count); only the body of its page is replaced.
- */
-export const BESPOKE_RESOURCE_LANDINGS: Readonly<
- Record React.ReactElement>
-> = {
- "state-of-software-dependency-trust": DependencyTrustLanding,
-};
diff --git a/apps/web/src/components/sections/resource/dependency-trust/AboutCleanStart.tsx b/apps/web/src/components/sections/resource/dependency-trust/AboutCleanStart.tsx
index f31a8b78..1e16ccc1 100644
--- a/apps/web/src/components/sections/resource/dependency-trust/AboutCleanStart.tsx
+++ b/apps/web/src/components/sections/resource/dependency-trust/AboutCleanStart.tsx
@@ -1,96 +1,178 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowRight, ArrowUpRight } from "lucide-react";
+import type { CSSProperties } from "react";
-import { Container, Section } from "@/components/layout";
-import { FadeUp } from "@/components/ui/FadeUp";
+import { Container } from "@/components/layout";
+import { Reveal } from "@/components/ui/Reveal";
import { ABOUT_CLEANSTART } from "./content";
+import { DARK_BAND, GradientTitle } from "./parts";
+/** Same sizing as the hero call to action, so the button reads as the site's own. */
+const PORTAL_BUTTON_VARS = {
+ "--cs-btn-px": "22px",
+ "--cs-btn-fs": "16px",
+ "--cs-btn-h": "3rem",
+} as CSSProperties;
+
+/**
+ * The last block before the footer. Its bottom padding is the card-overlap
+ * reservation the footer's CTA slot needs, painted by the same band so the card
+ * sits on the violet gradient, as on the other pages.
+ *
+ * The three products share one lit panel: each emblem stands on its own glow,
+ * a thin line runs behind them to say they are one platform, and the whole
+ * column is the link.
+ */
export function AboutCleanStart(): React.ReactElement {
return (
-
-
-
-
+
+ {/* A native disclosure: keyboard and screen-reader accessible with no
+ script, and the text stays in the page for search engines. */}
+
+
+ {ABOUT_RESEARCH.sourcesButton}
+
-
-
-
-
-
+ size={18}
+ strokeWidth={2}
+ className="shrink-0 transition-transform duration-300 group-open:rotate-180 motion-reduce:transition-none"
+ />
+
- {/* A native disclosure: keyboard and screen-reader accessible with no
- script, and the text stays in the page for search engines. */}
-
-
- {ABOUT_RESEARCH.sourcesButton}
-
-
+
+
+
+
);
}
diff --git a/apps/web/src/components/sections/resource/dependency-trust/DependencyTrustHero.tsx b/apps/web/src/components/sections/resource/dependency-trust/DependencyTrustHero.tsx
index c33a37f7..498317a8 100644
--- a/apps/web/src/components/sections/resource/dependency-trust/DependencyTrustHero.tsx
+++ b/apps/web/src/components/sections/resource/dependency-trust/DependencyTrustHero.tsx
@@ -1,7 +1,6 @@
import Image from "next/image";
import { HeroReveal } from "@/components/ui/Reveal";
-import type { ResourceDetail } from "@/lib/resources";
import { HERO } from "./content";
import { ReportDownloadButton } from "./ReportDownloadButton";
@@ -69,11 +68,7 @@ function ReportStack(): React.ReactElement {
);
}
-export function DependencyTrustHero({
- resource,
-}: {
- resource: ResourceDetail;
-}): React.ReactElement {
+export function DependencyTrustHero(): React.ReactElement {
return (
-
-
- {HERO.eyebrow}
-
-
-
-
+
diff --git a/apps/web/src/components/sections/resource/dependency-trust/DependencyTrustLanding.tsx b/apps/web/src/components/sections/resource/dependency-trust/DependencyTrustLanding.tsx
index 87f9f4aa..89c36db8 100644
--- a/apps/web/src/components/sections/resource/dependency-trust/DependencyTrustLanding.tsx
+++ b/apps/web/src/components/sections/resource/dependency-trust/DependencyTrustLanding.tsx
@@ -1,57 +1,35 @@
import { Header } from "@/components/nav/Header";
import { Footer } from "@/components/sections/Footer";
-import { resolveResourceGate } from "@/lib/resource-gate";
-import type { ResourceDetail } from "@/lib/resources";
import { AboutCleanStart } from "./AboutCleanStart";
import { AboutResearch } from "./AboutResearch";
import { DependencyTrustHero } from "./DependencyTrustHero";
-import { DownloadBand } from "./DownloadBand";
import { ExecutiveSummary } from "./ExecutiveSummary";
import { FindingsGlance } from "./FindingsGlance";
import { LeadershipQuote } from "./LeadershipQuote";
-import { ReportGateProvider } from "./ReportGate";
+import { ReportCta } from "./ReportCta";
+import { type ReportGateConfig, ReportGateProvider } from "./ReportGate";
import { ResearchFindings } from "./ResearchFindings";
/**
- * The State of Software Dependency Trust 2026 landing page. It lives at the
- * resource's own URL, so the Resource Center card, the sitemap entry, the SEO
- * fields, the download count and the thank-you email all stay with the CMS
- * record. Only the page body is bespoke.
+ * The State of Software Dependency Trust 2026 landing page. It is a static page
+ * in the web app: the copy lives in `content.ts`, and the only outside facts it
+ * needs are the ids of the report and the form that gates its download.
*/
-export function DependencyTrustLanding({
- resource,
-}: {
- resource: ResourceDetail;
-}): React.ReactElement {
- const { gated, gateFormId } = resolveResourceGate(resource);
-
- const page = (
- <>
+export function DependencyTrustLanding({ gate }: { gate: ReportGateConfig }): React.ReactElement {
+ return (
+
-
+
-
+
-
-
- >
- );
-
- if (!gated || gateFormId === null) return page;
- return (
-
- {page}
+ } />
);
}
diff --git a/apps/web/src/components/sections/resource/dependency-trust/DownloadBand.tsx b/apps/web/src/components/sections/resource/dependency-trust/DownloadBand.tsx
deleted file mode 100644
index 80dffb17..00000000
--- a/apps/web/src/components/sections/resource/dependency-trust/DownloadBand.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import Image from "next/image";
-
-import { Section } from "@/components/layout";
-import { FadeUp } from "@/components/ui/FadeUp";
-import type { ResourceDetail } from "@/lib/resources";
-
-import { DOWNLOAD } from "./content";
-import { ReportDownloadButton } from "./ReportDownloadButton";
-
-const PAGE_CLASS =
- "absolute overflow-hidden rounded-[8px] bg-white ring-1 ring-white/25 shadow-[0_-18px_48px_-14px_rgba(7,6,40,0.6)]";
-
-/**
- * The last call to action. The report's pages rise from the bottom edge of the
- * band and are cut off by it, so the report reads as something still to open.
- */
-export function DownloadBand({ resource }: { resource: ResourceDetail }): React.ReactElement {
- return (
-
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
-
-
- {DOWNLOAD.heading}
-
-
- {DOWNLOAD.body}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/sections/resource/dependency-trust/ExecutiveSummary.tsx b/apps/web/src/components/sections/resource/dependency-trust/ExecutiveSummary.tsx
index 37c659c9..eb66437e 100644
--- a/apps/web/src/components/sections/resource/dependency-trust/ExecutiveSummary.tsx
+++ b/apps/web/src/components/sections/resource/dependency-trust/ExecutiveSummary.tsx
@@ -1,104 +1,108 @@
import Image from "next/image";
+import type { CSSProperties } from "react";
-import { Container, Section } from "@/components/layout";
-import { FadeUp } from "@/components/ui/FadeUp";
+import { Reveal } from "@/components/ui/Reveal";
import { EXECUTIVE_SUMMARY } from "./content";
+import { GradientTitle, HAIRLINE_CARD, LightSection } from "./parts";
-const BODY_STYLE = {
+const BODY_STYLE: CSSProperties = {
fontFamily: "var(--font-sans)",
fontSize: "var(--fs-body)",
- lineHeight: "var(--fs-body-lh)",
- color: "rgba(17, 17, 17, 0.8)",
-} as const;
+ fontWeight: 400,
+ letterSpacing: "-0.02em",
+ lineHeight: 1.5,
+ color: "#444444",
+};
+
+/** The paragraph's own first sentence, so it can lead without any new copy. */
+function splitLead(paragraph: string): { lead: string; rest: string } {
+ const match = /^(.+?[.:])\s+(.+)$/s.exec(paragraph);
+ const lead = match?.[1];
+ const rest = match?.[2];
+ return lead !== undefined && rest !== undefined ? { lead, rest } : { lead: paragraph, rest: "" };
+}
export function ExecutiveSummary(): React.ReactElement {
- const [lead, ...rest] = EXECUTIVE_SUMMARY.paragraphs;
+ const [opening, ...points] = EXECUTIVE_SUMMARY.paragraphs;
+ const { lead, rest } = splitLead(opening);
return (
-
-
-
-
+ );
+}
+
+/**
+ * Findings 01 to 04 sit in an offset grid; finding 05, which is the report's
+ * conclusion, gets the dark full-width card so it reads as the payoff.
+ */
+export function ResearchFindings(): React.ReactElement {
+ const supporting = FINDINGS.items.slice(0, SPANS.length);
+ const conclusion = FINDINGS.items[SPANS.length];
-export function ResearchFindings({ resource }: { resource: ResourceDetail }): React.ReactElement {
return (
-
-
-
-
- }
- />
-
+
+
+
+
-
- {FINDINGS.items.map((finding) => (
-
- {/* The article stays outside FadeUp: FadeUp swaps its wrapper element for
- content below the fold, which would replace the node the rail watches. */}
-
-
+
+ );
+}
+
+interface DarkSectionProps {
+ dataSection: string;
+ children: ReactNode;
+}
+
+/**
+ * A violet-navy band for sections that sit between two light ones, with the same
+ * container and padding as `LightSection` so the page keeps one rhythm.
+ */
+export function DarkSection({ dataSection, children }: DarkSectionProps): React.ReactElement {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+/** Frosted card with the gradient edge the site uses on dark bands. */
+export const GLASS_CARD: CSSProperties = {
+ background:
+ "linear-gradient(155deg, rgba(255,255,255,0.07) 0%, rgba(255,255,255,0.025) 48%, rgba(255,255,255,0.05) 100%)",
+ backdropFilter: "blur(14px)",
+ WebkitBackdropFilter: "blur(14px)",
+ boxShadow: "0 40px 90px -30px rgba(8,4,24,0.7), inset 0 1px 0 rgba(255,255,255,0.10)",
+};
diff --git a/apps/web/src/components/sections/saas/SaasCTA.tsx b/apps/web/src/components/sections/saas/SaasCTA.tsx
index b23dc1d8..ed9ad053 100644
--- a/apps/web/src/components/sections/saas/SaasCTA.tsx
+++ b/apps/web/src/components/sections/saas/SaasCTA.tsx
@@ -1,231 +1,15 @@
-/*
- * Financial services CTA — the white card rendered in the Footer's locked CTA
- * slot, on the site's shared CTA treatment: decorative purple grid, corner glow
- * ellipses, a violet cube, dark text and a solid blue button.
- *
- * Layout matches FipsCTA and VulnCTA: headline left, supporting line and button
- * stacked on the right, both columns vertically centred in the slot.
- *
- * Unlike its financial-services sibling, every string here is the proposal's:
- * it supplies a headline, a supporting line and a button label for this card.
- */
-
-'use client';
-
-import Link from 'next/link';
-import { Reveal } from '@/components/ui/Reveal';
-
-const HEADLINE = 'Secure Your Next Release Before It Ships';
-const DESCRIPTION =
- 'Give developers verified software components that integrate into existing workflows.';
-const BUTTON_LABEL = 'Talk to an Expert';
-const BUTTON_HREF = '/contact-us';
+import type React from 'react';
+import { IndustryCTA } from '@/components/sections/industries/IndustryCTA';
+/* Every string here is the proposal's: headline, supporting line and button. */
export function SaasCTA(): React.ReactElement {
return (
-
- {/* Decorative radial-faded purple grid. */}
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
- {/* Ellipse glow — top-left. */}
-
-
-
- {/* Ellipse glow — bottom-right. */}
-
-
-
- {/* Decorative violet cube — bottom-RIGHT corner. It used to sit
- bottom-left, where it ran under the headline's last line. The right
- side of the card is clear below the button, so the cube reads as
- decoration there instead of as something behind the text. */}
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
- {/* Smaller cube for the stacked layout. `lg:hidden` rather than
- `md:hidden`: the large cube above is `lg:block`, so gating this one at
- md left 768-1023px with no cube at all. */}
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
- {/*
- * One content block across all widths, on the same grid LibrariesCTA and
- * the other product CTAs use: stacked and centred below lg, two columns
- * above it.
- *
- * `lg:items-start` is the point of the grid. The previous flex row centred
- * each column on its own axis, and the right column is taller (three lines
- * plus a button), so the headline settled below the supporting line's
- * first line and the two read as misaligned. Top-aligning the columns and
- * centring the grid as a whole (`lg:content-center`) puts their first
- * lines on one baseline.
- *
- * This also replaces a duplicated mobile block that repeated the headline,
- * description and button in the DOM at every width.
- */}
-
-
-
- {HEADLINE}
-
-
-
-
-
- {DESCRIPTION}
-
-
-
- {BUTTON_LABEL}
-
-
-
-
+
);
}
diff --git a/apps/web/src/components/sections/telecom/TelecomCTA.tsx b/apps/web/src/components/sections/telecom/TelecomCTA.tsx
new file mode 100644
index 00000000..4543a6a6
--- /dev/null
+++ b/apps/web/src/components/sections/telecom/TelecomCTA.tsx
@@ -0,0 +1,15 @@
+import type React from 'react';
+import { IndustryCTA } from '@/components/sections/industries/IndustryCTA';
+
+/* Headline, supporting line and button are the brief's, verbatim. */
+export function TelecomCTA(): React.ReactElement {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/sections/telecom/TelecomFoundation.tsx b/apps/web/src/components/sections/telecom/TelecomFoundation.tsx
new file mode 100644
index 00000000..b57d2362
--- /dev/null
+++ b/apps/web/src/components/sections/telecom/TelecomFoundation.tsx
@@ -0,0 +1,190 @@
+import type React from 'react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { ArrowRight } from 'lucide-react';
+import { Container, Section } from '@/components/layout';
+import { Reveal, RevealItem, RevealStagger } from '@/components/ui/Reveal';
+import { H2_STYLE, LEAD_STYLE } from './type';
+
+/*
+ * "Build with Verified Software Foundations": the page's mid dark band and the
+ * answer to the risk register above it, one card per live product.
+ *
+ * Renders are the shared product set under /images/financial-services/ (each
+ * carries its own product mark; see FinanceFoundation for how they were made).
+ * They have a real alpha channel, so they sit on glass cards here instead of
+ * the white cards the light-band versions use.
+ *
+ * The whole card is the link, not just its footer: three large targets that go
+ * to three product pages, with nothing else inside to click.
+ *
+ * Copy is the brief's. The brief labels the third card's button "Clean Images",
+ * an evident slip for "Clean Libraries"; the link goes to /clean-libraries.
+ */
+
+interface Product {
+ image: string;
+ imageAlt: string;
+ name: string;
+ tagline: string;
+ href: string;
+}
+
+const PRODUCTS: readonly [Product, Product, Product] = [
+ {
+ image: '/images/financial-services/card-cleansight-v4.webp',
+ imageAlt:
+ 'The CleanSight dashboard showing discovered assets across containers, images and repositories, with a magnifying glass over a verified component',
+ name: 'CleanSight',
+ tagline: 'Discover software assets and hidden risks across environments.',
+ href: '/cleansight',
+ },
+ {
+ image: '/images/financial-services/card-clean-images-v4.webp',
+ imageAlt: 'A shipping container badged with the Clean Images product logo',
+ name: 'Clean Images',
+ tagline: 'Hardened, minimal, and secure container images for your applications.',
+ href: '/cleanstart-images',
+ },
+ {
+ image: '/images/financial-services/card-clean-libraries-v4.webp',
+ imageAlt: 'Stacked library modules badged with the Clean Libraries product logo',
+ name: 'Clean Libraries',
+ tagline: 'Secure, trusted libraries and dependencies for your applications.',
+ href: '/clean-libraries',
+ },
+];
+
+export function TelecomFoundation(): React.ReactElement {
+ return (
+
+
+ Secure your telecommunications infrastructure with visibility, hardened images and
+ verified software dependencies.
+
+
+
+
+
+ {PRODUCTS.map((p) => (
+
+
+
+
+
+
+
+
+
+
+
+ {p.name}
+
+
+ {p.tagline}
+
+
+ Explore {p.name}
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/telecom/TelecomHero.tsx b/apps/web/src/components/sections/telecom/TelecomHero.tsx
new file mode 100644
index 00000000..e853fc9f
--- /dev/null
+++ b/apps/web/src/components/sections/telecom/TelecomHero.tsx
@@ -0,0 +1,118 @@
+import type React from 'react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { HeroReveal } from '@/components/ui/Reveal';
+
+/*
+ * Telecom hero: the site's standard solution-page hero shell (FinanceHero,
+ * FipsHero): bg-cs-hero mesh, gridline overlay, left-aligned copy, a 3D
+ * artifact on the right and a bottom fade into the white section below.
+ * Headline and button label are the brief's, verbatim.
+ *
+ * The artifact (cell tower, verified shield, rack servers, dish) was rendered
+ * on flat black and keyed to a real alpha channel, so it composites straight
+ * onto the gradient with no blend mode. It is decorative and not the LCP
+ * element (the H1 carries `lcp`), and it is `hidden xl:block`, so it is not
+ * `priority`: a priority preload would fire on phones that never paint it.
+ */
+export function TelecomHero(): React.ReactElement {
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Telecom Networks Run on Software.{' '}
+ Security Must Keep Up.
+
+
+
+
+
+ Talk to a Security Expert
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/telecom/TelecomJourney.tsx b/apps/web/src/components/sections/telecom/TelecomJourney.tsx
new file mode 100644
index 00000000..f53bbb91
--- /dev/null
+++ b/apps/web/src/components/sections/telecom/TelecomJourney.tsx
@@ -0,0 +1,245 @@
+import type React from 'react';
+import { Fragment } from 'react';
+import { ChevronRight } from 'lucide-react';
+import { Container, Section } from '@/components/layout';
+import { Reveal, RevealItem, RevealStagger } from '@/components/ui/Reveal';
+import { TelecomJourneyArt } from './TelecomJourneyArt';
+import { H2_STYLE } from './type';
+
+/*
+ * "Build Modern Networks on Verified Software": Discover, Standardize, Operate
+ * as three stations on one network link, not three cards.
+ *
+ * One stage holds all three, so the steps read as a single flow. The animated
+ * scenes (TelecomJourneyArt) stand on a shared platform level, and the gaps
+ * between them are fibre links: dashes flow toward the next step and a light
+ * packet travels Discover to Standardize, then Standardize to Operate (the
+ * second link waits half a cycle). The links echo the signal backbone in the
+ * "Need More" section, so the page keeps one visual language for "network".
+ *
+ * Operate is the destination: a violet aura behind its scene and the gradient
+ * on its title. No step numbers; the link direction carries the order.
+ * Hovering a station lifts it onto a white surface and plays its scene.
+ *
+ * Below lg the stations stack and each link turns vertical between them.
+ *
+ * Copy is the brief's, verbatim ("visibility" spelled correctly; the mock's
+ * "visibillty" is a typo, not copy).
+ */
+
+interface Step {
+ art: 'discover' | 'standardize' | 'operate';
+ title: string;
+ body: string;
+ destination: boolean;
+}
+
+const STEPS: readonly [Step, Step, Step] = [
+ {
+ art: 'discover',
+ title: 'Discover',
+ body: 'Gain visibility across clusters, registries, images and applications.',
+ destination: false,
+ },
+ {
+ art: 'standardize',
+ title: 'Standardize',
+ body: 'Replace inconsistent public software with verified components.',
+ destination: false,
+ },
+ {
+ art: 'operate',
+ title: 'Operate',
+ body: 'Maintain verified software foundations through continuous verification.',
+ destination: true,
+ },
+];
+
+/** Station top padding and art height; the links sit at 70% of the art, the platforms' level. */
+const STATION_PAD = 'clamp(20px, 2vw, 28px)';
+const ART_HEIGHT = 'clamp(204px, 17vw, 248px)';
+const RAIL_TOP = `calc(${STATION_PAD} + ${ART_HEIGHT} * 0.7 - 5px)`;
+
+/**
+ * A fibre link between two stations: a soft glow, flowing dashes, a packet with
+ * a comet tail, an arrowhead. At lg it reaches into both neighbouring stations'
+ * padding so it spans platform edge to platform edge, not just the gutter.
+ */
+function FibreLink({ delay }: { delay: string }): React.ReactElement {
+ return (
+
+ Build Modern Networks on{' '}
+ Verified Software
+
+
+
+
+ {/* Dot field, fading out at the edges, and the destination's glow
+ bleeding in from the Operate end. */}
+
+
+
+
+ {STEPS.map((step, i) => (
+
+
+
+
+ {i < STEPS.length - 1 ? (
+
+
+
+ ) : null}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/telecom/TelecomJourneyArt.tsx b/apps/web/src/components/sections/telecom/TelecomJourneyArt.tsx
new file mode 100644
index 00000000..6a9002ab
--- /dev/null
+++ b/apps/web/src/components/sections/telecom/TelecomJourneyArt.tsx
@@ -0,0 +1,208 @@
+'use client';
+
+import type React from 'react';
+import { useRef } from 'react';
+import Image from 'next/image';
+import { useRevealInView } from '@/components/ui/Reveal';
+import { REVEAL_VIEWPORT } from '@/lib/motion';
+
+/*
+ * The three Discover / Standardize / Operate artworks, assembled from separate
+ * layers so each part can move on its own (keyframes: globals.css, `cs-tj-*`).
+ *
+ * The layers were not peeled out of the flat renders: the pixels hidden behind
+ * an object do not exist in a flat image. Each scene was rendered again as an
+ * exploded view, every part floating apart on one camera, then keyed and split,
+ * so the parts line up exactly when re-stacked here.
+ *
+ * Discover the magnifier drops in, then sweeps the platform on a slow loop;
+ * a light spot on the platform tracks it. Hover lifts the lens.
+ * Standardize four tiles arrive scattered and askew, straighten, then settle
+ * into one aligned stack: the step's own story. Hover explodes the
+ * stack back apart. The tiles in the render overlap in perspective,
+ * so only the top one was fully visible: it is cut out and copied
+ * four times. The platform's back corner, which the lowest tile hid
+ * in the render, was rebuilt by extending its two visible back edges,
+ * so every tile is free to move, the lowest included.
+ * Operate a straight-on gear set upright in a CSS 3D plane, so it turns in
+ * its own plane instead of spinning flat like a sticker. Stacked
+ * copies pushed back in z give it thickness. Hover adds a second,
+ * faster turn on top, so the speed-up never jumps.
+ *
+ * Positions are percentages of each scene's frame, measured from the renders.
+ * Everything starts on reveal (`data-on`) and is static under reduced motion.
+ */
+
+type Variant = 'discover' | 'standardize' | 'operate';
+
+interface Layer {
+ src: string;
+ w: number;
+ h: number;
+}
+
+const DISCOVER_PLATFORM: Layer = { src: '/images/telecom/journey/discover-platform.webp', w: 790, h: 326 };
+const LENS: Layer = { src: '/images/telecom/journey/discover-lens.webp', w: 490, h: 415 };
+const STD_PLATFORM: Layer = { src: '/images/telecom/journey/standardize-base.webp', w: 682, h: 420 };
+const TILE: Layer = { src: '/images/telecom/journey/standardize-tile.webp', w: 379, h: 199 };
+const GEAR: Layer = { src: '/images/telecom/journey/gear.webp', w: 852, h: 851 };
+
+/** Discover and Operate share the discover render's frame (790 x 816). */
+const OPEN_FRAME = '790 / 816';
+/** The standardize render, trimmed to the exploded stack and platform (682 x 700). */
+const STACK_FRAME = '682 / 700';
+
+/*
+ * Tiles, top to bottom. `top` is the settled stack: 38 render px apart, the
+ * lowest resting just above the platform face. `lift` is the exploded spacing
+ * (95 render px apart) as a share of the tile's own 199px height; `jx`/`jr`
+ * are the scattered, askew arrival. Lowest tile arrives first.
+ */
+const TILES = [
+ { top: '31.57%', lift: '-111.06%', jx: '-18%', jr: '-8deg', delay: '0.3s' },
+ { top: '37%', lift: '-82.41%', jx: '16%', jr: '7deg', delay: '0.2s' },
+ { top: '42.43%', lift: '-53.77%', jx: '-12%', jr: '-5deg', delay: '0.1s' },
+ { top: '47.86%', lift: '-25.13%', jx: '10%', jr: '5deg', delay: '0s' },
+] as const;
+
+function LayerImg({ layer, sizes }: { layer: Layer; sizes: string }): React.ReactElement {
+ return (
+
+ );
+}
+
+function Discover(): React.ReactElement {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function Standardize(): React.ReactElement {
+ return (
+ <>
+
+
+
+ {[...TILES].reverse().map((t) => (
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+}
+
+function Operate(): React.ReactElement {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {[4, 3, 2, 1].map((depth) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+ >
+ );
+}
+
+const SCENES: Record React.ReactElement }> = {
+ discover: {
+ frame: OPEN_FRAME,
+ label: 'A magnifying glass sweeping across a violet platform',
+ Scene: Discover,
+ },
+ standardize: {
+ frame: STACK_FRAME,
+ label: 'Scattered tiles straightening into one aligned stack on a violet platform',
+ Scene: Standardize,
+ },
+ operate: {
+ frame: OPEN_FRAME,
+ label: 'A gear turning steadily on a violet platform',
+ Scene: Operate,
+ },
+};
+
+export function TelecomJourneyArt({ variant }: { variant: Variant }): React.ReactElement {
+ const ref = useRef(null);
+ const on = useRevealInView(ref, REVEAL_VIEWPORT);
+ const { frame, label, Scene } = SCENES[variant];
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/telecom/TelecomNetwork.tsx b/apps/web/src/components/sections/telecom/TelecomNetwork.tsx
new file mode 100644
index 00000000..527420d9
--- /dev/null
+++ b/apps/web/src/components/sections/telecom/TelecomNetwork.tsx
@@ -0,0 +1,193 @@
+import type React from 'react';
+import Image from 'next/image';
+import { Container, Section } from '@/components/layout';
+import { Reveal, RevealItem, RevealStagger } from '@/components/ui/Reveal';
+import { H2_STYLE } from './type';
+
+/*
+ * "Modern Telecom Networks Need More": the four surfaces a network now runs on,
+ * strung along one signal backbone. A light pulse travels the rail left to
+ * right and each node pings as it passes, so the section reads as one network
+ * rather than four unrelated tiles. Pulse and pings are CSS (globals.css,
+ * `cs-tel-*`), transform/opacity only, and hidden under reduced motion; the
+ * static rail stays.
+ *
+ * Ping delays are where the pulse crosses each node: the pulse spot travels
+ * from -10% to 110% of the rail over 4.8s at linear timing, and the nodes sit
+ * at 12.5/37.5/62.5/87.5%, so (p + 0.1) / 1.2 * 4.8s = 0.9, 1.9, 2.9, 3.9s.
+ *
+ * Below lg the rail is dropped and the four stack two-up: a backbone that
+ * wraps onto two lines reads as two networks.
+ *
+ * Copy is the brief's, verbatim.
+ */
+
+interface Surface {
+ icon: string;
+ iconAlt: string;
+ title: string;
+ body: string;
+ pingDelay: string;
+}
+
+const SURFACES: readonly [Surface, Surface, Surface, Surface] = [
+ {
+ icon: '/images/telecom/icon-cloud-native.webp',
+ iconAlt: '3D violet cloud with a network node',
+ title: 'Cloud-Native Networks',
+ body: 'Software-defined networks across cloud and edge environments.',
+ pingDelay: '0.9s',
+ },
+ {
+ icon: '/images/telecom/icon-network-functions.webp',
+ iconAlt: '3D cluster of stacked violet server modules',
+ title: 'Network Functions',
+ body: 'Modern network services operate across distributed telecom environments.',
+ pingDelay: '1.9s',
+ },
+ {
+ icon: '/images/telecom/icon-open-source.webp',
+ iconAlt: '3D violet code brackets on a rounded tile',
+ title: 'Open Source Components',
+ body: 'Libraries and dependencies powering telecom applications.',
+ pingDelay: '2.9s',
+ },
+ {
+ icon: '/images/telecom/icon-distributed.webp',
+ iconAlt: '3D violet radio mast broadcasting signal waves',
+ title: 'Distributed Infrastructure',
+ body: 'Complex, multi-vendor environments requiring consistent security.',
+ pingDelay: '3.9s',
+ },
+];
+
+/** The node a surface hangs from: a ringed dot on the rail, pinging as the pulse crosses it. */
+function RailNode({ pingDelay }: { pingDelay: string }): React.ReactElement {
+ return (
+
+
+
+
+
+ );
+}
+
+export function TelecomNetwork(): React.ReactElement {
+ return (
+
+
+
+
+ Modern Telecom Networks Need More
+
+
+
+
+ {/* The backbone. Its top matches the node row below: icon stage
+ height plus half a node. Fades out at both ends so it reads as
+ a link that continues past the frame, not a bar with caps. */}
+
+
+
+
+
+
+ {SURFACES.map((s) => (
+
+
+
+
+
+
+
+
+
+ {s.title}
+
+
+ {s.body}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/telecom/TelecomOutcomes.tsx b/apps/web/src/components/sections/telecom/TelecomOutcomes.tsx
new file mode 100644
index 00000000..1b7ba1f0
--- /dev/null
+++ b/apps/web/src/components/sections/telecom/TelecomOutcomes.tsx
@@ -0,0 +1,190 @@
+import type React from 'react';
+import { Fragment } from 'react';
+import type { LucideIcon } from 'lucide-react';
+import { Activity, BadgeCheck, Layers, TrendingDown } from 'lucide-react';
+import { Reveal, RevealItem, RevealStagger } from '@/components/ui/Reveal';
+import { H2_STYLE } from './type';
+
+/*
+ * "Outcomes That Drive Telecom Resilience": the page's close, on the site's
+ * outcomes treatment (FinanceOutcomes, CisoValidationOutcomes): figures set
+ * straight on the dark band, held apart by the lit glow bars, no container.
+ * A small line icon heads each figure, in the brief's mock.
+ *
+ * As the last band before the Footer's overlapping CTA card, bottom padding is
+ * --spacing-section-cta (the footer contract).
+ *
+ * Copy is the brief's, verbatim.
+ */
+
+interface Outcome {
+ Icon: LucideIcon;
+ line1: string;
+ line2: string;
+ body: string;
+}
+
+const OUTCOMES: readonly [Outcome, Outcome, Outcome, Outcome] = [
+ {
+ Icon: TrendingDown,
+ line1: 'Reduced',
+ line2: 'Software Risk',
+ body: 'Minimize vulnerabilities across network workloads.',
+ },
+ {
+ Icon: Layers,
+ line1: 'Standardized',
+ line2: 'Foundations',
+ body: 'Deploy verified components across network environments.',
+ },
+ {
+ Icon: Activity,
+ line1: 'Operational',
+ line2: 'Resilience',
+ body: 'Improve software reliability across distributed infrastructure.',
+ },
+ {
+ Icon: BadgeCheck,
+ line1: 'Compliance',
+ line2: 'Readiness',
+ body: 'Demonstrate software assurance with verifiable evidence.',
+ },
+];
+
+function GlowBar(): React.ReactElement {
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ );
+}
+
+function OutcomeFigure({ Icon, line1, line2, body }: Outcome): React.ReactElement {
+ return (
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/telecom/TelecomPillars.tsx b/apps/web/src/components/sections/telecom/TelecomPillars.tsx
new file mode 100644
index 00000000..9950fffc
--- /dev/null
+++ b/apps/web/src/components/sections/telecom/TelecomPillars.tsx
@@ -0,0 +1,210 @@
+import type React from 'react';
+import Image from 'next/image';
+import { Container, Section } from '@/components/layout';
+import { Reveal } from '@/components/ui/Reveal';
+import { H2_STYLE } from './type';
+
+/*
+ * "Built for Modern Telecommunications": the four assurance pillars in one
+ * matrix card, closed by a ribbon of the standards the pillars name (the
+ * SaasDelivery / FinanceRequirements family). One sentence per pillar because
+ * that is all the brief gives; every ribbon entry appears in that copy.
+ *
+ * Copy is the brief's, verbatim.
+ */
+
+interface Pillar {
+ icon: string;
+ iconAlt: string;
+ title: string;
+ body: string;
+}
+
+const PILLARS: readonly [Pillar, Pillar, Pillar, Pillar] = [
+ {
+ icon: '/images/attack-surface-reduction/approach-icon-secure.webp',
+ iconAlt: '3D icon of a security padlock',
+ title: 'Security',
+ body: 'Hardened foundations, near-zero known CVEs and reduced attack surface.',
+ },
+ {
+ icon: '/images/compare/icon-signed-artifact.webp',
+ iconAlt: '3D icon of a signed and sealed artifact',
+ title: 'Integrity',
+ body: 'SLSA Level 3 provenance, cryptographic signing and reproducible builds.',
+ },
+ {
+ icon: '/images/compare/icon-sbom.webp',
+ iconAlt: '3D icon of a software bill of materials document',
+ title: 'Transparency',
+ body: 'SBOMs, AI BOMs and visibility into software components and dependencies.',
+ },
+ {
+ icon: '/images/compare/icon-fips.webp',
+ iconAlt: '3D icon of a compliance shield',
+ title: 'Compliance',
+ body: 'FIPS 140-3, CIS Benchmarks, DISA STIG and support for software assurance requirements.',
+ },
+];
+
+const STANDARDS: readonly string[] = [
+ 'FIPS 140-3',
+ 'SLSA Level 3',
+ 'SBOM',
+ 'AI BOM',
+ 'CIS Benchmarks',
+ 'DISA STIG',
+];
+
+const RULE = 'rgba(154,81,255,0.12)';
+
+export function TelecomPillars(): React.ReactElement {
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+
+ Built for Modern Telecommunications
+
+
+
+
+
+
+ {PILLARS.map((p, i) => (
+
+
+
+
+
+
+ {p.title}
+
+
+ {p.body}
+
+
+ ))}
+
+
+
+ {STANDARDS.map((std) => (
+
+ {std}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/telecom/TelecomRisks.tsx b/apps/web/src/components/sections/telecom/TelecomRisks.tsx
new file mode 100644
index 00000000..7b43fd91
--- /dev/null
+++ b/apps/web/src/components/sections/telecom/TelecomRisks.tsx
@@ -0,0 +1,163 @@
+import type React from 'react';
+import type { LucideIcon } from 'lucide-react';
+import { Container as ContainerIcon, EyeOff, Layers, ShieldAlert } from 'lucide-react';
+import { Container, Section } from '@/components/layout';
+import { Reveal, RevealItem, RevealStagger } from '@/components/ui/Reveal';
+import { H2_STYLE, LEAD_STYLE } from './type';
+
+/*
+ * "Risk Enters Long Before Deployment": the risk register, as a 2x2 under
+ * hairline cross-rules on the tinted band (the SaasRisks shape).
+ *
+ * Line icons in violet tiles rather than the 3D renders the section above
+ * uses. The four surfaces above are things the network is made of; these are
+ * threats to it, and drawing them in the same glossy objects would make the
+ * hazards read as products. Nothing grades the four: the brief lists them flat,
+ * so there is no severity scale.
+ *
+ * Copy is the brief's, verbatim.
+ */
+
+interface Risk {
+ Icon: LucideIcon;
+ title: string;
+ body: string;
+}
+
+const RISKS: readonly [Risk, Risk, Risk, Risk] = [
+ {
+ Icon: ContainerIcon,
+ title: 'Vulnerable Container Images',
+ body: 'Inherited vulnerabilities in public base images.',
+ },
+ {
+ Icon: Layers,
+ title: 'Open Source Dependencies',
+ body: 'Unseen risks from third-party software components.',
+ },
+ {
+ Icon: ShieldAlert,
+ title: 'Software Supply Chain Attacks',
+ body: 'Compromised components entering network infrastructure.',
+ },
+ {
+ Icon: EyeOff,
+ title: 'Limited Software Visibility',
+ body: 'Unknown assets and dependencies across distributed environments.',
+ },
+];
+
+export function TelecomRisks(): React.ReactElement {
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+
+
+ Risk Enters Long Before Deployment
+
+
+
+
+ Every software component powering your network can introduce risk.
+
+
+
+
+
+ {RISKS.map(({ Icon, title, body }, i) => (
+
+ {/* Stacked: every cell after the first takes a top rule. As a
+ 2x2: only the second row does, plus a left rule on the right
+ column. Resolved here rather than by stacking `sm:border-t-0`
+ on `border-t`, which Tailwind orders against us. */}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/telecom/type.ts b/apps/web/src/components/sections/telecom/type.ts
new file mode 100644
index 00000000..e8bf7dd5
--- /dev/null
+++ b/apps/web/src/components/sections/telecom/type.ts
@@ -0,0 +1,20 @@
+import type React from 'react';
+
+/* Section heading and lead styles shared by the telecom sections. Colour is set per band. */
+export const H2_STYLE: React.CSSProperties = {
+ margin: 0,
+ fontFamily: 'var(--font-display)',
+ fontSize: 'var(--fs-h2)',
+ fontWeight: 'var(--fs-h2-weight)',
+ letterSpacing: 'var(--fs-h2-ls)',
+ lineHeight: 'var(--fs-h2-lh)',
+};
+
+export const LEAD_STYLE: React.CSSProperties = {
+ margin: 0,
+ fontFamily: 'var(--font-sans)',
+ fontSize: 'var(--fs-lead-sm)',
+ fontWeight: 400,
+ letterSpacing: '-0.02em',
+ lineHeight: 1.5,
+};
diff --git a/apps/web/src/lib/analytics/gtm-snippet.test.ts b/apps/web/src/lib/analytics/gtm-snippet.test.ts
index cc5dda7e..1ffa73a2 100644
--- a/apps/web/src/lib/analytics/gtm-snippet.test.ts
+++ b/apps/web/src/lib/analytics/gtm-snippet.test.ts
@@ -66,6 +66,12 @@ describe("buildGtmSnippet host gating", () => {
expect(win().dataLayer).toBeUndefined();
});
+ it("loads no container on the named preview alias", () => {
+ runSnippetOn("preview.cleanstart.com");
+ expect(document.head.querySelector(LOADER)).toBeNull();
+ expect(win().dataLayer).toBeUndefined();
+ });
+
it("matches noindex hosts case-insensitively", () => {
runSnippetOn("CleanStart-Web.VERCEL.app");
expect(document.head.querySelector(LOADER)).toBeNull();
diff --git a/apps/web/src/lib/detail-route-not-found-guard.test.ts b/apps/web/src/lib/detail-route-not-found-guard.test.ts
index c535bea2..73717a13 100644
--- a/apps/web/src/lib/detail-route-not-found-guard.test.ts
+++ b/apps/web/src/lib/detail-route-not-found-guard.test.ts
@@ -14,6 +14,13 @@ describe("detailRouteExists", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
+ it("lets a static page under a detail prefix through without asking the CMS", async () => {
+ const fetchSpy = vi.fn();
+ vi.stubGlobal("fetch", fetchSpy);
+ await expect(detailRouteExists("/resources/state-of-software-dependency-trust")).resolves.toBe(true);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
it("still 404s a bare slug the CMS does not have", async () => {
vi.stubGlobal(
"fetch",
diff --git a/apps/web/src/lib/detail-route-not-found-guard.ts b/apps/web/src/lib/detail-route-not-found-guard.ts
index 0e1d8dd0..168e194a 100644
--- a/apps/web/src/lib/detail-route-not-found-guard.ts
+++ b/apps/web/src/lib/detail-route-not-found-guard.ts
@@ -64,6 +64,11 @@ const DETAIL_ROUTES: readonly DetailRoute[] = [
{ prefix: "/legal/", collection: "legalDocuments", extraFilter: PUBLISHED_FILTER },
];
+// Paths under a detail prefix that a static page serves itself, so the CMS has
+// no published document to find for them. The static segment wins routing, but
+// this guard runs first and would 404 them.
+const STATIC_PAGE_PATHS: ReadonlySet = new Set(["/resources/state-of-software-dependency-trust"]);
+
// `/legal/privacy-policy` is a redirect target, not a `legalDocuments` doc —
// `app/(legal)/legal/[slug]/page.tsx` permanentRedirect()s it to
// `/privacy-policy` before ever querying the collection. Never gate it here.
@@ -131,6 +136,7 @@ async function slugExists(route: DetailRoute, slug: string): Promise {
* cover, a slug that exists, or a lookup that failed (fail open).
*/
export async function detailRouteExists(pathname: string): Promise {
+ if (STATIC_PAGE_PATHS.has(pathname)) return true;
const match = matchRoute(pathname);
if (!match) return true;
if (match.route.prefix === "/legal/" && match.slug === LEGAL_PRIVACY_POLICY_SLUG) {
diff --git a/apps/web/src/lib/seo/indexing.test.ts b/apps/web/src/lib/seo/indexing.test.ts
index 86359335..45d5da61 100644
--- a/apps/web/src/lib/seo/indexing.test.ts
+++ b/apps/web/src/lib/seo/indexing.test.ts
@@ -11,6 +11,11 @@ describe("isNoindexHost", () => {
expect(isNoindexHost("CleanStart-Web.VERCEL.app:443")).toBe(true);
});
+ it("flags the named preview alias, ignoring port and case", () => {
+ expect(isNoindexHost("preview.cleanstart.com")).toBe(true);
+ expect(isNoindexHost("Preview.CleanStart.com:443")).toBe(true);
+ });
+
it("does not flag the production host or an empty host", () => {
expect(isNoindexHost("www.cleanstart.com")).toBe(false);
expect(isNoindexHost(null)).toBe(false);
@@ -30,6 +35,7 @@ describe("isIndexingAllowed", () => {
vi.stubEnv("VERCEL_ENV", "production");
expect(isIndexingAllowed("www.cleanstart.com")).toBe(true);
expect(isIndexingAllowed("anything.vercel.app")).toBe(false);
+ expect(isIndexingAllowed("preview.cleanstart.com")).toBe(false);
});
it("treats a host-less (build-time) production call as indexable", () => {
diff --git a/apps/web/src/lib/seo/indexing.ts b/apps/web/src/lib/seo/indexing.ts
index 0b467943..22760e41 100644
--- a/apps/web/src/lib/seo/indexing.ts
+++ b/apps/web/src/lib/seo/indexing.ts
@@ -22,13 +22,18 @@
* same list into its in-browser host check (lib/analytics/gtm-snippet.ts) rather
* than hardcoding a second copy that could drift from this one.
*
- * Intentionally EMPTY since 2026-07-29: `staging.cleanstart.com` was deleted
- * from DNS, so there is no longer a named non-production alias. Preview deploys
- * are still covered by `NOINDEX_HOST_SUFFIXES` below. Add a host here (not a
- * second list elsewhere) if a named alias is ever reintroduced — doing so also
- * keeps every GTM tag off it automatically.
+ * `staging.cleanstart.com` was deleted from DNS on 2026-07-29. Preview deploys
+ * are covered by `NOINDEX_HOST_SUFFIXES` below, plus the named alias here.
+ * Add a host here (not a second list elsewhere) whenever a named alias exists;
+ * doing so also keeps every GTM tag off it automatically.
+ *
+ * `preview.cleanstart.com` is the Vercel domain bound to the `development`
+ * branch. Preview builds already run with `VERCEL_ENV=preview` (never
+ * indexable) and ship no GTM id, so today this is a backstop: it keeps search
+ * and tracking off the host even if a GTM id or a production build ever
+ * reaches it.
*/
-export const NOINDEX_HOSTS: readonly string[] = [];
+export const NOINDEX_HOSTS: readonly string[] = ["preview.cleanstart.com"];
/** Host suffixes we never index (Vercel preview aliases). Same rationale. */
export const NOINDEX_HOST_SUFFIXES: readonly string[] = [".vercel.app"];
diff --git a/docs/web/WEB-PAGES.md b/docs/web/WEB-PAGES.md
index c20064fe..d970bbf1 100644
--- a/docs/web/WEB-PAGES.md
+++ b/docs/web/WEB-PAGES.md
@@ -86,7 +86,7 @@ down to 1024.
## Solutions Pages
-> **`/industries` segment.** The two industry pages are the only nested static routes on the site; everything else is one segment. `/industries` itself has no page and therefore 404s — a segment with no hub forfeits the main reason to nest (a hub that ranks for the category term and passes equity down) and dead-ends anyone who truncates the URL. Build the hub, or the nesting is decoration.
+> **`/industries` segment.** The industry pages are the only nested static routes on the site; everything else is one segment. `/industries` itself has no page and therefore 404s — a segment with no hub forfeits the main reason to nest (a hub that ranks for the category term and passes equity down) and dead-ends anyone who truncates the URL. Build the hub, or the nesting is decoration.
| # | Page Name | URL Slug | Type | Status | Notes |
|---|-----------|----------|------|--------|-------|
@@ -98,6 +98,8 @@ down to 1024.
| 12 | Impact Estimator | `/impact-estimator` | Static | ✅ | Interactive Operational Impact simulator (light theme). Client `ImpactSimulator` + isolated `model.ts` engine (client-owned bands from ROI 1.xlsx). Sections: Hero (eyebrow + H1), Simulator (sticky inputs, gauge, KPI cards, hours card, copy-link button beside the trust line, mobile summary strip), How-it's-calculated chain, FAQ (six questions, FAQPage JSON-LD), Footer CTA. Inputs round-trip through the URL via `url-state.ts` (unit-tested; e2e in `tests/e2e/impact-estimator.spec.ts`). Nav-linked under Solutions › Capability since 2026-09-02 (`gauge` glyph); **Indexable 2026-09-02**: `noindex,nofollow` dropped, listed in `STATIC_ROUTES`. `pageRegistry` row (id=44) in place, so the page emits a WebPage node. Renamed from `/roi-calculator` on 2026-09-02; the old path 308s to the new one in `next.config.ts`. |
| 13 | Financial Services | `/industries/financial-services` | Static | ✅ | Title, description and H1 are the SEO team's, applied verbatim. **Renamed from `/financial-services` 2026-08-31** while that URL was noindex, unlinked and out of the sitemap in production, so nothing was de-ranked; a 301 is registered in the CMS `redirects` collection (id=41) regardless, since it did resolve publicly. **Live 2026-08-31**: indexable (the `noindex,nofollow` pair dropped) and listed in `STATIC_ROUTES`. Nav-linked (Solutions › By industry). Breadcrumb, `JsonLdGraph` and `pageRegistry` row (id=42) in place. **Renamed again 2026-09-08** to `/industries/financial-services`, this time post-launch from an indexed, sitemap-listed, nav-linked URL. Its 301 is in `next.config.ts` (not the CMS) so it deploys with the route move. **Two CMS rows still key on the old path and must be updated at deploy:** `pageRegistry` id=42 (else the JSON-LD graph resolves to nothing) and `redirects` id=41 (else `/financial-services` 301s into a 404). |
| 14 | Software / SaaS | `/industries/software-applications` | Static | ✅ | **Renamed 2026-09-08** from `/industries/modern-applications`, post-launch from an indexed, sitemap-listed, nav-linked URL; 301 in `next.config.ts` so it deploys with the route move. H1 changed to "Software Moves Faster. / Security Must Be Smarter." and nav label to "Software / SaaS" (Solutions › By industry). Title, description and breadcrumb rewritten to match: title is now "Container Security for SaaS and Software \| CleanStart" (the sibling's shape), breadcrumb "Software and SaaS". The H1 carries no keyword, so the title tag is the only place the head term lives. Built as `/saas`, then `/industries/saas-container-security`, and settled at this path 2026-09-02 before ever being indexed or linked, so the earlier paths carry no redirects. **Live 2026-09-02**: indexable, listed in `STATIC_ROUTES`. Breadcrumb and `JsonLdGraph` in place; the `pageRegistry` row (id=43) keys on path and **must be updated to `/industries/software-applications` in prod** or the WebPage node drops out. |
+| 15 | Telecom | `/industries/telecom` | Static | 🟡 Live, noindex | Built 2026-11-06 from the client's Telecom brief (copy verbatim; H1 "Telecom Networks Run on Software. Security Must Keep Up."). **Live in production 2026-11-06 but noindex:** `noindex`, not in `STATIC_ROUTES`, not in the Solutions › By industry nav. Title "Container Security for Telecom Networks | CleanStart" and the meta description are ours, pending SEO sign-off. Breadcrumb, `JsonLdGraph` and `pageRegistry` seed row in place (row not yet created in prod: run `seed-page-registry.ts`). To launch: drop `noindex`, add to `STATIC_ROUTES`, add the nav item. CTA card is the shared `industries/IndustryCTA`, which the two sibling pages now also use. |
+| 16 | State of Software Dependency Trust 2026 (report) | `/resources/state-of-software-dependency-trust` | Static | 🟡 Live, noindex | Gated research-report landing, built 2026-11-06 from the client's Report LP copy (verbatim) plus the report's own methodology and sources page. **A static page, not rendered from the CMS:** copy lives in `components/sections/resource/dependency-trust/content.ts`, metadata and JSON-LD in the route file. Only the report's record id (37) and its gate form id (11) are hardcoded in the route; the signed download is minted server-side from the record when the form is submitted. The proxy's slug guard lets this one path through (`STATIC_PAGE_PATHS` in `lib/detail-route-not-found-guard.ts`), because the CMS has no published document at that slug. **Live in production 2026-11-06 but `noindex`**, not in `STATIC_ROUTES`, not linked from any nav. Title and description are ours, pending SEO sign-off. `pageRegistry` seed row in place (row not yet created in prod: run `seed-page-registry.ts`). To launch for search: drop `noindex` in the route and add the path to `STATIC_ROUTES`. |
| 3 | Enhance SCA | `/software-composition-analysis` | Static | ❌ removed | **Deleted 2026-07-07** — page, `sca` section components, and image assets fully removed (was orphaned: `index,follow` but absent from nav/sitemap, so Google kept surfacing an unlinked page). Route now 301s to `/guide/software-composition-analysis` via the `redirects` collection (see `post-launch-redirects-seed.ts`). |
---