diff --git a/apps/cms/src/payload/lib/broken-links/extract.test.ts b/apps/cms/src/payload/lib/broken-links/extract.test.ts index a42b4560e..fae6b1bdb 100644 --- a/apps/cms/src/payload/lib/broken-links/extract.test.ts +++ b/apps/cms/src/payload/lib/broken-links/extract.test.ts @@ -82,8 +82,72 @@ describe('extractAllLinks', () => { ); }); - it('drops site-relative URLs and non-string scalar fields', () => { - const links = extractAllLinks({ body: null, applyUrl: '/internal-path', registrationUrl: null, atsUrl: 42 }); + it('resolves site-relative URLs against the public origin, and drops non-string scalars', () => { + // Reverses the previous contract, which dropped these. Nothing else + // validates a hand-typed internal path: it carries no relationship for the + // slug-change hook, so `/guide/orchestration` shipped as a live 404. + const links = extractAllLinks( + { body: null, applyUrl: '/internal-path', registrationUrl: null, atsUrl: 42 }, + 'https://www.cleanstart.com', + ); + expect(links).toEqual([ + { url: 'https://www.cleanstart.com/internal-path', anchorText: null, location: 'Apply URL' }, + ]); + }); + + it('resolves site-relative body links too, keeping the anchor text', () => { + const links = extractAllLinks( + { + body: wrap([ + { type: 'link', fields: { url: '/guide/orchestration' }, children: [{ type: 'text', text: 'orchestration guide' }] }, + ]), + }, + 'https://www.cleanstart.com', + ); + expect(links).toEqual([ + { url: 'https://www.cleanstart.com/guide/orchestration', anchorText: 'orchestration guide', location: 'Body' }, + ]); + }); + + it('never rewrites a protocol-relative URL into one of ours', () => { + // `//other.example/path` is another host, not a site path. Prefixing the + // origin would silently retarget it at ourselves and report a foreign + // link as healthy. It has no scheme, so the SSRF guard drops it instead — + // the property under test is that it is not absorbed into our origin. + const links = extractAllLinks( + { body: null, applyUrl: '//other.example/path' }, + 'https://www.cleanstart.com', + ); + expect(links).toEqual([]); + expect(JSON.stringify(links)).not.toContain('cleanstart.com'); + }); + + it('collapses a relative and an absolute reference to the same page into one check', () => { + const links = extractAllLinks( + { + body: wrap([ + { type: 'link', fields: { url: '/pricing' }, children: [{ type: 'text', text: 'pricing' }] }, + ]), + applyUrl: 'https://www.cleanstart.com/pricing', + }, + 'https://www.cleanstart.com', + ); + expect(links).toEqual([ + { url: 'https://www.cleanstart.com/pricing', anchorText: 'pricing', location: 'Body' }, + ]); + }); + + it('does not resolve anchors, mailto or tel into page URLs', () => { + const links = extractAllLinks( + { + body: wrap([ + { type: 'link', fields: { url: '#section-2' } }, + { type: 'link', fields: { url: 'mailto:hi@cleanstart.com' } }, + { type: 'link', fields: { url: 'tel:+911234567890' } }, + ]), + }, + 'https://www.cleanstart.com', + ); expect(links).toEqual([]); }); diff --git a/apps/cms/src/payload/lib/broken-links/extract.ts b/apps/cms/src/payload/lib/broken-links/extract.ts index fe65bdc30..87c747169 100644 --- a/apps/cms/src/payload/lib/broken-links/extract.ts +++ b/apps/cms/src/payload/lib/broken-links/extract.ts @@ -1,3 +1,4 @@ +import { resolveSiteUrl } from '../site-url'; import { isSafePublicHttpUrl } from '../url-safety/ssrf-guard'; /** @@ -5,9 +6,16 @@ import { isSafePublicHttpUrl } from '../url-safety/ssrf-guard'; * URL the editor referenced, with the visible anchor text and a * human-readable location. Used by the nightly broken-link scanner. * - * Internal-doc relationships (`linkType === 'internal'`, `doc != null`) + * Internal-doc *relationships* (`linkType === 'internal'`, `doc != null`) * are skipped — Payload's slug-change hook keeps those resolvable. * + * Hand-typed site-relative paths are NOT skipped. They carry no relationship + * for the slug-change hook to follow, so nothing else in the system validates + * them: `/guide/orchestration` and `/images/redis/details` both shipped in + * published bodies as 404s and went unnoticed until a manual crawl. They are + * resolved against the public origin so the scanner HEAD-checks them like any + * other link. + * * SSRF defence: every emitted URL passes `isSafePublicHttpUrl`. */ @@ -71,6 +79,17 @@ export const extractLinksFromLexical = (body: unknown): LexicalLink[] => { const isFetchSafeHttpUrl = (raw: string): boolean => isSafePublicHttpUrl(raw).ok; +/** + * Resolve a root-relative editor link against the public origin. + * + * Only `/path` is rewritten. `//host/path` is protocol-relative and points at + * another origin, so prefixing it would silently retarget the link; anything + * else (absolute URLs, `#anchor`, `mailto:`, `tel:`) is returned untouched and + * falls to the SSRF guard to accept or drop. + */ +const absolutiseInternal = (raw: string, origin: string): string => + raw.startsWith('/') && !raw.startsWith('//') ? `${origin}${raw}` : raw; + const SCALAR_URL_FIELDS: ReadonlyArray = [ ['applyUrl', 'Apply URL'], ['atsUrl', 'ATS URL'], @@ -86,9 +105,14 @@ const SCALAR_URL_FIELDS: ReadonlyArray = * by field label). Returns absolute http(s) URLs that pass the SSRF * guard; first occurrence of a URL wins (body before typed fields). */ -export const extractAllLinks = (doc: Record): ExtractedLink[] => { +export const extractAllLinks = ( + doc: Record, + siteOrigin: string = resolveSiteUrl(), +): ExtractedLink[] => { const byUrl = new Map(); - const add = (url: string, anchorText: string | null, location: string): void => { + const add = (raw: string, anchorText: string | null, location: string): void => { + // Dedupe on the resolved URL so `/x` and `https://site/x` collapse to one check. + const url = absolutiseInternal(raw, siteOrigin); if (isFetchSafeHttpUrl(url) && !byUrl.has(url)) { byUrl.set(url, { url, anchorText, location }); } diff --git a/apps/cms/src/payload/lib/page-registry-seed.ts b/apps/cms/src/payload/lib/page-registry-seed.ts index 0c06f7d49..d1a28b280 100644 --- a/apps/cms/src/payload/lib/page-registry-seed.ts +++ b/apps/cms/src/payload/lib/page-registry-seed.ts @@ -59,6 +59,9 @@ export const PAGE_REGISTRY_SEED: readonly PageRegistrySeedRow[] = [ { path: '/software-bill-materials', title: 'Software Bill of Materials', kind: 'static', order: 7, webPageType: 'WebPage' }, { path: '/for-developers', title: 'For Developers', kind: 'static', order: 8, webPageType: 'WebPage' }, { path: '/for-ciso', title: 'For CISO', kind: 'static', order: 9, webPageType: 'WebPage' }, + // Solutions › Capability tool. Order sits after every seeded row so existing + // dashboard positions keep their numbers. + { path: '/impact-estimator', title: 'Impact Estimator', kind: 'static', order: 49, webPageType: 'WebPage' }, // Resources (mega-menu) { path: '/blogs', title: 'Blogs', kind: 'cms-listing', order: 10, backingCollection: 'blogs' }, diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index ae698706b..fd0f16c39 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -79,6 +79,24 @@ const nextConfig: NextConfig = { destination: "/guide/:slug*", permanent: true, }, + // Industry pages live under `/industries/`, but the slug already names + // the industry, so the shorter path is the natural guess and returns a + // hard 404. Same courtesy 301 as `/guides` above: it was never a live + // URL, just one worth catching. The sibling /industries/modern-applications + // was never indexed or linked under its earlier slugs, so it carries none. + { + source: "/financial-services-container-security", + destination: "/industries/financial-services-container-security", + permanent: true, + }, + // The operational-impact estimator launched at `/roi-calculator` and was + // renamed while still noindex,nofollow, so nothing is indexed under the + // old path. It is live in the client's review links, though, so 308 it. + { + source: "/roi-calculator", + destination: "/impact-estimator", + permanent: true, + }, // Canonical detail routes are singular `/event/[slug]` and `/job/[slug]` // (matching the indexed Webflow URLs). The redesign also shipped plural // aliases that rendered the same content and self-canonicalled to diff --git a/apps/web/public/images/ciso/enterprise-icon-devsecops.svg b/apps/web/public/images/ciso/enterprise-icon-devsecops.svg index 358f86d09..e458ed410 100644 --- a/apps/web/public/images/ciso/enterprise-icon-devsecops.svg +++ b/apps/web/public/images/ciso/enterprise-icon-devsecops.svg @@ -1,4 +1,4 @@ - + @@ -49,7 +49,7 @@ - + diff --git a/apps/web/public/images/ciso/enterprise-icon-security-ops.svg b/apps/web/public/images/ciso/enterprise-icon-security-ops.svg index 74d55857c..e5b4ffff7 100644 --- a/apps/web/public/images/ciso/enterprise-icon-security-ops.svg +++ b/apps/web/public/images/ciso/enterprise-icon-security-ops.svg @@ -1,4 +1,4 @@ - + @@ -142,7 +142,7 @@ - + diff --git a/apps/web/public/images/cleanstart-platform/cta-bg-union.svg b/apps/web/public/images/cleanstart-platform/cta-bg-union.svg deleted file mode 100644 index ce5d72fcc..000000000 --- a/apps/web/public/images/cleanstart-platform/cta-bg-union.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/hero-cube.webp b/apps/web/public/images/cleanstart-platform/hero-cube.webp deleted file mode 100644 index a42a38f8c..000000000 Binary files a/apps/web/public/images/cleanstart-platform/hero-cube.webp and /dev/null differ diff --git a/apps/web/public/images/cleanstart-platform/outputs-corner-union.svg b/apps/web/public/images/cleanstart-platform/outputs-corner-union.svg deleted file mode 100644 index 9c5266bcb..000000000 --- a/apps/web/public/images/cleanstart-platform/outputs-corner-union.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/outputs-icon-1.svg b/apps/web/public/images/cleanstart-platform/outputs-icon-1.svg deleted file mode 100644 index a46d56a61..000000000 --- a/apps/web/public/images/cleanstart-platform/outputs-icon-1.svg +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/outputs-icon-2.svg b/apps/web/public/images/cleanstart-platform/outputs-icon-2.svg deleted file mode 100644 index f80f16fa2..000000000 --- a/apps/web/public/images/cleanstart-platform/outputs-icon-2.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/outputs-icon-3.svg b/apps/web/public/images/cleanstart-platform/outputs-icon-3.svg deleted file mode 100644 index 903f0e96e..000000000 --- a/apps/web/public/images/cleanstart-platform/outputs-icon-3.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/outputs-icon-4.svg b/apps/web/public/images/cleanstart-platform/outputs-icon-4.svg deleted file mode 100644 index ee5ca15e8..000000000 --- a/apps/web/public/images/cleanstart-platform/outputs-icon-4.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/tick-circle.svg b/apps/web/public/images/cleanstart-platform/tick-circle.svg deleted file mode 100644 index c1a878b4c..000000000 --- a/apps/web/public/images/cleanstart-platform/tick-circle.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/web/public/images/cleanstart-platform/trust-ball-icon-1.svg b/apps/web/public/images/cleanstart-platform/trust-ball-icon-1.svg deleted file mode 100644 index dc5421190..000000000 --- a/apps/web/public/images/cleanstart-platform/trust-ball-icon-1.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/trust-ball-icon-2.svg b/apps/web/public/images/cleanstart-platform/trust-ball-icon-2.svg deleted file mode 100644 index d66b6f7a6..000000000 --- a/apps/web/public/images/cleanstart-platform/trust-ball-icon-2.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/trust-ball-icon-3.svg b/apps/web/public/images/cleanstart-platform/trust-ball-icon-3.svg deleted file mode 100644 index 5046e41a5..000000000 --- a/apps/web/public/images/cleanstart-platform/trust-ball-icon-3.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/trust-ball-icon-4.svg b/apps/web/public/images/cleanstart-platform/trust-ball-icon-4.svg deleted file mode 100644 index 0a1668d84..000000000 --- a/apps/web/public/images/cleanstart-platform/trust-ball-icon-4.svg +++ /dev/null @@ -1,420 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/trust-cubes.svg b/apps/web/public/images/cleanstart-platform/trust-cubes.svg deleted file mode 100644 index ab5be7440..000000000 --- a/apps/web/public/images/cleanstart-platform/trust-cubes.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/cleanstart-platform/cta-cube-textured.webp b/apps/web/public/images/teams/cta-cube-textured.webp similarity index 100% rename from apps/web/public/images/cleanstart-platform/cta-cube-textured.webp rename to apps/web/public/images/teams/cta-cube-textured.webp diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index 3e4dd9b2b..535a03f26 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -4,7 +4,6 @@ ## Product Pages - [CleanStart Images](https://www.cleanstart.com/cleanstart-images): Pre-built, hardened container base images with near-zero CVEs and automatic versioned updates. - [CleanSight](https://www.cleanstart.com/cleansight): Continuous vulnerability visibility across your entire container estate. -- [CleanStart Platform](https://www.cleanstart.com/cleanstart-platform): Full platform overview — images, scanning, SBOM, and integrations. - [FIPS Compliance](https://www.cleanstart.com/fips): FIPS 140-2/3 validated container images for regulated industries. - [Software Bill of Materials (SBOM)](https://www.cleanstart.com/software-bill-materials): Automated SBOM generation and management for every image. - [Attack Surface Reduction](https://www.cleanstart.com/attack-surface-reduction): Shrink your container attack surface by removing unnecessary packages and layers. diff --git a/apps/web/src/app/cleanstart-platform/page.tsx b/apps/web/src/app/cleanstart-platform/page.tsx deleted file mode 100644 index 8503650d0..000000000 --- a/apps/web/src/app/cleanstart-platform/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Header } from "@/components/nav/Header"; -import { FadeUp } from "@/components/ui/FadeUp"; -import { Footer } from "@/components/sections/Footer"; -import { PlatformHero } from "@/components/sections/cleanstart-platform/PlatformHero"; -import { PlatformTrustSource } from "@/components/sections/cleanstart-platform/PlatformTrustSource"; -import { PlatformTrustArchitecture } from "@/components/sections/cleanstart-platform/PlatformTrustArchitecture"; -import { PlatformTrustedOutputs } from "@/components/sections/cleanstart-platform/PlatformTrustedOutputs"; -import { PlatformCTA } from "@/components/sections/cleanstart-platform/PlatformCTA"; -import { buildPageMetadata } from "@/lib/seo/canonical"; -import { breadcrumbSchema } from "@/lib/seo/jsonld"; -import { JsonLdGraph } from "@/components/JsonLdGraph"; -import { getPageGraph } from "@/lib/seo/compose-page"; - -export const metadata = buildPageMetadata({ - title: "Inside the CleanStart Platform", - description: - "AI-native software manufacturing for trusted runtime foundations. Explore how CleanStart builds trusted software through deterministic trust architecture.", - path: "/cleanstart-platform", - variant: "hero", - eyebrow: "Platform", - ogTitle: "Inside the CleanStart Platform", - titleAccent: "Platform", - // Page is not yet complete — keep it reachable by direct URL but out of the - // index until it ships. noindex,follow (the default) so link equity still flows. - noindex: true, -}); - -export const revalidate = 21600; // 6h ISR fallback — on-demand publish revalidation keeps this fresh - -export default async function CleanStartPlatformPage(): Promise { - const graph = await getPageGraph("/cleanstart-platform", [ - breadcrumbSchema([ - { name: "Home", path: "/" }, - { name: "CleanStart Platform" }, - ]), - ]); - return ( - <> - -
-
- - - - - - - - - - - - - -
-
} /> - - ); -} diff --git a/apps/web/src/app/compare/cleanstart-vs-docker-hardened-images/page.tsx b/apps/web/src/app/compare/cleanstart-vs-docker-hardened-images/page.tsx index 0cc5bdecc..a8fbedc3b 100644 --- a/apps/web/src/app/compare/cleanstart-vs-docker-hardened-images/page.tsx +++ b/apps/web/src/app/compare/cleanstart-vs-docker-hardened-images/page.tsx @@ -6,29 +6,18 @@ import { buildPageMetadata } from "@/lib/seo/canonical"; import { breadcrumbSchema, faqPageSchema } from "@/lib/seo/jsonld"; import { getPageGraph } from "@/lib/seo/compose-page"; import { CompareHero } from "@/components/sections/compare/CompareHero"; -import { CompareIntro } from "@/components/sections/compare/CompareIntro"; +import { CompareFoundations } from "@/components/sections/compare/CompareFoundations"; import { CompareMatrix } from "@/components/sections/compare/CompareMatrix"; -import { CompareSocialProof } from "@/components/sections/compare/CompareSocialProof"; -import { ComparePhilosophies } from "@/components/sections/compare/ComparePhilosophies"; -import { CompareBeyondCves } from "@/components/sections/compare/CompareBeyondCves"; -import { CompareBuilds } from "@/components/sections/compare/CompareBuilds"; -import { CompareProvenance } from "@/components/sections/compare/CompareProvenance"; -import { CompareReadiness } from "@/components/sections/compare/CompareReadiness"; -import { CompareChoose } from "@/components/sections/compare/CompareChoose"; +import { CompareBuildFlow } from "@/components/sections/compare/CompareBuildFlow"; +import { CompareDifferentiators } from "@/components/sections/compare/CompareDifferentiators"; import { CompareFAQ } from "@/components/sections/compare/CompareFAQ"; import { CompareCTA } from "@/components/sections/compare/CompareCTA"; -import { - COMPARE_FAQ_ITEMS, - TITLE_MAIN, -} from "@/components/sections/compare/compare-data"; - -const PATH = "/compare/cleanstart-vs-docker-hardened-images"; +import { FAQS, META, PATH, TITLE } from "@/components/sections/compare/compare-data"; export const metadata = buildPageMetadata({ - title: "Docker Hardened Images vs CleanStart | Full Comparison", + title: META.title, absoluteTitle: true, - description: - "Compare Docker Hardened Images vs CleanStart across security, provenance, compliance, SBOMs, deterministic builds, and software verification.", + description: META.description, path: PATH, eyebrow: "Comparison", /* @@ -36,8 +25,8 @@ export const metadata = buildPageMetadata({ * deliberate: `nofollow` is not the default for a per-page `noindex` (the * helper still emits `follow` so link equity flows), so it is set explicitly * here. Drop BOTH of these and re-add the path to `app/sitemap.ts` when the - * page ships — the sitemap entry is removed for as long as this is noindex, - * because listing a noindex URL is a contradictory signal. + * page ships — the sitemap entry stays removed for as long as this is + * noindex, because listing a noindex URL is a contradictory signal. */ noindex: true, nofollow: true, @@ -46,19 +35,22 @@ export const metadata = buildPageMetadata({ export const revalidate = 21600; // 6h ISR fallback — on-demand publish revalidation keeps this fresh /** - * The source copy is a fourteen-section technical article. Rather than give - * every heading its own band, related headings share one band and are separated - * by hairline rules, so the page reads in the site's light/dark section rhythm - * instead of as a stack of fourteen slabs. Every heading keeps the document's - * own wording and its H2 level; only the number of *bands* was reduced. + * Docker Hardened Images vs CleanStart. + * + * Five bands, one per heading in the source document, in the site's + * light/dark rhythm: hero (dark) → foundations (wash) → capability matrix + * (white) → build flow (dark) → differentiators (wash) → FAQ (white) → the + * footer CTA card. Every string is in `compare-data.ts`; the FAQ feeds both + * the rendered accordion and the FAQPage JSON-LD from the same array, so the + * two cannot drift. * - * `FadeUp` wraps the below-fold sections only — the hero renders visible for - * LCP. + * `FadeUp` wraps the below-fold sections only — the hero renders visible so it + * stays an LCP candidate. */ export default async function CleanStartVsDockerHardenedImagesPage(): Promise { const graph = await getPageGraph(PATH, [ - breadcrumbSchema([{ name: "Home", path: "/" }, { name: TITLE_MAIN }]), - faqPageSchema([...COMPARE_FAQ_ITEMS]), + breadcrumbSchema([{ name: "Home", path: "/" }, { name: TITLE }]), + faqPageSchema([...FAQS]), ]); return ( @@ -68,31 +60,16 @@ export default async function CleanStartVsDockerHardenedImagesPage(): Promise - + - - - - - - - - - - - - - - - - + - + diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index b60915c9a..445c0376e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1316,6 +1316,109 @@ body { after JS hydration (which stranded LCP behind hydration on every page). The fade-up is now purely presentational; offset/timing come from CSS vars set by the HeroReveal component. */ +/* SaaS hero artifact (SaasHeroAppSurface.tsx). Builds once, then holds. A hero artifact that loops competes with the + headline for attention. + + Opacity only, no transforms: these are SVG groups, where transform-origin + resolves against the viewBox rather than the element unless transform-box is + set, and a stagger of scaling groups is not worth that risk. + + Written settled-first, so reduced motion renders the finished artifact rather + than an empty frame. */ +@media (prefers-reduced-motion: no-preference) { + @keyframes cs-hero-band { + from { + opacity: 0; + } + } + + .cs-hero-band, + .cs-hero-fade { + animation: cs-hero-band 560ms ease-out both; + } + + /* A slow float on two isolated pieces, and only two. The point is to stop the + artifact reading as a flat print, not to animate it — a hero that keeps + moving competes with the headline beside it. + + Amplitude matters more than it sounds. A first pass used 3px over 6s, which + is half a pixel per second and sits below the threshold of perception: the + animation was running and correct, and nobody could see it. 8px over 5s is + still quiet but actually reads. + + The two run on OPPOSITE paths rather than the same keyframes offset by a + delay. Same-direction drift, however desynchronised, still reads as the + whole image swaying; opposing it reads as two things floating. + + Applied to WRAPPER groups that carry no transform attribute of their own. + The cards are positioned with SVG `transform="rotate(...) translate(...)"`, + and animating transform on those would fight the attribute; a bare wrapper + has nothing to collide with and needs no transform-origin. + + Both pieces sit clear of their neighbours, so the travel never carries one + into another. */ + @keyframes cs-hero-drift-a { + from { + transform: translate(-2px, -8px); + } + + to { + transform: translate(2px, 8px); + } + } + + @keyframes cs-hero-drift-b { + from { + transform: translate(3px, 7px); + } + + to { + transform: translate(-3px, -7px); + } + } + + .cs-hero-drift { + animation: cs-hero-drift-a 5s ease-in-out infinite alternate; + } + + .cs-hero-drift-b { + animation: cs-hero-drift-b 6.5s ease-in-out -1.5s infinite alternate; + } + + /* Cursor parallax. SaasHeroParallax.tsx publishes --cs-px / --cs-py in the + range -1..1; the layers read them here so the browser can do the work on the + compositor without React re-rendering the SVG. + + Amounts increase with proximity — the blurred back row moves least, the + surface more, the front cards most. That ordering is what turns three + stacked planes into actual depth rather than three flat groups sliding + together. + + The transition is deliberate rather than leftover. rAF writes a new value + every frame, so a short ease gives the layers a slight trail behind the + cursor, which reads as mass. Without it the movement is rigid and snaps. + + transform-origin is irrelevant here: these are pure translations, so the + SVG default of 0 0 costs nothing. The drift animations live on wrapper + groups INSIDE the front row, so parent parallax and child drift compose + instead of overwriting each other. */ + .cs-par { + transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1); + } + + .cs-par-back { + transform: translate(calc(var(--cs-px, 0) * 5px), calc(var(--cs-py, 0) * 4px)); + } + + .cs-par-mid { + transform: translate(calc(var(--cs-px, 0) * 10px), calc(var(--cs-py, 0) * 8px)); + } + + .cs-par-front { + transform: translate(calc(var(--cs-px, 0) * 18px), calc(var(--cs-py, 0) * 14px)); + } +} + @keyframes cs-hero-reveal { from { opacity: 0; @@ -1365,28 +1468,26 @@ body { } } -/* Home hero H1 — "type-on → true-focus" then "focus-settle + living accent" - (see HeroHeading.tsx). Pure CSS so the LCP H1 text paints from the server - HTML; clip/opacity/blur live only in keyframes (never base rules), so - reduced-motion (animation: none) falls back to the final, fully-visible - heading. Timeline: 0.15s type-on starts → ~1.05s typed → focus snap → - 1.25s line 2 focus-settles → ~2.2s accent shimmer + settle glint. */ +/* Home hero H1 — "reject, then correct": the industry's "Hardened. Secure." + types on and gets struck through, then "Verified" rises above it as the + brand's actual claim (see HeroHeading.tsx). Pure CSS so the LCP H1 text + paints from the server HTML; clip/opacity/blur live only in keyframes + (never base rules where avoidable), so reduced-motion (animation: none) + falls back to the final, fully-corrected, fully-visible heading. Timeline: + 0.2s type-on starts → ~1.5s "Hardened. Secure." typed → 1.6s strike + + desaturate lands on "Hardened" → 2s "Verified" rises → ~2.8s shine + starts, line 3 focus-settles → ~3.5s fully settled. */ .cs-hero-h1 { position: relative; } -/* 1 · "Verified. Secure." — stepped clip reveal (type-on) held soft, then a - smooth blur→sharp focus snap once the line is fully revealed. */ -.cs-hh-typewrap { - display: inline-block; - position: relative; - white-space: nowrap; -} - -.cs-hh-type { - display: inline-block; - /* Brand cyan→purple gradient (shared H2 stops, .cs-text-gradient-impact), - mirrored periodic so the continuous drift loops seamlessly. */ +/* Shared brand cyan→purple gradient text treatment (shared H2 stops, + .cs-text-gradient-impact), mirrored periodic so the continuous drift + loops seamlessly. Used by "Verified", "Secure.", and "Hardened." while + it's still typing (before the strike desaturates it). */ +.cs-hh-verified, +.cs-hh-secure, +.cs-hh-hardened-grad { background-image: linear-gradient(100deg, #2cc1eb 0%, #9a51ff 50%, @@ -1396,13 +1497,47 @@ body { background-clip: text; color: transparent; -webkit-text-fill-color: transparent; +} + +/* 1 · "Verified" — the correction line. Rises into focus after the + "Hardened" strike lands, then joins "Secure." in the continuous shine. */ +.cs-hh-verified { + display: block; + animation: + cs-hh-verified-rise 0.8s cubic-bezier(0.16, 1, 0.3, 1) 2s both, + cs-hh-shine 6s linear 2.8s infinite; +} + +@keyframes cs-hh-verified-rise { + from { + opacity: 0; + filter: blur(10px); + transform: translateY(0.15em); + } + + to { + opacity: 1; + filter: blur(0); + transform: none; + } +} + +/* 2 · "Hardened. Secure." — stepped clip reveal (type-on) for the whole + line, held soft, then a smooth blur→sharp focus snap once fully + revealed. */ +.cs-hh-typewrap { + display: block; + position: relative; + white-space: nowrap; +} + +.cs-hh-line2 { + display: inline-block; animation: - cs-hh-reveal 0.9s steps(17) 0.15s both, - cs-hh-snap 0.5s ease-out 1.05s both, - cs-hh-shine 6s linear 1.6s infinite; - /* No will-change: the clip/blur entrance is one-shot, and the looping - background-position shine is a paint (not a compositable) property, so a - layer hint buys nothing while holding memory on the LCP heading. */ + cs-hh-reveal 1.3s steps(17) 0.2s both, + cs-hh-snap 0.7s ease-out 1.5s both; + /* No will-change: the clip/blur entrance is one-shot, so a layer hint + buys nothing while holding memory on the LCP heading. */ } @keyframes cs-hh-reveal { @@ -1425,6 +1560,89 @@ body { } } +/* "Hardened." types on in the gradient (so the type-on still reads as one + phrase), then — once the strike lands — crossfades to a muted gray twin + stacked in the same position. The trailing period is its own wrapper: it + shares the fade but sits outside the struck box, so the bar crosses the + word only. */ +.cs-hh-hardened, +.cs-hh-hardened-dot { + position: relative; + display: inline-block; + /* One step lighter than the H1's 600 (Manrope 500 is already loaded, so this + costs no extra font payload on the LCP heading). The rejected term reads + as subordinate to the "Verified"/"Secure." claims that keep the full + weight. Manrope has no true italic, so no font-style here — a synthesised + oblique shears badly at display sizes. */ + font-weight: 500; +} + +.cs-hh-hardened::after { + content: ""; + position: absolute; + /* Flush to the word, no overshoot: the box holds "Hardened" alone, so the + bar ends before the period rather than running on toward "Secure." */ + left: 0; + right: 0; + top: 50%; + /* Whole-pixel height so the bar renders crisp rather than antialiasing + soft across rows (a 2.5px line reads no heavier than a 2px one). */ + height: 3px; + /* Vermillion: saturated enough to read as a decisive cut, and far enough + around the wheel from the brand cyan/purple that it reads as negating + the word rather than decorating it. */ + background: #ef4b39; + border-radius: 2px; + transform-origin: left center; + animation: cs-hh-strike 0.55s ease-out 1.6s both; +} + +@keyframes cs-hh-strike { + from { + transform: scaleX(0); + } + + to { + transform: scaleX(1); + } +} + +.cs-hh-hardened-grad { + animation: cs-hh-fade-out 0.3s ease-out 1.6s both; +} + +.cs-hh-hardened-gray { + position: absolute; + inset: 0; + color: #8892a4; + opacity: 0; + animation: cs-hh-fade-in 0.3s ease-out 1.6s both; +} + +@keyframes cs-hh-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes cs-hh-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.cs-hh-secure { + animation: cs-hh-shine 6s linear 1.95s infinite; +} + /* Blinking caret that travels with the reveal edge, then fades as focus runs. Lives on the wrapper so the inner element's clip-path doesn't crop it. */ .cs-hh-typewrap::after { @@ -1436,9 +1654,9 @@ body { height: 0.82em; background: currentColor; animation: - cs-hh-caret-move 0.9s steps(17) 0.15s both, - cs-hh-blink 0.7s step-end 0.15s infinite, - cs-hh-caret-hide 0.25s linear 1.05s forwards; + cs-hh-caret-move 1.3s steps(17) 0.2s both, + cs-hh-blink 0.7s step-end 0.2s infinite, + cs-hh-caret-hide 0.35s linear 1.5s forwards; } @keyframes cs-hh-caret-move { @@ -1469,10 +1687,11 @@ body { } } -/* 2 · "Built for the AI Era." — focus-settle entrance after the type-on line. */ +/* 3 · "Built for the AI Era." — focus-settle entrance after the correction + has landed. */ .cs-hh-phrase { - display: inline-block; - animation: cs-hh-focus 0.8s cubic-bezier(0.16, 1, 0.3, 1) 1.25s both; + display: block; + animation: cs-hh-focus 1.1s cubic-bezier(0.16, 1, 0.3, 1) 2.4s both; /* One-shot focus-settle — no persistent compositor-layer hint. */ } @@ -1490,8 +1709,9 @@ body { } } -/* Continuous seamless drift for the colored "Verified. Secure." line: scroll - the periodic gradient by exactly one tile width so it loops with no snap. */ +/* Continuous seamless drift for the gradient "Verified"/"Secure." text: + scroll the periodic gradient by exactly one tile width so it loops with + no snap. */ @keyframes cs-hh-shine { from { background-position: 0 0; @@ -1504,16 +1724,36 @@ body { @media (prefers-reduced-motion: reduce) { - .cs-hh-type, + .cs-hh-verified, + .cs-hh-line2, + .cs-hh-hardened::after, + .cs-hh-hardened-grad, + .cs-hh-hardened-gray, + .cs-hh-secure, .cs-hh-phrase { animation: none; } - /* Hold a static cyan→purple gradient on line 1 when motion is off. */ - .cs-hh-type { + /* Hold the final, fully-corrected state when motion is off: "Verified" + and "Secure." visible in a static gradient, "Hardened." shown as its + already-struck-through gray twin. */ + .cs-hh-verified, + .cs-hh-secure { background-position: 0 0; } + .cs-hh-hardened-grad { + opacity: 0; + } + + .cs-hh-hardened-gray { + opacity: 1; + } + + .cs-hh-hardened::after { + transform: scaleX(1); + } + .cs-hh-typewrap::after { display: none; } @@ -5582,7 +5822,7 @@ body { } } -/* ROI calculator — "compounding chain" (RoiHowItWorks). Metro-line on desktop, +/* Impact Estimator page: "compounding chain" (ImpactHowItWorks). Metro-line on desktop, vertical timeline on mobile. Tier-coloured stations with a growing severity meter. Pseudo-elements + keyframes live here; motion is reduced-motion aware. */ .cs-chain { diff --git a/apps/web/src/app/impact-estimator/page.tsx b/apps/web/src/app/impact-estimator/page.tsx new file mode 100644 index 000000000..5ed079532 --- /dev/null +++ b/apps/web/src/app/impact-estimator/page.tsx @@ -0,0 +1,55 @@ +import type React from "react"; +import { Header } from "@/components/nav/Header"; +import { Footer } from "@/components/sections/Footer"; +import { FadeUp } from "@/components/ui/FadeUp"; +import { ImpactHero } from "@/components/sections/impact-estimator/ImpactHero"; +import { ImpactSimulator } from "@/components/sections/impact-estimator/ImpactSimulator"; +import { ImpactHowItWorks } from "@/components/sections/impact-estimator/ImpactHowItWorks"; +import { ImpactFAQ } from "@/components/sections/impact-estimator/ImpactFAQ"; +import { ImpactCTA } from "@/components/sections/impact-estimator/ImpactCTA"; +import { FAQS } from "@/components/sections/impact-estimator/impact-content"; +import { buildPageMetadata } from "@/lib/seo/canonical"; +import { breadcrumbSchema, faqPageSchema } from "@/lib/seo/jsonld"; +import { JsonLdGraph } from "@/components/JsonLdGraph"; +import { getPageGraph } from "@/lib/seo/compose-page"; + +export const metadata = buildPageMetadata({ + title: "Impact Estimator for Hardened Container Images | CleanStart", + absoluteTitle: true, + description: + "Estimate what hardened container images change for your runtime: vulnerability noise, patch cycles, release speed, footprint, and engineering hours recovered.", + path: "/impact-estimator", + eyebrow: "Tools", +}); + +export const revalidate = 21600; // 6h ISR fallback; on-demand publish revalidation keeps this fresh + +export default async function ImpactEstimatorPage(): Promise { + const graph = await getPageGraph("/impact-estimator", [ + breadcrumbSchema([ + { name: "Home", path: "/" }, + { name: "Impact Estimator" }, + ]), + faqPageSchema([...FAQS]), + ]); + + return ( + <> + +
+
+ + + + + + + + + + +
+
} /> + + ); +} diff --git a/apps/web/src/app/industries/financial-services-container-security/page.tsx b/apps/web/src/app/industries/financial-services-container-security/page.tsx index 6378553ec..405872b2c 100644 --- a/apps/web/src/app/industries/financial-services-container-security/page.tsx +++ b/apps/web/src/app/industries/financial-services-container-security/page.tsx @@ -19,11 +19,11 @@ import { getPageGraph } from "@/lib/seo/compose-page"; * * Title, description and H1 are the SEO team's, applied verbatim. * - * First page under the /industries segment, with saas-container-security as - * its sibling. The segment is the one exception to this site's otherwise flat - * routing (every other static page is a single segment, including the - * /for-developers + /for-ciso role family) and it is deliberate: two committed - * children and a named nav family, same reasoning as /compare. + * First page under the /industries segment, with modern-applications (the + * SaaS page) as its sibling. The segment is the one exception to this site's + * otherwise flat routing (every other static page is a single segment, + * including the /for-developers + /for-ciso role family) and it is deliberate: + * two committed children and a named nav family, same reasoning as /compare. * * NOTE: /industries itself has no page.tsx and therefore 404s. A segment with * no hub is a dead end for anyone who truncates the URL, and it forfeits the @@ -38,7 +38,7 @@ import { getPageGraph } from "@/lib/seo/compose-page"; * * Launched: the noindex,nofollow pair is dropped and the path is listed in the * sitemap's STATIC_ROUTES. The breadcrumb, JsonLdGraph and pageRegistry row - * were already in place. Its sibling /industries/saas-container-security stays + * were already in place. Its sibling /industries/modern-applications stays * noindex,nofollow and unlisted, pending sign-off on its copy. */ export const metadata = buildPageMetadata({ diff --git a/apps/web/src/app/industries/saas-container-security/page.tsx b/apps/web/src/app/industries/modern-applications/page.tsx similarity index 61% rename from apps/web/src/app/industries/saas-container-security/page.tsx rename to apps/web/src/app/industries/modern-applications/page.tsx index e9ef6f764..90cce8bdc 100644 --- a/apps/web/src/app/industries/saas-container-security/page.tsx +++ b/apps/web/src/app/industries/modern-applications/page.tsx @@ -16,23 +16,26 @@ import { JsonLdGraph } from '@/components/JsonLdGraph'; import { getPageGraph } from '@/lib/seo/compose-page'; /* - * /industries/saas-container-security + * /industries/modern-applications * - * Title, description and H1 are the SEO team's, applied verbatim. Sibling to - * financial-services-container-security under the /industries segment; see that - * file for why the segment exists and why /industries itself still 404s. + * Title and description are the SEO team's, applied verbatim; the H1 is the + * client's. Sibling to financial-services-container-security under the + * /industries segment; see that file for why the segment exists and why + * /industries itself still 404s. * - * Renamed from /saas, which never resolved in production (it returned 404 - * there, so no redirect is needed — unlike its sibling, which did resolve). + * Built as /saas, then /industries/saas-container-security, and settled here + * on 2026-09-02 before ever being indexed or linked, so the earlier paths + * carry no redirects. The pageRegistry row keys on path; update it to this + * path or the WebPage node drops out of the graph. * - * Still noindex,nofollow and out of the sitemap pending copy approval, but it - * now carries the same breadcrumb + JsonLdGraph pair and pageRegistry row as + * Launched: the noindex,nofollow pair is dropped, the path is listed in the + * sitemap's STATIC_ROUTES and the Solutions > By industry nav row is restored. + * It carries the same breadcrumb + JsonLdGraph pair and pageRegistry row as * its sibling, so it emits the full Organization + WebSite + WebPage + - * BreadcrumbList graph. To launch: drop the two flags and add the path to the - * sitemap's STATIC_ROUTES. Nothing else is outstanding. + * BreadcrumbList graph. * - * The breadcrumb is Home > SaaS, with no Industries crumb, because /industries - * has no page yet and the crumb would link to a 404. + * The breadcrumb is Home > Modern Applications, with no Industries crumb, + * because /industries has no page and the crumb would link to a 404. * * Band rhythm, in order: dark hero, white, tinted, DARK, white, tinted, DARK. * Only one dark run reaches the end of the page. The Footer is itself a dark @@ -40,25 +43,23 @@ import { getPageGraph } from '@/lib/seo/compose-page'; * blocks into the close; the two light sections are separated by value instead. */ export const metadata = buildPageMetadata({ - title: 'Container Security for SaaS Companies | CleanStart', + title: 'Modern Application Security | CleanStart', absoluteTitle: true, description: - 'Protect SaaS applications with hardened container images, near-zero CVEs, SBOMs, signed provenance, and continuous software supply chain visibility.', - path: '/industries/saas-container-security', + 'Secure modern applications with verified software components, hardened container images, and trusted open-source libraries built for faster, safer software delivery.', + path: '/industries/modern-applications', eyebrow: 'Solutions', - noindex: true, - nofollow: true, }); export const revalidate = 21600; // 6h ISR fallback — on-demand publish revalidation keeps this fresh export default async function SaasPage(): Promise { - const graph = await getPageGraph('/industries/saas-container-security', [ - breadcrumbSchema([{ name: 'Home', path: '/' }, { name: 'SaaS' }]), + const graph = await getPageGraph('/industries/modern-applications', [ + breadcrumbSchema([{ name: 'Home', path: '/' }, { name: 'Modern Applications' }]), ]); return ( <> - +
diff --git a/apps/web/src/app/roi-calculator/page.tsx b/apps/web/src/app/roi-calculator/page.tsx deleted file mode 100644 index e5ade9bf5..000000000 --- a/apps/web/src/app/roi-calculator/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type React from "react"; -import { Header } from "@/components/nav/Header"; -import { Footer } from "@/components/sections/Footer"; -import { FadeUp } from "@/components/ui/FadeUp"; -import { RoiHero } from "@/components/sections/roi-calculator/RoiHero"; -import { RoiSimulator } from "@/components/sections/roi-calculator/RoiSimulator"; -import { RoiHowItWorks } from "@/components/sections/roi-calculator/RoiHowItWorks"; -import { RoiCTA } from "@/components/sections/roi-calculator/RoiCTA"; -import { buildPageMetadata } from "@/lib/seo/canonical"; -import { breadcrumbSchema } from "@/lib/seo/jsonld"; -import { JsonLdGraph } from "@/components/JsonLdGraph"; -import { getPageGraph } from "@/lib/seo/compose-page"; - -export const metadata = buildPageMetadata({ - title: "ROI Calculator — Operational Impact of Hardened Images | CleanStart", - absoluteTitle: true, - description: - "Estimate the operational impact of moving to minimal, trusted container images — Vulnerability Noise Reduction, Patch Cycle Overhead Reduction, Faster Secure Releases, Runtime Footprint Reduction, and Engineering Hours Recovered.", - path: "/roi-calculator", - eyebrow: "Tools", - // Not ready for search — kept out of the sitemap and nav (built: false), and - // both noindex + nofollow'd here so crawlers that reach it directly neither - // index it nor pass equity onward. Drop both (and add the route to sitemap - // STATIC_ROUTES) when the page ships. - noindex: true, - nofollow: true, -}); - -export const revalidate = 21600; // 6h ISR fallback — on-demand publish revalidation keeps this fresh - -export default async function RoiCalculatorPage(): Promise { - const graph = await getPageGraph("/roi-calculator", [ - breadcrumbSchema([ - { name: "Home", path: "/" }, - { name: "ROI Calculator" }, - ]), - ]); - - return ( - <> - -
-
- - - - - - - -
-
} /> - - ); -} diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index 2c40ec562..f19b9fe2c 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -97,9 +97,9 @@ const STATIC_ROUTES: ReadonlyArray<{ path: string }> = [ { path: '/clean-libraries' }, { path: '/cleansight' }, { path: '/cleanstart-images' }, - // `/cleanstart-platform` is intentionally de-listed — the page is not yet - // complete, so it is noindex'd and excluded from the sitemap. Re-add when it - // ships (and drop the `noindex` in its page metadata). + // `/cleanstart-platform` was deleted 2026-09-02 — the page was never finished + // and shipped noindex, unlinked and unlisted, so nothing was de-ranked. Recover + // the route and its sections from git history when it is rebuilt. { path: '/community' }, // `/compare/cleanstart-vs-docker-hardened-images` is intentionally de-listed // — the page is not signed off yet, so it is noindex,nofollow and excluded @@ -112,11 +112,10 @@ const STATIC_ROUTES: ReadonlyArray<{ path: string }> = [ { path: '/for-ciso' }, { path: '/for-developers' }, { path: '/guide' }, + { path: '/impact-estimator' }, { path: '/industries/financial-services-container-security' }, - // Its sibling `/industries/saas-container-security` is intentionally de-listed - // — the copy is not signed off, so the page stays noindex,nofollow. Re-add it - // when it ships (and drop the `noindex` / `nofollow` in its page metadata). - // `/industries` itself has no page, so there is no hub URL to list either. + { path: '/industries/modern-applications' }, + // `/industries` itself has no page, so there is no hub URL to list. // `/knowledge-hub` is a redirect to the first article (no standalone listing) — // excluded here. The individual /knowledge-hub/ articles are emitted below. // `/legal` is a 308 redirect (not a page) — excluded here. The individual diff --git a/apps/web/src/components/nav/MobileNav.tsx b/apps/web/src/components/nav/MobileNav.tsx index a0913d123..065fb1185 100644 --- a/apps/web/src/components/nav/MobileNav.tsx +++ b/apps/web/src/components/nav/MobileNav.tsx @@ -83,7 +83,7 @@ export function MobileNav() { {item.label} - +
    {leaves.map((leaf, i) => "__header" in leaf ? ( diff --git a/apps/web/src/components/nav/icons/glyphs.tsx b/apps/web/src/components/nav/icons/glyphs.tsx index 8186a8682..8efe59e1f 100644 --- a/apps/web/src/components/nav/icons/glyphs.tsx +++ b/apps/web/src/components/nav/icons/glyphs.tsx @@ -31,6 +31,15 @@ export const glyphs: Record = { ), + // Half-dial with a needle: the Impact Estimator's own radial gauge, reduced. + gauge: ( + <> + + + + + + ), 'shield-check': ( <> @@ -150,6 +159,15 @@ export const glyphs: Record = { ), + // Stacked components: the same mark the Modern Applications hero uses for + // Verified Components, so the menu row and the page it opens share a symbol. + layers: ( + <> + + + + + ), star: , mail: ( <> diff --git a/apps/web/src/components/sections/cleanstart-platform/PlatformCTA.tsx b/apps/web/src/components/sections/cleanstart-platform/PlatformCTA.tsx deleted file mode 100644 index 8195fa2fa..000000000 --- a/apps/web/src/components/sections/cleanstart-platform/PlatformCTA.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import Link from "next/link"; - -export function PlatformCTA() { - return ( -
    - {/* Decorative union bg pattern */} - {/* eslint-disable-next-line @next/next/no-img-element */} - - - {/* Purple glow — top-left */} -
    - - {/* Cyan glow — bottom-right */} -
    - - {/* Textured cube — bleeds out bottom-right corner */} - {/* eslint-disable-next-line @next/next/no-img-element */} - - - {/* Content */} -
    - {/* Left: title */} -

    - Reconstruct Trust from the Source -

    - - {/* Right: description + button */} -
    -

    - Explore how CleanStart builds trusted software foundations through - AI-native intelligence and deterministic trust architecture. -

    - - - Browse Images - -
    -
    -
    - ); -} diff --git a/apps/web/src/components/sections/cleanstart-platform/PlatformHero.tsx b/apps/web/src/components/sections/cleanstart-platform/PlatformHero.tsx deleted file mode 100644 index 7269ee20e..000000000 --- a/apps/web/src/components/sections/cleanstart-platform/PlatformHero.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import Image from "next/image"; -import Link from "next/link"; - -export function PlatformHero() { - return ( -
    - {/* 3D Platform Cube — right side. Grid + glow are nested so they stay - locked to the image footprint and don't bleed across the hero. */} -
    - {/* Purple glow — centered behind the cube */} -
    - - {/* Background vector grid — centered behind the cube, fades via radial gradient */} - - - - - - - - - - - {/* Cube sits above the grid + glow */} - CleanStart Platform Architecture -
    - - {/* Text content — centered on mobile, left-aligned from lg */} -
    -
    -

    - Inside the CleanStart Platform -

    -

    - AI-native software manufacturing for trusted runtime foundations. -

    -
    - - {/* CTA Button — site-standard glassmorphism style */} - - See the Trust Architecture in Motion - -
    - -
    - ); -} diff --git a/apps/web/src/components/sections/cleanstart-platform/PlatformTrustArchitecture.tsx b/apps/web/src/components/sections/cleanstart-platform/PlatformTrustArchitecture.tsx deleted file mode 100644 index 37a5b9737..000000000 --- a/apps/web/src/components/sections/cleanstart-platform/PlatformTrustArchitecture.tsx +++ /dev/null @@ -1,279 +0,0 @@ -/** - * "The CleanStart Trust Architecture" — vertical trust-flow timeline. - * - * A central SOURCE CODE card feeds a glowing connector that drops and elbows - * into a left-hand spine running through four numbered stage cards (01–04). - * Ported from Figma node 1199:3974. The spine is built from per-row CSS line - * segments (each spanning its row + the gap below) so it stays continuous and - * pinned to the ball centres regardless of how the card text reflows. - */ - -interface Stage { - number: string; - name: string; - subtitle: string; - description: string; - features: string[]; -} - -const STAGES: Stage[] = [ - { - number: "01", - name: "Tricorder", - subtitle: "AI Logic Engine", - description: "Analyze software before artifacts exist.", - features: ["Source-level intelligence", "Behavioral graph mapping", "AI-native anomaly detection"], - }, - { - number: "02", - name: "CleanCompile", - subtitle: "Deterministic Reconstruction", - description: "Rebuild verified software in hermetic environments.", - features: ["Hermetic build pipelines", "Reproducible outputs", "Cryptographic attestation"], - }, - { - number: "03", - name: "CleanImage", - subtitle: "Trusted Runtime Assembly", - description: "Assemble minimal, hardened runtime environments.", - features: ["Minimal runtime images", "BusyBox replacement", "CleanStart OS foundations"], - }, - { - number: "04", - name: "The Vault", - subtitle: "Trusted Runtime Foundations", - description: "Deliver continuously rebuilt and verifiable outputs.", - features: ["Zero known CVE foundations", "Shell-less environments", "Continuously verifiable outputs"], - }, -]; - -const CARD_BG = "linear-gradient(177deg, rgba(255,255,255,0) 13.17%, rgba(154,81,255,0.30) 85.26%)"; -const SOURCE_BG = "linear-gradient(177deg, rgba(255,255,255,0) 13.17%, rgba(154,81,255,0.10) 85.26%)"; - -/** - * Glassy neon connector segment — a bright gradient core with a soft blurred - * halo (Figma: 12px Linear stroke + Layer blur + "Plus lighter" blend). The - * caller positions/sizes the track via `className` (a thin band along the run); - * the core + halo fill it. `cap` rounds the run's ends so segments meeting at a - * corner read as a smooth rounded bend. - */ -function GlassLine({ orientation, className }: { orientation: "v" | "h"; className: string }) { - const v = orientation === "v"; - const core = v ? "inset-y-0 left-1/2 w-[3px] -translate-x-1/2" : "inset-x-0 top-1/2 h-[3px] -translate-y-1/2"; - const halo = v ? "inset-y-0 left-1/2 w-2.5 -translate-x-1/2" : "inset-x-0 top-1/2 h-2.5 -translate-y-1/2"; - return ( - - {/* Uniform (non-directional) colours so per-row segments tile seamlessly - into one continuous line with no dim band at the joins. */} - - - - ); -} - -function NumberBall({ number }: { number: string }) { - return ( -
    - - {number} - -
    - ); -} - -function Check({ label }: { label: string }) { - return ( -
  • - {/* eslint-disable-next-line @next/next/no-img-element */} - - - {label} - -
  • - ); -} - -function StageRow({ stage, isLast }: { stage: Stage; isLast: boolean }) { - return ( -
    - {/* Soft mint→magenta glow (Figma 光斑) hugging the spine, behind the ball */} -
    - {/* Spine segment — spans this row (and the gap below, except the last) so - stacked rows form one continuous glowing line through the ball centres. */} - - {/* Ball → card connector stub (Figma Vector) — plugs the ball into the card */} - - -
    - {/* Right-edge lens flare (Figma "Flare") — a tall vertically-elongated - streak hugging the right border, brightest (mint-white) at mid-height - and blooming into magenta then purple. The 264px height is clipped by - the card so the bright mid-band spans the full card height, exactly as - in Figma (the flare overruns the card top & bottom). */} -
    -
    -
    -

    - {stage.name} -

    -

    - {stage.subtitle} -

    -

    - {stage.description} -

    -
    -
      - {stage.features.map((feat) => ( - - ))} -
    -
    -
    - ); -} - -export function PlatformTrustArchitecture() { - return ( -
    - {/* Cyan flare, top-right corner (Figma 光斑) */} -
    - - {/* Header */} -
    -
    -

    - The CleanStart Trust{" "} - - Architecture - -

    -

    - AI-native trust reconstruction. Deterministic by design. -

    -
    -
    - - {/* Timeline */} -
    - {/* SOURCE CODE card */} -
    -

    - SOURCE CODE -

    -

    - Verified repositories and trusted upstream software sources. -

    -
    - - {/* Connector elbow: drop from source centre, turn left to the spine. - The glassy lines carry a flare burst at the source junction. */} -
    - {/* Flare burst + streak where the line leaves the SOURCE CODE card */} - - - {/* drop (centre) → turn left → down into the spine */} - - - -
    - - {/* Stage rows */} -
    - {STAGES.map((stage, i) => ( - - ))} -
    -
    -
    - ); -} diff --git a/apps/web/src/components/sections/cleanstart-platform/PlatformTrustSource.tsx b/apps/web/src/components/sections/cleanstart-platform/PlatformTrustSource.tsx deleted file mode 100644 index 1f51e4dcd..000000000 --- a/apps/web/src/components/sections/cleanstart-platform/PlatformTrustSource.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/** - * "Trust Begins at the Source" — 2×2 pinwheel card grid. - * - * Four trust-stage cards arranged in a 2×2 grid; each card's single large - * (62px) corner points toward the grid centre, forming a pinwheel. Collapses - * to a single column below `sm`. Ported 1:1 from Figma node 1199:2996. - */ - -interface TrustCard { - title: string; - body: string; - icon: string; - /** Tailwind radius classes — one large corner points toward the grid centre. */ - radius: string; -} - -const CARDS: TrustCard[] = [ - { - title: "Analyze Before Artifacts Exist", - body: "Source-level intelligence before packaging begins.", - icon: "/images/cleanstart-platform/trust-ball-icon-2.svg", - radius: "rounded-[8px] sm:rounded-br-[62px]", - }, - { - title: "Rebuild From Verified Source", - body: "Deterministic reconstruction inside hermetic environments.", - icon: "/images/cleanstart-platform/trust-ball-icon-1.svg", - radius: "rounded-[8px] sm:rounded-bl-[62px]", - }, - { - title: "Assemble Minimal Runtimes", - body: "Only the required components. Nothing is unnecessary.", - icon: "/images/cleanstart-platform/trust-ball-icon-4.svg", - radius: "rounded-[8px] sm:rounded-tr-[62px]", - }, - { - title: "Deliver Trusted Foundations", - body: "Continuously rebuilt and verifiable runtime environments.", - icon: "/images/cleanstart-platform/trust-ball-icon-3.svg", - radius: "rounded-[8px] sm:rounded-tl-[62px]", - }, -]; - -export function PlatformTrustSource() { - return ( -
    - {/* Corner decorations — pinned to the 1440 frame corners, behind content */} -
    - {/* Cyan glow blobs (Ellipse 46691 / 46692) — pure CSS, blurred circles */} -
    -
    - {/* Cube clusters — full-frame SVG pinned to each corner; the opposite - cluster is pushed off-frame and clipped by the section overflow. */} - {/* eslint-disable-next-line @next/next/no-img-element */} - - {/* eslint-disable-next-line @next/next/no-img-element */} - -
    - - {/* Header */} -
    -
    -

    - Trust Begins at the{" "} - - Source - -

    -
    -

    Security must begin before software becomes a package, container, or runtime artifact.

    -

    - CleanStart analyzes software intent, reconstructs verified components, and assembles minimal runtime - foundations before inherited risk can propagate downstream. -

    -
    -
    -
    - - {/* 2×2 pinwheel card grid */} -
    -
    - {CARDS.map((card) => ( -
    -
    - {/* eslint-disable-next-line @next/next/no-img-element */} - -
    -

    - {card.title} -

    -

    - {card.body} -

    -
    - ))} -
    -
    - -
    -
    - ); -} diff --git a/apps/web/src/components/sections/cleanstart-platform/PlatformTrustedOutputs.tsx b/apps/web/src/components/sections/cleanstart-platform/PlatformTrustedOutputs.tsx deleted file mode 100644 index 8b75d8264..000000000 --- a/apps/web/src/components/sections/cleanstart-platform/PlatformTrustedOutputs.tsx +++ /dev/null @@ -1,185 +0,0 @@ -interface OutputCardProps { - icon: string; - title: string; - description: string; -} - -function OutputCard({ icon, title, description }: OutputCardProps) { - return ( -
    - {/* Top purple glow */} -
    - - {/* Decorative grid lines */} -
    - {[48.5, 120, 162, 234].map((x) => ( -
    - ))} - {[68, 184].map((y) => ( -
    - ))} -
    - - {/* Blue ball icon */} -
    - {/* eslint-disable-next-line @next/next/no-img-element */} - -
    - - {/* Text */} -
    -

    - {title} -

    -

    - {description} -

    -
    - - {/* Cyan border glow */} -
    -
    - ); -} - -export function PlatformTrustedOutputs() { - return ( -
    - {/* Corner decorations */} - {/* eslint-disable-next-line @next/next/no-img-element */} - - {/* eslint-disable-next-line @next/next/no-img-element */} - - -
    - {/* Title */} -

    - Trusted Outputs Across the{" "} - - Platform - -

    - - {/* Cards grid */} -
    - - - - -
    -
    -
    - ); -} diff --git a/apps/web/src/components/sections/compare/CompareBeyondCves.tsx b/apps/web/src/components/sections/compare/CompareBeyondCves.tsx deleted file mode 100644 index a38fcca05..000000000 --- a/apps/web/src/components/sections/compare/CompareBeyondCves.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { Container, Section } from "@/components/layout"; -import { Reveal, RevealItem, RevealStagger } from "@/components/ui/Reveal"; -import { BEYOND_CVES } from "./compare-data"; -import { ListLead, Prose, SectionHeading } from "./compare-editorial"; -import { - BAND_DARK, - EllipseGlow, - HexOutline, - Icon3D, -} from "./compare-visuals"; - -/** - * Security: More Than Reducing CVEs. - * - * Two movements, and the one place on the page where a light-to-dark break is - * earned rather than decorative: the light half lists what hardening buys, then - * the article turns — one question answered, five left open — and the dark band - * carries that turn. - */ - -const BENEFIT_ICONS: readonly string[] = [ - "/images/cleanstart-images/uvp-icon-smaller-images.webp", - "/images/attack-surface-reduction/approach-icon-minimal.webp", - "/images/for-developers/why/icon-remediation.webp", - "/images/cleanstart-images/uvp-icon-memory.webp", - "/images/cleanstart-images/uvp-icon-attack-surface.webp", -]; - -const COLUMN_TEXT: React.CSSProperties = { - fontFamily: "var(--font-sans)", - fontSize: "var(--fs-body)", - lineHeight: 1.6, - letterSpacing: "-0.01em", - maxWidth: "44ch", -}; - -export function CompareBeyondCves(): React.ReactElement { - return ( - <> -
    - - - - {BEYOND_CVES.heading} - - - {BEYOND_CVES.benefitsLead} - - - {BEYOND_CVES.benefits.map((benefit, index) => ( - -
    - -

    - {benefit} -

    -
    -
    - ))} -
    -
    -
    - - {/* ── The turn ── */} -
    - - - - - -
    -
    - -

    - {BEYOND_CVES.pivot} -

    -
    - -

    - {BEYOND_CVES.answered} -

    -
    - -

    - {BEYOND_CVES.close} -

    -
    -
    - -
    - -

    - {BEYOND_CVES.unansweredLead} -

    -
    - - - {BEYOND_CVES.unanswered.map((question, index) => ( - -
    - - {index + 1} - - - {question} - -
    -
    - ))} -
    -
    -
    -
    -
    - - ); -} diff --git a/apps/web/src/components/sections/compare/CompareBuildFlow.tsx b/apps/web/src/components/sections/compare/CompareBuildFlow.tsx new file mode 100644 index 000000000..cc6157c64 --- /dev/null +++ b/apps/web/src/components/sections/compare/CompareBuildFlow.tsx @@ -0,0 +1,218 @@ +import { Section, Container } from "@/components/layout"; +import { Reveal, RevealStagger, RevealItem } from "@/components/ui/Reveal"; +import { BUILD_FLOW, type BuildFlowColumn } from "./compare-data"; +import { + BAND_DARK, + BRAND, + DarkPanel, + EllipseGlow, + HexOutline, +} from "./compare-visuals"; + +/** + * "How Do Docker Hardened Images and CleanStart Build Secure Container Images?" + * + * The document gives each platform an ordered build approach — five stages for + * Docker, seven for CleanStart — so the section draws them as two pipelines on + * one baseline. The panels are equal height and the stages are top-aligned, + * which means the two extra verification stages read as the difference they + * are instead of being flattened into a tidy pair of matching lists. + * + * On the site's dark band, because this is where the argument turns and the + * page needs a change of ground between two light sections. + */ + +function Pipeline({ + column, +}: { + column: BuildFlowColumn; +}): React.ReactElement { + const isCleanStart = column.id === "cleanstart"; + + return ( + + + +

    + {column.label} +

    + +

    + {column.body} +

    + +

    + {column.stepsLabel} +

    + + {/* The rail. `
      ` because the stages are an order, not a set. */} +
        + + {column.steps.map((step) => ( +
      1. + + + {step} + +
      2. + ))} +
      + +

      + {column.traitsLabel} +

      + +
        + {column.traits.map((trait) => ( +
      • + {trait} +
      • + ))} +
      + + ); +} + +export function CompareBuildFlow(): React.ReactElement { + return ( +
      + + + + +
      + +

      + {BUILD_FLOW.heading} +

      +
      + + +

      + {BUILD_FLOW.intro} +

      +
      +
      + + + {BUILD_FLOW.columns.map((column) => ( + + + + ))} + +
      +
      + ); +} diff --git a/apps/web/src/components/sections/compare/CompareBuilds.tsx b/apps/web/src/components/sections/compare/CompareBuilds.tsx deleted file mode 100644 index 9eb6c0820..000000000 --- a/apps/web/src/components/sections/compare/CompareBuilds.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { Container, Section } from "@/components/layout"; -import { Reveal, RevealItem, RevealStagger } from "@/components/ui/Reveal"; -import { ReproducibleBuildProof } from "./compare-artifacts"; -import { HERMETIC, REPRODUCIBLE, SOURCE_BUILT } from "./compare-data"; -import { ListLead, P, Prose, SectionHeading } from "./compare-editorial"; -import { BAND_DARK, Icon3D, VectorGrid } from "./compare-visuals"; - -/** - * Building from Source · Hermetic and Deterministic Builds · Reproducible - * Builds — the article's three build-assurance sections, set as three movements - * inside ONE band instead of three stacked slabs. - * - * Every heading stays an H2 with the document's own wording. The consolidation - * here is visual: one background, one decoration pass, hairline rules between - * movements. Demoting these to H3 would have needed an invented parent heading, - * which the copy document does not supply. - */ - -const SOURCE_ICONS: readonly string[] = [ - "/images/compare/icon-origin.webp", - "/images/for-developers/why/icon-development.webp", - "/images/compare/icon-provenance.webp", - "/images/sbom/risk-icon-incomplete.webp", -]; - -export function CompareBuilds(): React.ReactElement { - return ( - <> -
      - - {/* ── Building from Source ── */} -
      - - {SOURCE_BUILT.heading} - - - {SOURCE_BUILT.listLead} - - - {(SOURCE_BUILT.items ?? []).map((item, index) => ( - - {/* - * Four discrete capabilities, so four cards — separate boxes - * here rather than the single divided panel the opening - * questions use, which keeps the two light sections from - * reading as the same layout twice. - */} -
      - - -

      - {item} -

      -
      -
      - ))} -
      - - -
      -
      -
      - - {/* - * ── Hermetic and Deterministic Builds ── - * - * Its own dark band, for two reasons. Structurally, the five movements from - * "Building from Source" to "SBOMs and AI BOMs" ran to 4,287px of unbroken - * light — #F6F6F6 against #FFFFFF is not a perceptible change, so it read - * as one slab. Conceptually, this is the section about a sealed environment - * that cannot reach outside itself, and the inversion is the enclosure. - */} -
      - {/* Bottom-right, and well off the edge. The prohibitions panel runs the - full container width, so a plate at the default bleed sat underneath - it; this one is pushed out until it clears the panel entirely. */} - - -
      - - {HERMETIC.heading} - - - - {/* The four prohibitions are the one list on this page that is a set - of negatives, so they are struck rather than ticked. */} - -
      -

      - {HERMETIC.listLead} -

      -
        - {(HERMETIC.items ?? []).map((item) => ( -
      • - - - {item} - -
      • - ))} -
      -
      -
      - - -
      -
      -
      - - {/* ── Reproducible Builds ── */} -
      - -
      - - {REPRODUCIBLE.heading} - - -
      -
      -

      {REPRODUCIBLE.lead}

      - -

      - {REPRODUCIBLE.question} -

      -
      - -
      - - {/* - * The section asks a question, so this answers it rather than - * restating it: two builds that agree on nothing — different - * builder, different day, different machine — and produce the same - * digest. That is what "reproducible" means, shown instead of - * asserted. - */} - - - -
      - - {/* The article's own one-line thesis, at display scale on the band. - It carried a dark card before; the size alone is enough. */} - -

      - {REPRODUCIBLE.pull} -

      -
      -

      {REPRODUCIBLE.close}

      -
      -
      -
      - - ); -} - -function Cross(): React.ReactElement { - return ( - - - - ); -} diff --git a/apps/web/src/components/sections/compare/CompareCTA.tsx b/apps/web/src/components/sections/compare/CompareCTA.tsx index 860bceba4..77154e047 100644 --- a/apps/web/src/components/sections/compare/CompareCTA.tsx +++ b/apps/web/src/components/sections/compare/CompareCTA.tsx @@ -1,7 +1,18 @@ /* - * Comparison CTA — white card rendered inside the Footer's locked CTA slot. - * Follows the FipsCTA / CleanSight treatment: decorative purple grid, corner - * glow ellipses, a violet cube, dark text, solid blue button. + * Closing CTA — the white card that paints inside the Footer's locked CTA slot. + * + * Geometry (overlap, radius, clipping) belongs to `Footer.tsx`; this file only + * fills the slot, following the FipsCTA / CleanSight treatment: purple corner + * bloom, the shared union plate, a violet cube, dark type, solid blue button. + * + * The headline is an `

      ` rather than a styled `

      `. The source document + * sets "Build With Verified Container Images" as a heading, and dropping it to + * a paragraph because the card sits in the footer would lose the last section + * of the outline SEO wrote. + * + * One DOM across all breakpoints. The phone layout is the same elements + * centred, not a second copy: a duplicated headline would put the page's + * closing H2 in the markup twice. */ "use client"; @@ -10,10 +21,26 @@ import Link from "next/link"; import { Reveal } from "@/components/ui/Reveal"; import { CTA } from "./compare-data"; -const HEADLINE = CTA.heading; -const DESCRIPTION = CTA.body; -const BUTTON_LABEL = CTA.button; -const BUTTON_HREF = "/book-a-demo"; +function Bloom({ + className, + style, +}: { + className?: string; + style: React.CSSProperties; +}): React.ReactElement { + return ( +

      + ); +} export function CompareCTA(): React.ReactElement { return ( @@ -27,7 +54,7 @@ export function CompareCTA(): React.ReactElement { aria-hidden src="/images/cleansight/cta-union.svg" alt="" - className="pointer-events-none select-none absolute hidden lg:block" + className="pointer-events-none absolute hidden select-none lg:block" style={{ left: "547px", top: "-220px", @@ -39,60 +66,44 @@ export function CompareCTA(): React.ReactElement { decoding="async" /> -
      -
      -
      -
      -

      - {HEADLINE} -

      + {CTA.heading} +

      - {DESCRIPTION} + {CTA.body}

      - {BUTTON_LABEL} + {CTA.button}
    - -
    - {/* eslint-disable-next-line @next/next/no-img-element */} - - -

    - {HEADLINE} -

    - -

    - {DESCRIPTION} -

    - - - {BUTTON_LABEL} - -
    ); } diff --git a/apps/web/src/components/sections/compare/CompareChoose.tsx b/apps/web/src/components/sections/compare/CompareChoose.tsx deleted file mode 100644 index ac82fc520..000000000 --- a/apps/web/src/components/sections/compare/CompareChoose.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { Container, Section } from "@/components/layout"; -import { Reveal, RevealItem, RevealStagger } from "@/components/ui/Reveal"; -import { - CHOOSING, - FINAL_THOUGHTS, - WHICH_BETTER, - WHICH_BETTER_ALT_HEADING, -} from "./compare-data"; -import { P, Prose, SectionHeading } from "./compare-editorial"; -import { - BAND_DARK, - EllipseGlow, - RULE_LIGHT, -} from "./compare-visuals"; - -/** - * Choosing the Right Approach · Is CleanStart the Right Alternative · Which - * solution is better? · Final Thoughts. - * - * The two recommendations are the one place on this page where a panel is the - * right affordance — this is a decision, and each option has to be picked up as - * a unit. They get identical chrome and differ only in tint: the article - * recommends Docker Hardened Images without qualification for a real set of - * buyers, and dressing our side up would misrepresent it. - * - * Final Thoughts closes on a dark band, so the article ends where the hero - * began. - */ - -const OPTIONS = [ - { - ...CHOOSING.dhi, - logo: "/images/cleanstart-images/workflows-docker.webp", - branded: false, - }, - { - ...CHOOSING.cleanstart, - logo: "/images/security/cs-logomark.svg", - branded: true, - }, -] as const; - -export function CompareChoose(): React.ReactElement { - return ( - <> -
    - -
    - - {CHOOSING.heading} - - -
    - -
    - {/* H3, not H2. The source document sets "Which solution is - better?" at its own H2 level (one below every section heading), - and the SEO comment asks for this alternative-intent heading to - be added "as an h2" — i.e. as that heading's sibling. Both - therefore sit under the Choosing H2. */} - - {WHICH_BETTER_ALT_HEADING} - - - - {OPTIONS.map((option) => ( - -
    -
    - {/* Both marks sit on the same dark tile. The CleanStart - logomark is white-and-cyan, so it needs a dark ground; - giving Docker's mark a white tile instead would make - the pair look accidental rather than compared. */} - - {/* eslint-disable-next-line @next/next/no-img-element */} - - - {/* Not a heading. The source document sets these two - vendor names as labels inside the recommendation - copy, not as document structure; promoting them to - H3 would add two headings the SEO outline does not - have. */} -

    - {option.name} -

    -
    - -

    - {option.text} -

    -
    -
    - ))} -
    - - -

    {CHOOSING.close}

    -
    -
    - -
    - - {WHICH_BETTER.heading} - - -
    -
    -
    - - {/* ── Final Thoughts ── */} -
    - - - -
    -
    - -

    - {FINAL_THOUGHTS.heading} -

    -
    - -

    - {FINAL_THOUGHTS.pull} -

    -
    -
    - -
    - {FINAL_THOUGHTS.body.map((text, index) => ( - -

    - {text} -

    -
    - ))} -
    -
    -
    -
    - - ); -} diff --git a/apps/web/src/components/sections/compare/CompareDifferentiators.tsx b/apps/web/src/components/sections/compare/CompareDifferentiators.tsx new file mode 100644 index 000000000..39930a343 --- /dev/null +++ b/apps/web/src/components/sections/compare/CompareDifferentiators.tsx @@ -0,0 +1,76 @@ +import { Section, Container } from "@/components/layout"; +import { Reveal, RevealStagger, RevealItem } from "@/components/ui/Reveal"; +import { DIFFERENTIATORS } from "./compare-data"; +import { CornerTile, cornerAt, Icon3D, WASH_LIGHT } from "./compare-visuals"; + +/** + * "Where CleanStart Differentiates" and its three sub-headings. + * + * These are the document's only H2s, so they are the page's only H3s. Three + * `SbomAdvantage` corner tiles carrying the violet 3D icon set already in + * `public/images/compare`; the oversized corner rotates across the row, which + * is the rhythm the site gets out of that tile rather than a colour rotation. + */ +export function CompareDifferentiators(): React.ReactElement { + return ( +
    + +
    + +

    + {DIFFERENTIATORS.heading} +

    +
    +
    + + + {DIFFERENTIATORS.items.map((item, index) => ( + + + + +

    + {item.heading} +

    + +

    + {item.body} +

    +
    +
    + ))} +
    +
    +
    + ); +} diff --git a/apps/web/src/components/sections/compare/CompareFAQ.tsx b/apps/web/src/components/sections/compare/CompareFAQ.tsx index 8d85c810a..4a46828dc 100644 --- a/apps/web/src/components/sections/compare/CompareFAQ.tsx +++ b/apps/web/src/components/sections/compare/CompareFAQ.tsx @@ -1,207 +1,200 @@ "use client"; -import { useState } from "react"; -import Link from "next/link"; -import { Reveal, RevealStagger, RevealItem } from "@/components/ui/Reveal"; -import { COMPARE_FAQS, FAQ_HEADING, type CompareFaq } from "./compare-data"; +import { useId, useState } from "react"; +import { Section, Container } from "@/components/layout"; +import { Reveal } from "@/components/ui/Reveal"; +import { FAQS, FAQ_HEADING, UI, type CompareFaq } from "./compare-data"; +import { BRAND } from "./compare-visuals"; /** - * Comparison FAQ — Balanced 2-Column Split: - * - Left: Heading, intro copy, and quick support/demo callout card. - * - Right: Sleek accordion cards with smooth grid-height transitions and glowing purple toggle discs. + * The eight questions from the document, as a single-open accordion. + * + * The questions are `

    ` inside the section's H2. The document does not style + * them as headings, but an accordion whose trigger is not in a heading gives + * screen-reader users no way to walk the list, and `

    ` here nests under the + * FAQ H2 without adding a level the document does not already have. + * + * Answers stay in the DOM when collapsed (`hidden` on a wrapper, height + * animated by a grid row) so the text is in the page source for crawlers and + * matches the FAQPage JSON-LD the route emits. */ -export function CompareFAQ(): React.ReactElement { - const [openId, setOpenId] = useState(COMPARE_FAQS[0]?.id ?? null); - return ( -
    -
    -
    - {/* Left Column: Heading & Help Callout */} -
    - -

    - {FAQ_HEADING} -

    -

    - Everything you need to know about container image security, provenance, reproducible builds, and compliance. -

    -
    - - {/* Quick Demo Callout Box */} - -
    - {/* A UI callout, not part of the document outline. */} -

    - Have more questions? -

    -

    - Speak directly with our security engineering team to explore CleanStart for your supply chain. -

    - - Book a Technical Demo - - - - -
    -
    -
    - - {/* Right Column: Interactive Accordion Cards */} -
    - {/* No gap — the rows share one continuous set of rules, and a - bottom rule closes the list. */} - - {COMPARE_FAQS.map((faq) => ( - - setOpenId(openId === faq.id ? null : faq.id)} - /> - - ))} - -
    -
    -
    -
    - ); -} - -function FaqCard({ - item, - isOpen, +function Row({ + faq, + open, onToggle, + index, }: { - item: CompareFaq; - isOpen: boolean; + faq: CompareFaq; + open: boolean; onToggle: () => void; + index: number; }): React.ReactElement { - const answerId = `compare-faq-answer-${item.id}`; + const panelId = `compare-faq-panel-${faq.id}`; + const buttonId = `compare-faq-trigger-${faq.id}`; + return ( - /* - * A rule, not a card. Seven bordered, filled, shadowed boxes stacked on top - * of each other read as seven objects; seven rows under one rule read as - * one list — which is what an FAQ is. The open row is marked by its - * question colour and the toggle alone, so nothing moves sideways. - */ -

    -

    -
    -
    -

    + {faq.question} + + + - {item.answer} -

    + + + + + +

    + +
    +
    +
    +

    + {faq.answer} +

    +
    -
    + ); } -/** Glowing purple toggle disc. */ -function ToggleDisc({ isOpen }: { isOpen: boolean }): React.ReactElement { +export function CompareFAQ(): React.ReactElement { + const [openId, setOpenId] = useState(FAQS[0].id); + const headingId = useId(); + return ( - - - - - + +
    +
    + +

    + {FAQ_HEADING} +

    +

    + {UI.faqIntro} +

    +
    +
    + + +
      + {FAQS.map((faq, index) => ( + + setOpenId((current) => (current === faq.id ? null : faq.id)) + } + /> + ))} +
    +
    +
    +
    + ); } - diff --git a/apps/web/src/components/sections/compare/CompareFoundationStacks.tsx b/apps/web/src/components/sections/compare/CompareFoundationStacks.tsx new file mode 100644 index 000000000..8f3a9bf90 --- /dev/null +++ b/apps/web/src/components/sections/compare/CompareFoundationStacks.tsx @@ -0,0 +1,226 @@ +import { HERO_DIAGRAM, VENDOR } from "./compare-data"; +import { BRAND } from "./compare-visuals"; + +/** + * The hero artwork: two image stacks drawn side by side. + * + * The whole comparison turns on one asymmetry — Docker Hardened Images stand on + * a base inherited from an upstream distribution, CleanStart stands on nothing — + * so the diagram draws exactly that and nothing else. The Docker column has a + * parent block feeding into it; the CleanStart column has an empty frame where + * that parent would be. Every label is a phrase the capability matrix below + * also makes, so the picture never gets ahead of the table. + * + * Pure CSS on brand colours: no new asset, nothing to keep in sync with Figma, + * and it re-flows instead of scaling a fixed-size raster down to mud. + */ + +const SLAB_H = "clamp(38px, 3.1vw, 46px)"; + +function Slab({ + label, + tone, + index, +}: { + label: string; + tone: "docker" | "cleanstart"; + /** Depth in the stack, used to brighten the CleanStart slabs as they rise. */ + index: number; +}): React.ReactElement { + const isCleanStart = tone === "cleanstart"; + return ( +
    + + + {label} + +
    + ); +} + +function Column({ + vendor, + inherited, + link, + layers, + tone, +}: { + vendor: string; + inherited: { label: string; detail: string }; + link: string; + layers: readonly string[]; + tone: "docker" | "cleanstart"; +}): React.ReactElement { + const isCleanStart = tone === "cleanstart"; + return ( +
    +

    + {vendor} +

    + + {/* The parent block. Solid on the Docker side, an empty frame on ours. */} +
    + + {inherited.label} + + + {inherited.detail} + +
    + + {/* The connector. It is the diagram's whole point that these differ. */} +
    + + + {link} + +
    + +
    + {layers.map((layer, i) => ( + + ))} +
    +
    + ); +} + +export function CompareFoundationStacks(): React.ReactElement { + return ( +
    +
    + +
    + + +
    + +
    + {HERO_DIAGRAM.caption} +
    +
    + ); +} diff --git a/apps/web/src/components/sections/compare/CompareFoundations.tsx b/apps/web/src/components/sections/compare/CompareFoundations.tsx new file mode 100644 index 000000000..7bc778cc2 --- /dev/null +++ b/apps/web/src/components/sections/compare/CompareFoundations.tsx @@ -0,0 +1,206 @@ +import { Section, Container } from "@/components/layout"; +import { Reveal, RevealStagger, RevealItem } from "@/components/ui/Reveal"; +import { FOUNDATIONS } from "./compare-data"; +import { BRAND, LightBandDecor, WASH_LIGHT } from "./compare-visuals"; + +/** + * "What Are Docker Hardened Images and How Do They Compare With CleanStart?" + * + * Two panels, deliberately not symmetrical in weight. The Docker panel is a + * plain white tile with a slate rule; the CleanStart panel carries the site's + * gradient assurance card. Same structure, same type scale, same bullet count — + * the page is not hiding the comparison, it is just clear about whose site this + * is. + * + * The vendor names and the two "focuses on" lead-ins are `

    `, not headings: + * the source document does not set them as headings, and promoting them would + * add an outline level SEO never wrote. + */ + +function FocusMarker({ + tone, +}: { + tone: "docker" | "cleanstart"; +}): React.ReactElement { + return ( + + ); +} + +function Panel({ + column, + tone, +}: { + column: (typeof FOUNDATIONS.columns)[number]; + tone: "docker" | "cleanstart"; +}): React.ReactElement { + const isCleanStart = tone === "cleanstart"; + return ( +

    + {isCleanStart && ( + + )} + +
    + {/* Accent rule above the name: the page's one colour-coded axis. */} + + +

    + {column.label} +

    + +

    + {column.body} +

    + +

    + {column.focusLabel} +

    + +
      + {column.focus.map((item) => ( +
    • + + + {item} + +
    • + ))} +
    +
    +
    + ); +} + +export function CompareFoundations(): React.ReactElement { + return ( +
    + + + +
    + +

    + {FOUNDATIONS.heading} +

    +
    + + +

    + {FOUNDATIONS.intro} +

    +
    +
    + + + {FOUNDATIONS.columns.map((column) => ( + + + + ))} + +
    +
    + ); +} diff --git a/apps/web/src/components/sections/compare/CompareHero.tsx b/apps/web/src/components/sections/compare/CompareHero.tsx index d06009059..29bc1b878 100644 --- a/apps/web/src/components/sections/compare/CompareHero.tsx +++ b/apps/web/src/components/sections/compare/CompareHero.tsx @@ -1,20 +1,20 @@ import Link from "next/link"; import { HeroReveal } from "@/components/ui/Reveal"; -import { TITLE_SUB, UI_CHROME } from "./compare-data"; -import { CompareHeroArtifact } from "./CompareHeroArtifact"; +import { HERO_CTA, STANDFIRST, TITLE_PARTS, UI } from "./compare-data"; +import { CompareFoundationStacks } from "./CompareFoundationStacks"; import { Glow } from "./compare-visuals"; /** - * Comparison hero: title, standfirst and jump link against the illustrated - * artifact the page goes on to interrogate. + * Comparison hero: the document's title and standfirst on the left, the + * foundation-stack diagram on the right. * - * The four opening questions deliberately do NOT live here. `INTRO_LEAD` ends - * on a colon and the questions complete that sentence, so they travel together - * into `CompareQuestions` rather than being split across a column boundary. + * Two calls to action, ranked. The document's own CTA ("Explore CleanStart + * Images") is the primary; the jump link to the capability matrix is the quiet + * secondary, because a visitor arriving on a comparison query wants the table + * and would otherwise scroll past four sections to reach it. * - * Ends flat — no fade band into the section below. Height is tuned to sit in - * the same range as the other page heroes (/pricing ~549px, /fips ~667px at - * 1720w) rather than towering over them. + * The diagram is desktop-only. Stacked under the title on a phone it costs a + * full screen above the fold and pushes both CTAs out of view. */ export function CompareHero(): React.ReactElement { return ( @@ -22,9 +22,18 @@ export function CompareHero(): React.ReactElement { data-section="CompareHero" className="relative overflow-hidden bg-cs-hero" > - - - + +
    -
    - {/* Left: title, standfirst, jump link */} +

    - Docker Hardened Images vs{" "} - CleanStart + {TITLE_PARTS.lead} + + {TITLE_PARTS.accent} + + {TITLE_PARTS.tail}

    @@ -62,36 +73,56 @@ export function CompareHero(): React.ReactElement { letterSpacing: "-0.02em", lineHeight: 1.35, color: "rgba(255,255,255,0.68)", - maxWidth: "34ch", + maxWidth: "38ch", marginTop: "clamp(18px, 1.8vw, 26px)", }} > - {TITLE_SUB} + {STANDFIRST}

    - - {UI_CHROME.jumpToMatrix} - + {/* + * Stock CTA pair, no per-page variants. `cs-btn-blue` is the + * dark-hero primary the two most recent heroes use (SaasHero, + * FinanceHero) at h44 / px24 / fs16, and it matches the blue + * button in this page's own footer CTA. `cs-btn-ghost` is the + * site's only secondary, at the PricingHero / LibrariesHero + * sizing. + */} +
    + + {HERO_CTA.label} + + + + {UI.jumpToMatrix} + +
    - {/* - * Right: the artifact. Desktop only — stacked under the title on a - * phone it costs a screenful above the fold and pushes the CTA out - * of view without adding meaning. - */}
    - +
    diff --git a/apps/web/src/components/sections/compare/CompareHeroArtifact.tsx b/apps/web/src/components/sections/compare/CompareHeroArtifact.tsx deleted file mode 100644 index 36602f5eb..000000000 --- a/apps/web/src/components/sections/compare/CompareHeroArtifact.tsx +++ /dev/null @@ -1,209 +0,0 @@ - - -/** - * Animated Container Supply Chain & Provenance Comparison Engine. - * - * Visualizing the core architectural difference between: - * - Docker Hardened Images (Surface Hardening / SLSA Level 3) - * - CleanStart Verified Images (Deterministic Hermetic Pipeline / SLSA Level 4 / AI BOM) - * - * Pure SVG + CSS animation system. - */ -export function CompareHeroArtifact(): React.ReactElement { - return ( -
    - - - - - {/* Ambient Pools */} - - - - - - - - - - {/* Core Gradients */} - - - - - - - - - - - - {/* Ambient Ground Pools */} - - - - {/* Connection Link Between Platforms */} - - - {/* LEFT NODE: Docker Hardened Images */} - - {/* Base Container Slabs */} - - - - - - - - - {/* DHI Shield Header */} - - - - {/* Left Label Chip */} - - - - Docker Hardened Images - - - - - {/* RIGHT NODE: CleanStart Verified Platform (Hero Core) */} - - {/* Active Scanner Line Beam */} - - - - - {/* Layer 1 (Bottom) */} - - - - - {/* Layer 2 (Middle) */} - - - - - {/* Layer 3 (Top Verified Crown) */} - - - - - {/* Glowing Aura Ring */} - - - {/* Top Verification Seal */} - - - - - - {/* Floating Feature Micro-Chips */} - {/* - * Chip wording is verbatim from the source document. "aligned" is - * load-bearing: the document gives Docker an attained "SLSA Build - * Level 3" and CleanStart "SLSA Level 4 aligned", which is not a - * claim of certification. Do not shorten it to "SLSA Level 4". - */} - - - - SLSA Level 3 aligned - - - - - - - AI BOM - - - - {/* Bottom CleanStart Label */} - - - - CleanStart Verified Images - - - - -
    - ); -} diff --git a/apps/web/src/components/sections/compare/CompareIntro.tsx b/apps/web/src/components/sections/compare/CompareIntro.tsx deleted file mode 100644 index f70c8d4ba..000000000 --- a/apps/web/src/components/sections/compare/CompareIntro.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Container, Section } from "@/components/layout"; -import { Reveal, RevealItem, RevealStagger } from "@/components/ui/Reveal"; -import { cn } from "@/lib/cn"; -import { INTRO_BODY, INTRO_LEAD, OPENING_QUESTIONS } from "./compare-data"; -import { Icon3D, LightBandDecor, WASH_LIGHT } from "./compare-visuals"; - -/** - * The article's opening block, kept whole. - * - * In the source document everything between the title and the "At a Glance" - * heading is one untitled run: the lead paragraph, the four questions, then - * three closing paragraphs. No subheading divides it, so none is added here. - * - * The questions carry icons and no numbers: the document renders them as a - * plain bulleted list, so numbering them would assert a sequence the source - * does not. - */ - -const QUESTION_ICONS: readonly string[] = [ - "/images/compare/icon-origin.webp", - "/images/for-developers/why/icon-development.webp", - "/images/about/icon-continuous-compliance.webp", - "/images/compare/icon-regulatory.webp", -]; - -export function CompareIntro(): React.ReactElement { - return ( -
    - - - - -

    - {INTRO_LEAD} -

    -
    - - {/* - * ONE container, not four. Four separate tiles made these read as four - * product features; four bare columns left the band looking unresolved. - * A single panel divided by hairlines holds the set together — the - * questions are one thought in the document, and this is one object. - */} - -
    - {/* Violet wash from the top-left corner only, so the panel has - depth without becoming a coloured block. */} - - - - {OPENING_QUESTIONS.map((question, index) => ( - -
    0 ? "lg:border-l" : "", - index >= 2 ? "sm:border-t lg:border-t-0" : "", - )} - style={{ padding: "clamp(24px, 2.2vw, 34px)" }} - > - -

    - {question} -

    -
    -
    - ))} -
    -
    -
    - - - {INTRO_BODY.map((text, index) => ( -

    - {text} -

    - ))} -
    -
    -
    - ); -} diff --git a/apps/web/src/components/sections/compare/CompareMatrix.tsx b/apps/web/src/components/sections/compare/CompareMatrix.tsx index 04f504d0d..71ed2ee70 100644 --- a/apps/web/src/components/sections/compare/CompareMatrix.tsx +++ b/apps/web/src/components/sections/compare/CompareMatrix.tsx @@ -1,373 +1,400 @@ import { Section, Container } from "@/components/layout"; import { Reveal } from "@/components/ui/Reveal"; import { - KEY_TAKEAWAY, - KEY_TAKEAWAY_LABEL, - MATRIX_HEADING, - MATRIX_ROWS, - UI_CHROME, - VENDOR_CLEANSTART, - VENDOR_DHI, - type MatrixRow, + MATRIX, + MATRIX_ROW_COUNT, + UI, + VENDOR, + type MatrixCell, } from "./compare-data"; -import { RULE } from "./compare-editorial"; +import { BRAND, VectorGrid } from "./compare-visuals"; /** - * The capability comparison, rendered flat — header plus fifteen rows — because - * that is exactly what the source document's table is. See the note in - * compare-data.ts on why the category bands were removed. + * The capability matrix — the section a visitor arriving on a comparison query + * came for, so it gets the page's widest column and its only sticky chrome. + * + * One DOM, two layouts. It is a real `` with a caption, column headers + * and `scope="colgroup"` group rows; below `lg` the table parts flip to + * `display: block` and each row becomes a labelled card. Rendering a second + * mobile copy of twenty rows would double the markup and duplicate every + * string in the page source. + * + * Because that flip is a breakpoint change, every property that differs + * between the two layouts is a class, never an inline `style` — an inline + * declaration would win over the `max-lg:` variant and strand the mobile + * layout with desktop padding. Inline styles here carry colour and type only. + * + * The CleanStart column is tinted for its full height and capped with a violet + * rule. Twenty rows is more than the eye can track across three columns; the + * tint is what keeps the reader in the right one. + * + * The document writes "✓" and "—" in some cells. Those become markers with an + * accessible name rather than bare punctuation, so a screen reader announces + * "Available" instead of reading a dash or skipping the glyph entirely. */ -const CLEANSTART_TINT = "rgba(106, 61, 240, 0.035)"; +/** Shared padding/border rhythm for the twenty data rows. */ +const CELL = + "align-top border-b border-[rgba(17,17,17,0.06)] px-[clamp(16px,1.4vw,24px)] py-[clamp(14px,1.15vw,18px)] max-lg:block max-lg:border-0 max-lg:px-0 max-lg:py-0"; -export function CompareMatrix(): React.ReactElement { - return ( -
    - - {/* Section Heading — Pure H2 without eyebrow kicker */} - -

    - {MATRIX_HEADING} -

    -
    - - {/* Matrix Table */} - -
    - - - - - - - - - - - - {MATRIX_ROWS.map((row) => ( - - ))} - -
    {UI_CHROME.matrixCaption}
    - Capability -
    - - - {/* Legend & Trademark Footer */} - -
    - - - - - {UI_CHROME.legendIncluded} - - - - — - - {UI_CHROME.legendAbsent} - -
    -
    - - {/* - * The section's closing beat, set as a statement rather than a tinted - * card. This is the one sentence a reader should leave the table with, - * and scale carries that better than a box does — the table above is - * already the section's one enclosed object. - */} - -
    - {/* The document prefixes this paragraph "Key takeaway:" — real - sourced text, so it stands as the label. */} - - {KEY_TAKEAWAY_LABEL} - +const HEAD_CELL = + "sticky z-10 top-[calc(var(--cs-header-h)+8px)] text-left align-bottom px-[clamp(16px,1.4vw,24px)] py-[18px]"; -
    - {KEY_TAKEAWAY} -
    -
    -
    +function YesMark({ + tone, +}: { + tone: "docker" | "cleanstart"; +}): React.ReactElement { + const isCleanStart = tone === "cleanstart"; + return ( + + + + + + + {UI.available} + + ); +} - - +function NoMark(): React.ReactElement { + return ( + + + {UI.notAvailable} + ); } -/** Vendor Column Header */ -function VendorColumnHead({ - label, - logo, - branded = false, +function Cell({ + cell, + tone, }: { - label: string; - /** The vendor's own mark. On a page that names a competitor, the real logo - * is both more legible and more honest than a stand-in glyph. */ - logo: string; - branded?: boolean; + cell: MatrixCell; + tone: "docker" | "cleanstart"; }): React.ReactElement { + if (cell.kind === "yes") return ; + if (cell.kind === "no") return ; return ( - -
    - - {/* eslint-disable-next-line @next/next/no-img-element */} - - - - {label} - -
    - + {cell.value} +
    ); } -/** Matrix Table Row */ -function MatrixTableRow({ row }: { row: MatrixRow }): React.ReactElement { - const emphasised = row.divergent === true; - const padY = emphasised ? "clamp(16px, 1.5vw, 22px)" : "clamp(12px, 1.1vw, 16px)"; - +/** + * Column label repeated inside every cell below `lg`, where the head is gone. + * The CleanStart label is violet: with the column tint dropped on the card + * layout, the label colour is the only thing left carrying the page's one + * colour-coded axis. + */ +function CellLabel({ + children, + tone, +}: { + children: string; + tone: "docker" | "cleanstart"; +}): React.ReactElement { return ( - - - - {row.capability} - - - - - + + {children} + ); } -/** Matrix Table Cell */ -function MatrixCell({ - cell, - padY, - isCleanStart = false, +function HeadCell({ + vendor, + tone, }: { - cell: MatrixRow["docker"] | MatrixRow["cleanstart"]; - padY: string; - isCleanStart?: boolean; + vendor: string; + tone: "docker" | "cleanstart"; }): React.ReactElement { + const isCleanStart = tone === "cleanstart"; return ( - -
    - {cell.state === "yes" && !cell.note && ( - - - - - - {UI_CHROME.legendIncluded} - - - )} + + + {vendor} + + + ); +} - {cell.state === "no" && ( - - - Not offered - - )} +export function CompareMatrix(): React.ReactElement { + const lastGroupId = MATRIX.groups[MATRIX.groups.length - 1]?.id; - {cell.state === "yes" && cell.note && ( -
    - + {/* + * The bleed clip lives on this layer, not on the section. `overflow: + * hidden` on the section would make it the scroll container the sticky + * column head resolves against, and the head would stop sticking. + */} +
    + +
    + + +
    + +

    - - - + + + +

    - {cell.note} - -

    - )} + {MATRIX.intro} +

    - {cell.state === "text" && cell.note && ( - + {MATRIX_ROW_COUNT} capabilities + + · + + {MATRIX.groups.length} categories +

    + +
    + + + {/* + * `overflow: clip`, not `hidden`. Both round the table's corners, but + * `hidden` makes this a scroll container and a scroll container is + * what `position: sticky` resolves against — the column head would + * silently stop sticking. `clip` does not create one. + */} +
    - {cell.note} - - )} -
    - - ); -} + + -function Tick({ stroke = "#111111" }: { stroke?: string }): React.ReactElement { - return ( - - - - ); -} + + + + + -function Dash(): React.ReactElement { - return ( - - - + + + + + + + + + {MATRIX.groups.map((group) => ( + + + + + + {group.rows.map((row, rowIndex) => { + const isFinalRow = + group.id === lastGroupId && + rowIndex === group.rows.length - 1; + // The card's own border already draws this line; a cell + // border here would double it. + const edge = isFinalRow ? " border-b-0" : ""; + + return ( + + + + + + + + ); + })} + + ))} +
    {MATRIX.caption}
    + Capability +
    + {group.label} +
    + + {row.capability} + + + {VENDOR.docker} + + + {VENDOR.cleanstart} + +
    +
    + + + +

    + {MATRIX.footnote} +

    +
    + + ); } diff --git a/apps/web/src/components/sections/compare/ComparePhilosophies.tsx b/apps/web/src/components/sections/compare/ComparePhilosophies.tsx deleted file mode 100644 index bd0c89014..000000000 --- a/apps/web/src/components/sections/compare/ComparePhilosophies.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { Container, Section } from "@/components/layout"; -import { Reveal, RevealItem, RevealStagger } from "@/components/ui/Reveal"; -import { - PHILOSOPHIES_SECTION, - PHILOSOPHY_CLEANSTART, - PHILOSOPHY_DHI, -} from "./compare-data"; -import { - BAND_DARK, - EllipseGlow, - VectorGrid, -} from "./compare-visuals"; - -/** - * Two Different Security Philosophies. - * - * The two sides are deliberately unequal in shape — the article gives Docker a - * six-item list and CleanStart four running paragraphs — so they are set as two - * panels free to differ in length rather than forced into matching boxes. - * - * Each side is headed by the vendor's own mark rather than an invented glyph. - * On a page that names a competitor, the real logo is both more legible and - * more honest than a stand-in icon. - */ - -const BODY_STYLE: React.CSSProperties = { - fontFamily: "var(--font-sans)", - fontSize: "var(--fs-body)", - fontWeight: 400, - lineHeight: 1.6, - letterSpacing: "-0.01em", - color: "rgba(255,255,255,0.78)", -}; - -export function ComparePhilosophies(): React.ReactElement { - return ( -
    - - - - - -
    -

    - {PHILOSOPHIES_SECTION.heading} -

    -

    - {PHILOSOPHIES_SECTION.body[0]} -

    -
    -
    - - {/* A luminous rule, not two boxes. Translucent panels on a dark band are - low-contrast filler; the contrast the article draws is between the - two arguments, and a divider states that without enclosing either. - The two sides are also free to differ in length here, which the - article intends — Docker gets a list, CleanStart running prose. */} - - -
    - -

    - {PHILOSOPHY_DHI.lead} -

    -
      - {PHILOSOPHY_DHI.items.map((item) => ( -
    • - - {item} -
    • - ))} -
    -

    - {PHILOSOPHY_DHI.close} -

    -
    -
    - - -
    - {/* The panel tint that used to sit here is gone with the panel. - It was absolutely positioned against the panel; without one it - would have spread across the whole section. The CleanStart - side is now distinguished by its lead line instead. */} -
    - -
    - {PHILOSOPHY_CLEANSTART.body.map((text, index) => ( -

    - {text} -

    - ))} -
    -
    -
    -
    -
    -
    -
    - ); -} - -/** Vendor logo in a glass tile, with the vendor name beside it. */ -function VendorMark({ - src, - name, -}: { - src: string; - name: string; -}): React.ReactElement { - return ( -
    - - {/* eslint-disable-next-line @next/next/no-img-element */} - - -

    - {name} -

    -
    - ); -} - -function Tick(): React.ReactElement { - return ( - - - - ); -} diff --git a/apps/web/src/components/sections/compare/CompareProvenance.tsx b/apps/web/src/components/sections/compare/CompareProvenance.tsx deleted file mode 100644 index 1b2144f6b..000000000 --- a/apps/web/src/components/sections/compare/CompareProvenance.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { Container, Section } from "@/components/layout"; -import { Reveal, RevealItem, RevealStagger } from "@/components/ui/Reveal"; -import { ProvenanceRecord } from "./compare-artifacts"; -import { BOMS, PROVENANCE } from "./compare-data"; -import { ListLead, P, Prose, SectionHeading } from "./compare-editorial"; -import { Icon3D, RULE_LIGHT, WASH_LIGHT } from "./compare-visuals"; - -/** - * Software Provenance · SBOMs and AI BOMs — two movements in one band. - * - * Neither movement uses a card grid. Provenance is shown as the record itself, - * and the two bill-of-materials concepts are a rule-split pair. In both cases - * the box was decorating the content rather than clarifying it. - */ - -export function CompareProvenance(): React.ReactElement { - return ( -
    - - {/* ── Software Provenance ── */} -
    - - {PROVENANCE.heading} - - - {PROVENANCE.listLead} - - {/* - * The eight fields are the record's own keys, not eight tiles beside - * it. A provenance record is one document; setting it as one document - * says more than a grid of cards repeating its field names — and it - * turns this section's weakest block into the page's best visual. - */} - - - - - {/* The document's own SLSA contrast, given equal weight per side. */} - - {[ - { name: "Docker Hardened Images", text: PROVENANCE.after[0] }, - { name: "CleanStart", text: PROVENANCE.after[1] }, - ].map((side, index) => ( - - {/* A weighted rule, not a card. Two short paragraphs - contrasting two vendors need separating, not enclosing. */} -
    -

    - {side.name} -

    -

    - {side.text} -

    -
    -
    - ))} -
    - - -

    {PROVENANCE.after[2]}

    -
    -
    - - {/* ── SBOMs and AI BOMs ── */} -
    - {BOMS.heading} - - - {/* - * A progression, not a 50/50 split. The copy is explicit that AI BOMs - * *extend* SBOMs, so the layout says so: the established artifact, - * a connector, then CleanStart's extension carrying the brand tint. - * The previous symmetric pair also read as lopsided, because one side - * is a capability list and the other is prose — they were never the - * same shape of content. - */} - - -
    - -

    - Software Bill of Materials -

    -

    - {BOMS.listLead} -

    -
      - {(BOMS.items ?? []).map((item) => ( -
    • - - - {item} - -
    • - ))} -
    -
    -
    - - {/* The connector. Points right between the two cards, down when - they stack, so the direction of the relationship survives at - every width. */} -
    - - - - - - -
    - - -
    - -

    - AI Bill of Materials -

    -
    - {(BOMS.after ?? []).map((text) => ( -

    - {text} -

    - ))} -
    -
    -
    -
    -
    -
    -
    - ); -} - -function Tick(): React.ReactElement { - return ( - - - - ); -} - diff --git a/apps/web/src/components/sections/compare/CompareReadiness.tsx b/apps/web/src/components/sections/compare/CompareReadiness.tsx deleted file mode 100644 index daa06c8a1..000000000 --- a/apps/web/src/components/sections/compare/CompareReadiness.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import { Container, Section } from "@/components/layout"; -import { Reveal, RevealItem, RevealStagger } from "@/components/ui/Reveal"; -import { cn } from "@/lib/cn"; -import { COMPLIANCE, DEV_EXPERIENCE, VERIFYING } from "./compare-data"; -import { BAND_DARK, EllipseGlow, Icon3D } from "./compare-visuals"; - -/** - * Compliance and Regulatory Readiness · Developer Experience · Verifying - * Container Images — the article's three "what this buys you in practice" - * sections, set as three movements on one dark band. - * - * Three list shapes, three treatments: six compliance capabilities as icon - * tiles, seven tool names as plain chips (they are labels, not statements, and - * mixing real vendor logos with names we have no mark for would read as an - * accident), and seven verification questions as a numbered sequence — which is - * how the article frames them. - */ - -const EVIDENCE_ICONS: readonly string[] = [ - "/images/compare/icon-provenance.webp", - "/images/compare/icon-signed-artifact.webp", - "/images/compare/icon-sbom.webp", - "/images/attack-surface-reduction/approach-icon-deterministic.webp", - "/images/compare/icon-fips.webp", - "/images/compare/icon-stig.webp", -]; - -/** - * Brand marks for the tooling list, in the README-badge idiom. - * - * Artwork is Simple Icons (CC0), the same set shields.io badges use, saved as - * SVGs under `public/images/compare/tools/` so ~18KB of path data stays out of - * the bundle and nothing is fetched from a third-party CDN at runtime. - * - * Each mark sits on a white disc rather than directly on the band: Helm's brand - * colour is `#0F1689`, which is invisible against a dark section, so a - * full-colour-on-dark badge would silently lose one of the seven. - */ -const TOOL_LOGOS: Readonly> = { - Docker: "docker", - Kubernetes: "kubernetes", - Helm: "helm", - "GitHub Actions": "github-actions", - "GitLab CI": "gitlab", - Jenkins: "jenkins", - "Argo CD": "argo-cd", -}; - -const DARK_BODY: React.CSSProperties = { - fontFamily: "var(--font-sans)", - fontSize: "var(--fs-body)", - fontWeight: 400, - lineHeight: 1.65, - letterSpacing: "-0.01em", - color: "rgba(255,255,255,0.78)", - maxWidth: "68ch", - textWrap: "pretty", -}; - -function DarkHeading({ - id, - children, -}: { - id: string; - children: React.ReactNode; -}): React.ReactElement { - return ( - -

    - {children} -

    -
    - ); -} - -export function CompareReadiness(): React.ReactElement { - return ( -
    - - - - {/* ── Compliance and Regulatory Readiness ── */} -
    - - {COMPLIANCE.heading} - - {COMPLIANCE.body.map((text) => ( - -

    {text}

    -
    - ))} - -

    - {COMPLIANCE.listLead} -

    -
    - - {/* One panel holding all six, divided by hairlines — the same move as - the opening questions. Six separate tiles was noise; six bare rows - on a dark band had nothing holding them together. */} -
    - - {(COMPLIANCE.items ?? []).map((item, index) => ( - -
    0 ? "border-t sm:border-t-0" : "", - index % 2 === 1 ? "sm:border-l lg:border-l-0" : "", - index >= 2 ? "sm:border-t" : "", - index % 3 !== 0 ? "lg:border-l" : "", - index >= 3 ? "lg:border-t" : "lg:border-t-0", - )} - style={{ padding: "clamp(16px, 1.5vw, 22px)" }} - > - - - {item} - -
    -
    - ))} -
    -
    - - -

    {COMPLIANCE.after?.[0]}

    -
    -
    - - {/* ── Developer Experience ── */} -
    - {DEV_EXPERIENCE.heading} - -

    - {DEV_EXPERIENCE.body[0]} -

    -
    - -

    - {DEV_EXPERIENCE.listLead} -

    -
    - - - {DEV_EXPERIENCE.items.map((tool) => { - const slug = TOOL_LOGOS[tool]; - return ( - - - {slug && ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - - - )} - {tool} - - - ); - })} - - -
    - {DEV_EXPERIENCE.after.map((text) => ( - -

    {text}

    -
    - ))} -
    -
    - - {/* ── Verifying Container Images ── */} -
    - {VERIFYING.heading} - -
    -
    - -

    - {VERIFYING.body[0]} -

    -
    - -

    - {VERIFYING.listLead} -

    -
    - -

    - {VERIFYING.after[0]} -

    -
    -
    - - - {VERIFYING.items.map((question, index) => ( - -
    - - {index + 1} - - - {question} - -
    -
    - ))} -
    -
    -
    -
    -
    - ); -} diff --git a/apps/web/src/components/sections/compare/CompareSocialProof.tsx b/apps/web/src/components/sections/compare/CompareSocialProof.tsx deleted file mode 100644 index 149fc3584..000000000 --- a/apps/web/src/components/sections/compare/CompareSocialProof.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import Image from "next/image"; -import { Container, Section } from "@/components/layout"; -import { Reveal, RevealItem, RevealStagger } from "@/components/ui/Reveal"; -import { cn } from "@/lib/cn"; -import { SOCIAL_PROOF } from "./compare-data"; -import { WASH_LIGHT } from "./compare-visuals"; - -/** - * The plaque uses a shallower ramp than the page's `BAND_DARK`. That token runs - * #151021 → #131E8F → #471EC0 across a full-height section; compressed into a - * ~240px panel the same three stops read as a gradient smear rather than as a - * dark ground. - */ -const PLAQUE_DARK = - "linear-gradient(135deg, #12101F 0%, #191545 55%, #241A6B 100%)"; - -/** - * Third-party credentials, in the slot the SEO review marked with an empty - * placeholder heading and the note "Social proof is missing" (2026-07-30). - * - * Position is the whole point: it lands immediately after the capability table, - * where the reader has just seen both vendors tick nearly every row and the - * honest answer to "so why you?" has to be evidence rather than another claim. - * - * The credentials sit on ONE dark plaque rather than four separate dark tiles - * on a light band. All four badges are transparent artwork drawn for the dark - * footer — the Docker verified-publisher mark is white line art and disappears - * on white — so a dark ground is required, and a single panel divided by - * hairlines reads as a deliberate credential plate instead of four chips that - * happen to be dark. - * - * Badges only — no customer names, logos, or testimonials: none are cleared for - * use on a page that names a competitor. Nothing here is a new claim; every - * credential is already published in the site footer. - */ -export function CompareSocialProof(): React.ReactElement { - return ( -
    - -
    - -

    - {SOCIAL_PROOF.heading} -

    -
    - - -

    - {SOCIAL_PROOF.lead} -

    -
    -
    - - -
    - {/* Single ambient glow, centred behind the row. */} - - - - {SOCIAL_PROOF.credentials.map((credential, index) => ( - -
    0 - ? "sm:border-l" - : "", - )} - > -
    - {credential.name} -
    - -
    - - {credential.label} - - - {credential.name} - -
    -
    -
    - ))} -
    -
    -
    -
    -
    - ); -} diff --git a/apps/web/src/components/sections/compare/compare-artifacts.tsx b/apps/web/src/components/sections/compare/compare-artifacts.tsx deleted file mode 100644 index 8db0b3e81..000000000 --- a/apps/web/src/components/sections/compare/compare-artifacts.tsx +++ /dev/null @@ -1,289 +0,0 @@ -/** - * The comparison page's two coded artifacts. - * - * Both follow `sbom/SbomReportWindow`: built in code rather than as images, so - * they stay crisp at every DPI, cost nothing on LCP, and never shift layout. - * `font-mono` is not registered in this project's Tailwind theme, so the - * monospace family is applied inline via `var(--font-mono)`. - * - * These replace two grids of tiles. In both cases the article's own list became - * the artifact's content rather than being illustrated next to it — the eight - * provenance fields ARE the record's rows, and the reproducibility question is - * answered by showing the two builds resolving to one digest. - * - * Values are illustrative and the windows are marked decorative; the labels are - * the document's wording verbatim. - */ - -const WINDOW_SHELL: React.CSSProperties = { - width: "100%", - borderRadius: "16px", - overflow: "hidden", - background: "linear-gradient(160deg, #0c1130 0%, #080b1f 100%)", - border: "1px solid rgba(120,140,255,0.18)", - boxShadow: - "0 30px 80px -34px rgba(8,10,38,0.85), 0 0 64px rgba(70,30,191,0.18)", -}; - -const MONO: React.CSSProperties = { - fontFamily: "var(--font-mono)", - fontSize: "12.5px", - lineHeight: 1.55, -}; - -const KEY_COLOR = "#4FD1F5"; -const VALUE_COLOR = "rgba(255,255,255,0.82)"; -const MUTED = "rgba(255,255,255,0.34)"; - -function TitleBar({ - filename, - chip, -}: { - filename: string; - chip?: string; -}): React.ReactElement { - return ( -
    - {["#ff5f57", "#febc2e", "#28c840"].map((c) => ( - - ))} - - {filename} - - {chip && ( - - {chip} - - )} -
    - ); -} - -/* ─────────────────── provenance record ─────────────────── */ - -/** - * `fields` are the document's eight provenance-record entries, passed in so the - * copy stays owned by `compare-data`. Values are representative. - */ -const PROVENANCE_VALUES: readonly string[] = [ - "github.com/cleanstart/images", - "9f2c1ab4e7d0c3b5", - "cleanstart-builder@slsa-l4", - ".github/workflows/release.yml", - "412 packages, fully declared", - "sha256:7d3f…a91c", - "2026-07-14T09:22:31Z", - "cosign · in-toto v1.0", -]; - -export function ProvenanceRecord({ - fields, -}: { - fields: readonly string[]; -}): React.ReactElement { - return ( -
    - - -
    - {fields.map((field, i) => ( -
    - - {i + 1} - - {/* The document's field name, used as the record's key. */} - - {field} - - - {PROVENANCE_VALUES[i] ?? ""} - -
    - ))} -
    -
    - ); -} - -/* ─────────────────── reproducible build ─────────────────── */ - -const DIGEST = "sha256:7d3f9c02e5b8a4176ef31d0ca85b2f9e41c7d6a0b93e8f52a91c"; - -/** One of the two independent builds. They differ in everything but the result. */ -function BuildColumn({ - label, - builder, - when, - where, -}: { - label: string; - builder: string; - when: string; - where: string; -}): React.ReactElement { - return ( -
    -

    - {label} -

    - {[ - ["builder", builder], - ["ran at", when], - ["machine", where], - ].map(([k, v]) => ( -
    - {k} - - {v} - -
    - ))} -
    - ); -} - -/** - * Answers the section's own question — "if another engineer rebuilds this - * software using the same source code, will they obtain the same artifact?" — - * by showing two builds that agree on nothing except the digest. - */ -export function ReproducibleBuildProof(): React.ReactElement { - return ( -
    - - -
    -
    - - - {/* The equals node — the whole point of the artifact, so it gets a - real presence rather than a stray glyph. A rail runs through it on - both axes so the two builds always read as one comparison. */} -
    - - - = - -
    - - -
    - - {/* The one line both builds agree on. */} -
    -

    - Resulting artifact digest -

    -

    - {DIGEST} -

    -
    -
    -
    - ); -} diff --git a/apps/web/src/components/sections/compare/compare-data.ts b/apps/web/src/components/sections/compare/compare-data.ts index 5b4ec9933..593380fcd 100644 --- a/apps/web/src/components/sections/compare/compare-data.ts +++ b/apps/web/src/components/sections/compare/compare-data.ts @@ -1,543 +1,498 @@ /** - * Copy for the Docker Hardened Images ↔ CleanStart comparison page. + * Every string on `/compare/cleanstart-vs-docker-hardened-images`. * - * Every string here is taken verbatim from the source copy document - * ("Docker Hardened Images vs CleanStart"). Sentence case is normalised on - * list items and nothing is paraphrased, re-headlined, or invented — including - * the capability rows, which carry the document's own qualifier wording. + * The page is held to the SEO source document ("Docker Hardened Images vs + * CleanStart - Final"). Copy here is the document's, verbatim, with one class + * of edit: em-dashes are replaced with a colon or a semicolon per the house + * writing rule. Nothing is added, cut or re-worded. * - * The only additions are UI chrome that a copy document cannot supply: button - * labels, the table's accessible caption, and the legend. They are grouped at - * the bottom under UI_CHROME so the boundary stays obvious. + * Heading levels follow the document's outline, shifted one level because the + * page title takes H1: the document's H1s are the page's H2s, its H2s are the + * page's H3s. Anything the document does not set as a heading (the two vendor + * labels, the matrix group names, the "focuses on" and "Build approach" lead-ins) + * stays a `

    ` or a table header cell so the outline stays the one SEO wrote. + * + * The capability matrix and the FAQ are both consumed twice — once by the + * rendered section and once by the FAQPage JSON-LD / matrix counts — so they + * live here rather than inside a component. */ -/** - * `state: "text"` is a cell the document answers with a phrase and no mark — - * rendering those as a ✓ or a — would be us scoring a row the document - * deliberately left unscored. `divergent` marks the rows where the document - * itself qualifies or splits the answer. - */ -export interface MatrixRow { - id: string; - capability: string; - docker: { state: "yes" | "no" | "text"; note?: string }; - cleanstart: { state: "yes" | "no" | "text"; note?: string }; - divergent?: boolean; -} +export const PATH = "/compare/cleanstart-vs-docker-hardened-images"; -const yes = { state: "yes" } as const; +export const META = { + title: "Docker Hardened Images vs CleanStart | Secure Container Images", + description: + "Compare Docker Hardened Images vs CleanStart. Explore differences in hardened container images, SBOMs, software provenance, SLSA builds, and secure software supply chain practices.", +} as const; -export const MATRIX_ROWS: readonly MatrixRow[] = [ - { id: "attack-surface", capability: "Reduced attack surface", docker: yes, cleanstart: yes }, - { id: "minimal", capability: "Minimal image variants", docker: yes, cleanstart: yes }, - { id: "distroless", capability: "Distroless images", docker: yes, cleanstart: yes }, - { id: "cves", capability: "Near-zero known CVEs", docker: yes, cleanstart: yes }, - { - id: "sbom", - capability: "Software Bill of Materials (SBOM)", - docker: yes, - cleanstart: yes, - }, - { id: "signed", capability: "Signed software artifacts", docker: yes, cleanstart: yes }, - { - id: "provenance", - capability: "Software provenance", - docker: { state: "yes", note: "SLSA Build Level 3" }, - cleanstart: { state: "yes", note: "SLSA Level 4 aligned" }, - divergent: true, - }, - { id: "source-built", capability: "Source-built software", docker: yes, cleanstart: yes }, - { id: "crypto", capability: "Cryptographic verification", docker: yes, cleanstart: yes }, - { id: "rebuilds", capability: "Automatic rebuilds", docker: yes, cleanstart: yes }, - { id: "fips", capability: "FIPS-ready variants", docker: yes, cleanstart: yes }, - { id: "stig", capability: "STIG-aligned variants", docker: yes, cleanstart: yes }, - { - id: "ai-bom", - capability: "AI Bill of Materials (AI BOM)", - docker: { state: "no" }, - cleanstart: yes, - divergent: true, - }, - { - id: "hermetic", - capability: "Deterministic, hermetic build philosophy", - docker: { state: "text", note: "Limited public emphasis" }, - cleanstart: { state: "text", note: "Core design principle" }, - divergent: true, - }, - { - id: "posture", - capability: "Software Supply Chain Posture capabilities", - docker: { state: "text", note: "Image-focused" }, - cleanstart: { state: "text", note: "Broader software supply chain focus" }, - divergent: true, - }, -]; +/** Full H1, also used as the BreadcrumbList leaf. */ +export const TITLE = + "Docker Hardened Images vs CleanStart: Secure Container Images Compared"; -/* - * There are deliberately no capability categories here. The source document's - * table is a flat header plus fifteen rows, every row three cells wide — it - * groups nothing. Category bands ("Core Image Hardening", "Build Assurance & - * Provenance", "Supply Chain Posture & Governance") were added to the page on - * 2026-08-04 and removed the same day: none of those titles or their - * descriptions appear anywhere in the document, and grouping the rows asserts - * an editorial reading the SEO team never wrote or reviewed. +/** + * The H1 split for display. The hero sets the first half plain and the second + * half in the brand gradient, so the title reads as one line of type rather + * than as a coloured product name dropped into a sentence. */ +export const TITLE_PARTS = { + lead: "Docker Hardened Images vs ", + accent: "CleanStart", + tail: ": Secure Container Images Compared", +} as const; -export const VENDOR_DHI = "Docker Hardened Images"; -export const VENDOR_CLEANSTART = "CleanStart Verified Images"; +export const STANDFIRST = + "Compare Docker Hardened Images and CleanStart across container security, software provenance, reproducible builds, and software supply chain verification."; -/** Document title, split at the colon for the H1 / standfirst pair. */ -export const TITLE_MAIN = "Docker Hardened Images vs CleanStart"; -export const TITLE_SUB = - "A Technical Comparison of Two Approaches to Trusted Container Images"; +/** The two vendors, named once. Every section labels its columns from here. */ +export const VENDOR = { + docker: "Docker Hardened Images", + cleanstart: "CleanStart", +} as const; -export const INTRO_LEAD = - "Modern container security is no longer just about reducing vulnerabilities. Engineering teams are increasingly expected to answer broader questions:"; +export const HERO_CTA = { + label: "Explore CleanStart Images", + href: "/cleanstart-images", +} as const; -/** The four questions the document opens with. */ -export const OPENING_QUESTIONS: readonly string[] = [ - "Where did this software originate?", - "How was it built?", - "Can it be independently verified?", - "Does it meet regulatory and organizational security requirements?", -]; +/* ───────────────────────── hero diagram ───────────────────────── */ -export const INTRO_BODY: readonly string[] = [ - "These questions have become central to software supply chain security.", - "Docker Hardened Images (DHI) and CleanStart Verified Images both aim to provide secure container images for production workloads, but they approach the problem from different perspectives. Docker Hardened Images focus on delivering hardened, enterprise-ready container images with a minimal attack surface. CleanStart extends that foundation by emphasizing deterministic builds, software provenance, verification, and Software Supply Chain Posture.", - "This guide compares both approaches from a technical perspective, explaining not only what each platform provides, but why those capabilities matter to modern engineering organizations.", -]; +/** + * The hero artwork states the page's argument in one picture: Docker's stack + * stands on a base it inherits from an upstream distribution, CleanStart's + * stands on nothing. Both label sets are drawn from the matrix rows below, so + * the diagram never claims anything the table does not. + */ +export const HERO_DIAGRAM = { + caption: + "Where each stack starts: Docker Hardened Images harden an inherited Debian or Alpine base, CleanStart compiles every layer from verified source.", + docker: { + inherited: { label: "Upstream distro", detail: "Debian · Alpine" }, + link: "inherits", + layers: ["Reduced packages", "Hardened configuration", "Attested image"], + }, + cleanstart: { + inherited: { label: "Nothing inherited", detail: "zero upstream base" }, + link: "builds from source", + layers: ["Verified source", "Hermetic build", "Signed artifact"], + }, +} as const; -export const MATRIX_HEADING = "At a Glance: Hardened Container Images Comparison"; +/* ─────────────────────── section 1: foundations ─────────────────────── */ -export const KEY_TAKEAWAY = - "Both solutions significantly improve upon traditional public container images. The primary differences lie less in image hardening and more in the level of build assurance, software verification, and software supply chain governance they provide."; +export const FOUNDATIONS = { + heading: + "What Are Docker Hardened Images and How Do They Compare With CleanStart?", + intro: + "Docker Hardened Images and CleanStart take different approaches to container security. Both aim to reduce risk in the software supply chain, but they start from different foundations: one hardens an existing base, the other builds from verified source.", + columns: [ + { + id: "docker", + label: "Docker Hardened Images", + body: "Docker Hardened Images provide hardened container images designed to reduce attack surface and improve container security.", + focusLabel: "Docker focuses on:", + focus: [ + "Debian and Alpine-based foundations", + "Minimal production images", + "Reproducible builds", + "Supply chain metadata and attestations", + ], + }, + { + id: "cleanstart", + label: "CleanStart Verified Images", + body: "CleanStart provides verified container images built through controlled software supply chain processes designed to establish artifact trust.", + focusLabel: "CleanStart focuses on:", + focus: [ + "Distroless foundations", + "Source-based builds", + "Reproducible & hermetic build processes", + "Provenance & cryptographic verification", + ], + }, + ], +} as const; -export const KEY_TAKEAWAY_LABEL = "Key takeaway"; - -export interface Credential { - label: string; - name: string; - src: string; - w: number; - h: number; -} +/* ───────────────────────── section 2: matrix ───────────────────────── */ /** - * Not from the document body. The SEO review left an empty placeholder heading - * directly after the capability table, commented "Social proof is missing" - * (2026-07-30) — the point where the table has just shown both vendors ticking - * nearly every row. - * - * These are CleanStart's existing third-party credentials, the same set already - * published in the site footer, so this introduces no new claim. Deliberately no - * customer names or testimonials: none are cleared for use on a page that names - * a competitor. Wording is pending SEO/marketing sign-off. + * A matrix cell. `yes` / `no` render as markers with a screen-reader label; + * `text` renders the document's phrase. The document's own "✓" and "—" glyphs + * map to `yes` and `no` so the markers can carry an accessible name and a + * colour rather than sitting in the page as bare punctuation. */ -export const SOCIAL_PROOF = { - heading: "Independently Verified", - /* - * The lead does the work of the section's position: the table directly above - * shows both vendors ticking nearly every row, so the honest next move is to - * separate what we assert from what an outside party has examined. It makes - * no claim of its own — each credential below is already published in the - * site footer. - */ - lead: "The capabilities above are ours to state. These are the ones a third party has examined and attested to.", - credentials: [ +export type MatrixCell = + | { readonly kind: "yes" } + | { readonly kind: "no" } + | { readonly kind: "text"; readonly value: string }; + +export interface MatrixRow { + readonly id: string; + readonly capability: string; + readonly docker: MatrixCell; + readonly cleanstart: MatrixCell; +} + +export interface MatrixGroup { + readonly id: string; + readonly label: string; + readonly rows: readonly MatrixRow[]; +} + +const yes: MatrixCell = { kind: "yes" }; +const no: MatrixCell = { kind: "no" }; +const text = (value: string): MatrixCell => ({ kind: "text", value }); + +export const MATRIX = { + heading: + "Docker Hardened Images vs CleanStart: Container Security Comparison", + intro: + "Both Docker Hardened Images and CleanStart provide hardened container images with security metadata, signatures, and provenance. The difference lies in their approach to building, verifying, and maintaining software artifacts across the supply chain.", + caption: + "Capability comparison between Docker Hardened Images and CleanStart, grouped by image foundation, build and supply chain security, software transparency, and security and compliance.", + footnote: + "Comparison reflects each platform's published approach and CleanStart's documented capabilities as of September 2026. Specific behavior varies by image and variant.", + groups: [ { - /* Labels name the *kind* of attestation, so four credentials of three - * different kinds do not all read as the generic "Certification". */ - label: "Industry award", - name: "Cyber Security Excellence Awards Winner", - src: "/images/awards/award-1.webp", - w: 486, - h: 616, + id: "foundation", + label: "Image Foundation", + rows: [ + { + id: "base-foundation", + capability: "Base foundation", + docker: text("Debian and Alpine-based images"), + cleanstart: text( + "CleanStart OS: source-built minimal image (no inherited base)", + ), + }, + { + id: "image-hardening", + capability: "Image hardening", + docker: text( + "Reduced packages, hardened configurations, secure defaults", + ), + cleanstart: text( + "Compiled from source on a zero-inheritance foundation (custom glibc). Security flags set at build time; FIPS built in, not bolted on. Verified by a 78-test suite and 11 signed artifacts per variant.", + ), + }, + { + id: "production-variants", + capability: "Production variants", + docker: text("Production, development, compatibility variants"), + cleanstart: text("Production, development, debug variants"), + }, + ], }, { - label: "Registry verification", - name: "Docker Verified Publisher", - src: "/images/awards/award-2.webp", - w: 268, - h: 267, + id: "build", + label: "Build & Supply Chain Security", + rows: [ + { + id: "zero-inheritance", + capability: "Zero-inheritance architecture", + docker: text("Hardens existing Debian/Alpine base (inherits upstream)"), + cleanstart: text( + "Inherits nothing from upstream distros; every component compiled from verified source", + ), + }, + { + id: "public-build-definitions", + capability: "Public build definitions", + docker: yes, + cleanstart: text("Controlled build pipelines"), + }, + { + id: "source-based-builds", + capability: "Source-based builds", + docker: no, + cleanstart: yes, + }, + { + id: "hermetic-build", + capability: "Hermetic build process", + docker: text("Not fully hermetic"), + cleanstart: yes, + }, + { + id: "artifact-verification", + capability: "Artifact verification", + docker: text("Image attestations and signatures"), + cleanstart: text( + "Artifact verification through provenance and cryptographic signing", + ), + }, + ], }, { - label: "Independent audit", - name: "AICPA SOC 2", - src: "/images/awards/award-4.webp", - w: 1024, - h: 1023, + id: "transparency", + label: "Software Transparency", + rows: [ + { + id: "sboms", + capability: "SBOMs", + docker: text("SPDX and CycloneDX SBOMs"), + cleanstart: text("SPDX and CycloneDX SBOMs"), + }, + { + id: "image-signing", + capability: "Image signing", + docker: text("Cosign signatures"), + cleanstart: text("Cosign signatures"), + }, + { + id: "provenance", + capability: "Software provenance", + docker: text("SLSA Build Level 3 provenance"), + cleanstart: text("SLSA Level 3 aligned provenance"), + }, + { + id: "vex", + capability: "VEX / exploitability context", + docker: yes, + cleanstart: yes, + }, + { + id: "ai-bom", + capability: "AI BOM", + docker: no, + cleanstart: yes, + }, + ], }, { - label: "Certification", - name: "ISO/IEC 27001", - src: "/images/awards/award-3.webp", - w: 200, - h: 200, + id: "compliance", + label: "Security & Compliance", + rows: [ + { + id: "fips", + capability: "FIPS-ready images", + docker: yes, + cleanstart: yes, + }, + { + id: "stig", + capability: "STIG-aligned images", + docker: yes, + cleanstart: yes, + }, + { + id: "compliance-artifacts", + capability: "Compliance artifacts", + docker: yes, + cleanstart: yes, + }, + { + id: "vulnerability-intelligence", + capability: "Vulnerability intelligence", + docker: text("CVE metadata, VEX, security attestations"), + cleanstart: text( + "Vulnerability analysis, exploitability context, and verification workflows", + ), + }, + { + id: "remediation-model", + capability: "Vulnerability remediation model", + docker: text("Patch-based, up to 7 days (paid-tier SLA)"), + cleanstart: text( + "Automatic rebuild from source via Continuous Trust Loop, ~24h", + ), + }, + { + id: "vulnerability-data-accuracy", + capability: "Vulnerability data accuracy", + docker: no, + cleanstart: yes, + }, + { + id: "shell-less", + capability: "Shell-less and read-only", + docker: no, + cleanstart: yes, + }, + ], }, - ] satisfies readonly Credential[], + ] as const satisfies readonly MatrixGroup[], } as const; -export interface DocSection { - id: string; - heading: string; - /** Paragraphs before any list. */ - body: readonly string[]; - /** Optional lead-in sentence that introduces `items`. */ - listLead?: string; - items?: readonly string[]; - /** Paragraphs after the list. */ - after?: readonly string[]; +/** Row count, derived so the section summary can never drift from the table. */ +export const MATRIX_ROW_COUNT = MATRIX.groups.reduce( + (total, group) => total + group.rows.length, + 0, +); + +/* ──────────────────────── section 3: build flow ──────────────────────── */ + +export interface BuildFlowColumn { + readonly id: "docker" | "cleanstart"; + readonly label: string; + readonly body: string; + readonly stepsLabel: string; + readonly steps: readonly string[]; + readonly traitsLabel: string; + readonly traits: readonly string[]; } -export const PHILOSOPHIES_SECTION: DocSection = { - id: "philosophies", - heading: "Two Different Security Philosophies", - body: [ - "Although Docker Hardened Images and CleanStart solve similar problems, they begin from different architectural assumptions.", - ], -}; - -export const PHILOSOPHY_DHI = { - name: "Docker Hardened Images", - lead: "Docker Hardened Images are designed to reduce operational risk by delivering production-ready images with:", - items: [ - "Minimal software packages", - "Reduced attack surface", - "Enterprise support", - "Signed artifacts", - "Software provenance", - "Continuous updates", - ], - close: - "The emphasis is on delivering secure runtime images that organizations can confidently deploy.", -} as const; - -export const PHILOSOPHY_CLEANSTART = { - name: "CleanStart", - body: [ - "CleanStart begins earlier in the software lifecycle.", - "Instead of focusing solely on the final container image, it focuses on producing verified software artifacts through deterministic build pipelines.", - "The objective is not only to reduce vulnerabilities, but also to establish confidence in how every software artifact was produced.", - "This distinction becomes increasingly important for organizations implementing software supply chain frameworks such as SLSA, NIST SSDF, Executive Order 14028 requirements, or internal secure software development programs.", - ], -} as const; - -export const BEYOND_CVES = { - heading: "Security: More Than Reducing CVEs", - body: [ - "Reducing vulnerabilities remains one of the most effective ways to improve container security.", - "Both Docker Hardened Images and CleanStart significantly reduce unnecessary packages, remove common attack vectors, and deliver production-ready container images with substantially fewer known vulnerabilities than typical public container images.", - ], - benefitsLead: "Benefits include:", - benefits: [ - "Smaller images", - "Fewer packages to maintain", - "Reduced remediation effort", - "Lower operational overhead", - "Smaller runtime attack surface", - ], - pivot: "However, vulnerability reduction answers only one question:", - answered: "Does this image contain known vulnerabilities today?", - unansweredLead: "It does not answer:", - unanswered: [ - "Who built it?", - "Which source code produced it?", - "Was the build reproducible?", - "Has the artifact been modified?", - "Can another organization independently verify it?", - ], - close: - "Those questions belong to software integrity rather than vulnerability management.", -} as const; - -export const SOURCE_BUILT: DocSection = { - id: "building-from-source", - heading: "Building from Source", - body: [ - "One of the largest changes in software supply chain security over the past few years has been renewed interest in source-built software.", - "Historically, many container images incorporated binaries produced elsewhere.", - ], - listLead: - "Modern secure build systems increasingly rebuild packages directly from source, allowing organizations to:", - items: [ - "Verify software origin", - "Apply consistent compiler settings", - "Generate provenance", - "Reduce reliance on opaque upstream binaries", - ], - after: [ - "Both Docker Hardened Images and CleanStart embrace source-built software, helping establish stronger trust in the software delivered to production.", - ], -}; - -export const HERMETIC: DocSection = { - id: "hermetic-builds", - heading: "Understanding Hermetic and Deterministic Builds", - body: [ - "Hermetic builds are frequently mentioned in software supply chain discussions but are often misunderstood.", - "A hermetic build executes inside an isolated environment where every dependency is explicitly declared before compilation begins.", - ], - listLead: "The build environment cannot:", - items: [ - "Download undeclared packages", - "Depend on developer workstations", - "Rely on environment-specific configuration", - "Produce different artifacts because of transient infrastructure changes", - ], - after: [ - "Deterministic builds extend this concept by ensuring identical inputs consistently produce identical outputs.", - "This enables reproducible builds, improves build integrity, and reduces opportunities for supply chain attacks involving compromised package repositories or unexpected build dependencies.", - "CleanStart places particular emphasis on hermetic, deterministic build pipelines as a core architectural principle.", - ], -}; - -export const REPRODUCIBLE = { - heading: "Reproducible Builds", - lead: "A reproducible build answers one simple but powerful question:", - question: - "If another engineer rebuilds this software using the same source code, will they obtain the same artifact?", - body: [ - "If the answer is yes, consumers gain significantly greater confidence that the published software corresponds exactly to the documented source code.", - ], - pull: "Reproducibility transforms software verification from trust into evidence.", - close: - "For organizations operating in highly regulated environments, reproducible builds are increasingly becoming an important indicator of software integrity.", -} as const; - -export const PROVENANCE = { - heading: "Software Provenance", - body: ["Software provenance describes how an artifact was produced."], - listLead: "Typical provenance records include:", - items: [ - "Source repository", - "Commit identifier", - "Builder identity", - "Build workflow", - "Dependency information", - "Artifact digest", - "Timestamps", - "Cryptographic attestations", - ], - after: [ - "Docker Hardened Images provide SLSA Build Level 3 provenance together with signed software artifacts.", - "CleanStart extends this approach by emphasizing SLSA Level 4 aligned provenance, deterministic builds, and comprehensive verification throughout the build pipeline.", - "Rather than replacing vulnerability management, provenance complements it by documenting the origin and production history of software artifacts.", - ], +export const BUILD_FLOW = { + heading: + "How Do Docker Hardened Images and CleanStart Build Secure Container Images?", + intro: + "The two platforms secure containers at different points in the lifecycle. Docker hardens a container foundation and validates the result; CleanStart verifies everything from source through to the final signed artifact.", + columns: [ + { + id: "docker", + label: "Docker Hardened Images", + body: "Docker Hardened Images follow a hardened image approach designed to secure container foundations.", + stepsLabel: "Build approach:", + steps: [ + "Base Container Foundation", + "Security Hardening", + "Testing & Validation", + "Signed Container Image", + "Production Deployment", + ], + traitsLabel: "Key characteristics:", + traits: [ + "Hardened base images", + "Minimal production variants", + "Image attestations and metadata", + ], + }, + { + id: "cleanstart", + label: "CleanStart Verified Images", + body: "CleanStart builds verified container images through controlled software supply chain processes.", + stepsLabel: "Build approach:", + steps: [ + "Source Code", + "Source Verification", + "Controlled Build Pipeline", + "SBOM + Provenance Generation", + "Cryptographic Signing", + "Verified Container Image", + "Production Deployment", + ], + traitsLabel: "Key characteristics:", + traits: [ + "Source-built images", + "Reproducible & hermetic build processes", + "Software provenance", + "Artifact verification", + ], + }, + ] as const satisfies readonly BuildFlowColumn[], } as const; -export const BOMS: DocSection = { - id: "sboms-ai-boms", - heading: "SBOMs and AI BOMs", - body: [ - "A Software Bill of Materials (SBOM) provides an inventory of every software component included within a container image.", - ], - listLead: "SBOMs enable engineering teams to:", - items: [ - "Identify vulnerable dependencies", - "Understand licensing obligations", - "Perform impact analysis", - "Accelerate incident response", - ], - after: [ - "As organizations increasingly adopt AI-assisted software development, visibility into AI-generated artifacts becomes equally important.", - "CleanStart extends traditional SBOM capabilities with AI Bills of Materials (AI BOMs), helping organizations document AI-generated software components and strengthen governance across modern development workflows.", - ], -}; +/* ────────────────────── section 4: differentiators ────────────────────── */ -export const COMPLIANCE: DocSection = { - id: "compliance", - heading: "Compliance and Regulatory Readiness", - body: [ - "Modern compliance requirements increasingly focus on software integrity rather than vulnerability counts alone.", - "Organizations in financial services, healthcare, government, and critical infrastructure frequently require evidence describing how software was produced.", - ], - listLead: "Capabilities such as:", +export const DIFFERENTIATORS = { + heading: "Where CleanStart Differentiates", items: [ - "Software provenance", - "Signed artifacts", - "SBOMs", - "Deterministic builds", - "FIPS-ready images", - "STIG-aligned images", - ], - after: [ - "help simplify compliance activities while providing stronger assurance during audits.", - ], -}; - -// `as const` rather than `: DocSection` — these two are consumed field-by-field -// rather than through DocBlock, so their list fields must be non-optional. -export const DEV_EXPERIENCE = { - id: "developer-experience", - heading: "Developer Experience", - body: [ - "Security improvements should integrate naturally into existing development workflows.", - ], - listLead: - "Both Docker Hardened Images and CleanStart support standard OCI container ecosystems and integrate with common tooling including:", - items: [ - "Docker", - "Kubernetes", - "Helm", - "GitHub Actions", - "GitLab CI", - "Jenkins", - "Argo CD", - ], - after: [ - "From a developer perspective, adoption typically involves replacing a base image with secure Docker base images while continuing to use existing container workflows.", - "Where the approaches differ is the amount of verification metadata available to downstream security and compliance teams.", - ], -} as const; - -export const VERIFYING = { - id: "verifying-images", - heading: "Verifying Container Images", - body: [ - "Regardless of which platform you choose, engineers should verify the software they deploy.", - ], - listLead: - "A secure container image should allow you to answer questions such as:", - items: [ - "Is an SBOM available?", - "Is the image digitally signed?", - "Can software provenance be verified?", - "Is the image digest immutable?", - "Is the build process documented?", - "Was the software rebuilt from source?", - "Are updates published consistently?", - ], - after: [ - "These verification steps help establish confidence in both the software itself and the processes used to produce it.", - ], -} as const; - -export const CHOOSING = { - heading: "CleanStart vs Docker Hardened Images: Choosing the Right Approach", - body: [ - "Docker Hardened Images and CleanStart are not mutually exclusive philosophies. Both recognize that public container images require stronger security, better maintenance, and improved transparency.", - ], - dhi: { - name: "Docker Hardened Images", - text: "Docker Hardened Images are well suited for organizations looking for hardened, enterprise-supported images that integrate seamlessly into Docker's ecosystem while providing signed artifacts, provenance, and reduced vulnerabilities.", - }, - cleanstart: { - name: "CleanStart", - text: "CleanStart is designed for organizations that require additional assurance through deterministic build pipelines, SLSA Level 4 aligned provenance, AI BOMs, and a broader approach to Software Supply Chain Posture that extends beyond the container image itself.", - }, - close: - "The right choice ultimately depends on your security objectives, compliance requirements, and the level of verification your organization expects from its software supply chain.", -} as const; - -export const WHICH_BETTER = { - heading: "Which solution is better?", - body: [ - "Both Docker Hardened Images and CleanStart significantly improve software security compared to traditional public container images.", - "If your priority is hardened, enterprise-supported container images with strong security fundamentals, Docker Hardened Images provide an excellent foundation.", - "If your organization also requires higher-assurance build verification, deterministic software production, AI BOMs, and a broader Software Supply Chain Posture strategy, CleanStart extends those capabilities beyond traditional image hardening.", - ], -} as const; - -/** - * Not from the document body. Requested by the SEO review as a comment anchored - * to the "Which solution is better?" heading (2026-07-30): an additional H2 that - * carries the "alternative to Docker Hardened Images" query. It heads the two - * recommendation cards, which are the copy that answers it. - */ -export const WHICH_BETTER_ALT_HEADING = - "Is CleanStart the Right Alternative to Docker Hardened Images?"; - -export const FINAL_THOUGHTS = { - heading: "Final Thoughts", - pull: "Container security is evolving from secure images to verifiable software.", - body: [ - "Reducing vulnerabilities remains essential, but modern software supply chain security also requires organizations to understand where software originated, how it was built, and whether its integrity can be independently verified.", - "Whether you choose Docker Hardened Images, CleanStart, or another trusted image provider, the long-term objective remains the same: establish confidence in every software artifact before it reaches production.", - "That confidence is built not only through hardening, but through verification.", + { + id: "source-to-artifact", + /** Document H2 — renders as the page's H3. */ + heading: "Source-to-Artifact Verification", + body: "CleanStart emphasizes verification across the artifact lifecycle, from source inputs through reproducible builds and final image delivery.", + /** Chained cubes: the unbroken link from source input to delivered image. */ + icon: "/images/compare/icon-provenance.webp", + }, + { + id: "reproducible-builds", + heading: "Reproducible Build Confidence", + body: "Security teams can validate how artifacts are created and reproduce build outcomes through controlled build processes.", + icon: "/images/compare/icon-signed-artifact.webp", + }, + { + id: "verified-foundations", + heading: "Verified Software Foundations", + body: "CleanStart extends container security into broader software supply chain assurance across images, libraries, and dependencies.", + /** Manifest plus components: images, libraries and dependencies together. */ + icon: "/images/compare/icon-sbom.webp", + }, ], } as const; -export const CTA = { - heading: "Build Trust Into Every Container Image", - body: "Secure container images are only one part of software supply chain security. Discover how CleanStart helps engineering and security teams verify software integrity before deployment.", - button: "Request a Demo", -} as const; - -export const FAQ_HEADING = "Frequently Asked Questions"; +/* ───────────────────────────── section 5: FAQ ───────────────────────────── */ export interface CompareFaq { - id: string; - question: string; - answer: string; + readonly id: string; + readonly question: string; + readonly answer: string; } -export const COMPARE_FAQS: readonly CompareFaq[] = [ +export const FAQ_HEADING = "Frequently Asked Questions"; + +export const FAQS = [ { - id: "hardened-vs-verified", - question: "What is the difference between a hardened image and a verified image?", + id: "what-are-dhi", + question: "What are Docker Hardened Images?", answer: - "A hardened image reduces the attack surface by minimizing unnecessary software and lowering known vulnerabilities. A verified image builds on hardening by providing evidence describing how the software was produced, including provenance, reproducible builds, cryptographic signatures, and attestations.", + "Docker Hardened Images are minimal, security-focused container images built to reduce attack surface and improve container security. They are based on Debian and Alpine foundations, ship as minimal production variants with reduced packages and secure defaults, and include supply chain metadata such as SBOMs, Cosign signatures, SLSA Build Level 3 provenance and VEX exploitability context.", }, { - id: "still-scan", - question: "Do I still need vulnerability scanning?", + id: "difference", + question: + "What is the difference between Docker Hardened Images and CleanStart?", answer: - "Yes. Verification and vulnerability management solve different problems. Vulnerability scanning identifies known security issues, while verification establishes confidence in the integrity and origin of software artifacts.", + "Both provide hardened container images with SBOMs, Cosign signatures and SLSA-aligned provenance. The core difference is architecture: Docker Hardened Images harden existing Debian and Alpine base images and inherit from upstream distributions, while CleanStart uses a zero-inheritance model where every component is compiled from verified source on the CleanStart OS foundation (custom glibc), with hermetic builds, FIPS built in at build time, an AI BOM, and shell-less, read-only images.", }, { - id: "what-is-provenance", - question: "What is software provenance?", + id: "alternative", + question: "Is CleanStart a good alternative to Docker Hardened Images?", answer: - "Software provenance documents how software was produced, including its source repository, build workflow, builder identity, and cryptographic attestations. It enables consumers to verify the origin and integrity of software artifacts.", + "Yes. CleanStart is a strong alternative for teams that need deeper software supply chain assurance. It builds verified images from source with reproducible and hermetic pipelines, inherits nothing from upstream distributions, provides provenance and cryptographic verification, and extends coverage across images, libraries and dependencies rather than container images alone.", }, { - id: "why-reproducible", - question: "Why are reproducible builds important?", + id: "more-secure", + question: "Which platform builds more secure container images?", answer: - "Reproducible builds allow independent parties to verify that published artifacts correspond exactly to the documented source code, reducing reliance on trust alone.", + "Both are secure by design. Docker hardens a known base and adds attestations and signatures. CleanStart removes inherited risk entirely by compiling every component from verified source on a zero-inheritance foundation, hardening at build time, and validating each variant with a 78-test suite and 11 signed artifacts. Teams that prioritize source-to-artifact verification and hermetic builds generally favor CleanStart's approach.", }, { - id: "good-alternative", - question: "Is CleanStart a good alternative to Docker Hardened Images?", + id: "compliance", + question: "Which solution offers better compliance support?", + answer: + "Both offer FIPS-ready and STIG-aligned images plus compliance artifacts. CleanStart builds FIPS in at compile time rather than bolting it on afterward, and pairs it with SBOMs, provenance and an AI BOM, which gives auditors a consistent, source-verified evidence trail for regulated environments.", + }, + { + id: "vulnerability-effort", + question: "Which platform reduces vulnerability management effort the most?", answer: - "Docker Hardened Images and CleanStart both provide secure container images with reduced attack surfaces, signed artifacts, SBOMs, and software provenance. CleanStart differentiates itself by emphasizing deterministic build pipelines, SLSA Level 4 aligned provenance, AI BOMs, and a broader software supply chain posture. The right choice depends on your organization's security, compliance, and verification requirements.", + "Both reduce effort by shipping minimal images with less to patch. Docker uses patch-based remediation with fixes typically within 7 days on paid tiers. CleanStart automatically rebuilds affected images from source through its Continuous Trust Loop, targeting roughly 24-hour remediation, and adds vulnerability data accuracy and exploitability context so teams spend less time triaging false positives.", }, { - id: "compliance-support", - question: "Which platform offers better compliance support?", + id: "advantages", + question: + "What are the advantages of CleanStart over Docker Hardened Images?", answer: - "Both platforms support compliance initiatives through capabilities such as signed artifacts, software provenance, SBOMs, and hardened container images. CleanStart further emphasizes deterministic builds and broader software supply chain verification, which may provide additional assurance for organizations operating under strict regulatory or internal security requirements.", + "CleanStart's advantages include a zero-inheritance architecture (no upstream distro risk), source-based and hermetic builds, FIPS built in at build time, an AI BOM, shell-less and read-only images, faster source-based remediation via the Continuous Trust Loop, and supply chain assurance that extends across images, libraries and dependencies.", }, { - id: "kubernetes", - question: "Which platform works better with Kubernetes?", + id: "devsecops", + question: "Which hardened image solution is best for DevSecOps teams?", answer: - "Both Docker Hardened Images and CleanStart support standard OCI container ecosystems and integrate with Kubernetes alongside common CI/CD and GitOps tools. For most engineering teams, adopting either solution typically involves replacing the base image while maintaining existing deployment workflows.", + "Both ship signed, attested images with SBOMs and SLSA-aligned provenance that plug into CI/CD gates and admission control. DevSecOps teams that want to verify how every artifact is built from source, reproduce build outcomes, and enforce provenance across images, libraries and dependencies tend to prefer CleanStart's source-to-artifact model.", }, -]; +] as const satisfies readonly CompareFaq[]; -/** Plain-text Q&A pairs for `faqPageSchema`. */ -export const COMPARE_FAQ_ITEMS: ReadonlyArray<{ question: string; answer: string }> = - COMPARE_FAQS.map((faq) => ({ question: faq.question, answer: faq.answer })); +/* ───────────────────────────────── CTA ───────────────────────────────── */ -/** - * Strings the copy document cannot supply: interface labels and accessible - * text. Kept separate so the document-verbatim boundary above stays auditable. - */ -export const UI_CHROME = { - matrixCaption: - "Capability comparison between Docker Hardened Images and CleanStart Verified Images.", - legendIncluded: "Included", - legendAbsent: "Not offered", - jumpToMatrix: "See the comparison", +export const CTA = { + heading: "Build With Verified Container Images", + body: "Secure your software supply chain with CleanStart Images built from source, backed by SBOMs, software provenance, and cryptographic verification.", + button: "Start Building With CleanStart", + href: "https://images.cleanstart.com", } as const; -/* - * A Docker trademark / non-affiliation disclaimer used to sit under the matrix. - * Removed 2026-08-04: the word "trademark" appears nowhere in the source - * document, and the page is held to document-verbatim copy. If legal wants a - * disclaimer back, it should come from them as approved wording rather than be - * reinstated here. +/* ──────────────────────── UI-only strings ──────────────────────── */ + +/** + * Chrome the document does not write: link labels, accessible names and the + * two marker states in the matrix. Kept apart from the copy above so a future + * document diff never has to reason about them. */ +export const UI = { + jumpToMatrix: "Compare capabilities", + available: "Available", + notAvailable: "Not available", + faqIntro: + "Common questions about hardened container images, provenance, reproducible builds and compliance evidence.", +} as const; diff --git a/apps/web/src/components/sections/compare/compare-editorial.tsx b/apps/web/src/components/sections/compare/compare-editorial.tsx deleted file mode 100644 index a5b4d60dc..000000000 --- a/apps/web/src/components/sections/compare/compare-editorial.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import type { ReactNode } from "react"; -import { Reveal } from "@/components/ui/Reveal"; -import { cn } from "@/lib/cn"; -import { RULE_LIGHT } from "./compare-visuals"; - -/** - * Typographic primitives for the comparison page. - * - * These own text only — measure, weight and rhythm. Every surface, colour and - * decoration lives in `compare-visuals`, so there is exactly one place to change - * how the page looks and one place to change how it reads. - * - * `RULE` is re-exported from `compare-visuals` so the page has a single - * hairline value rather than two that drift apart. - */ - -export const RULE = RULE_LIGHT; - -/** Reading measure for body prose — the `--prose-column-max` band, in ch. */ -const MEASURE = "68ch"; - -/** Section heading. Carries no eyebrow: fourteen tracked uppercase kickers is - * the exact scaffolding tell this page is removing. */ -export function SectionHeading({ - children, - id, - inverse = false, - size = "h2", -}: { - children: ReactNode; - id?: string; - inverse?: boolean; - size?: "h2" | "h3"; -}): React.ReactElement { - const Tag = size === "h2" ? "h2" : "h3"; - return ( - - - {children} - - - ); -} - -/** Body paragraph at the reading measure. */ -export function P({ - children, - inverse = false, - lead = false, - className, -}: { - children: ReactNode; - inverse?: boolean; - /** Slightly larger, for a section's opening paragraph. */ - lead?: boolean; - className?: string; -}): React.ReactElement { - return ( -

    - {children} -

    - ); -} - -/** Stack of paragraphs with consistent leading between them. */ -export function Prose({ - paragraphs, - inverse = false, - lead = false, - className, -}: { - paragraphs: readonly string[]; - inverse?: boolean; - lead?: boolean; - className?: string; -}): React.ReactElement { - return ( - - {paragraphs.map((text) => ( -

    - {text} -

    - ))} -
    - ); -} - -/** Short lead-in line that introduces a list. Not a kicker — a full sentence. */ -export function ListLead({ - children, - inverse = false, -}: { - children: ReactNode; - inverse?: boolean; -}): React.ReactElement { - return ( - -

    - {children} -

    -
    - ); -} diff --git a/apps/web/src/components/sections/compare/compare-visuals.tsx b/apps/web/src/components/sections/compare/compare-visuals.tsx index 02938f8bf..366b00d36 100644 --- a/apps/web/src/components/sections/compare/compare-visuals.tsx +++ b/apps/web/src/components/sections/compare/compare-visuals.tsx @@ -10,11 +10,9 @@ import { cn } from "@/lib/cn"; * oversized-corner white tile from `sbom/SbomAdvantage`. * * There is deliberately no accent-colour array. The site's palette runs - * violet → indigo → blue; the per-card teal/orange rotation this file used to - * export is what made the page read as a different product. - * - * `Glow` is consumed by `CompareHero`, which is frozen — do not change its - * signature. + * violet → indigo → blue, and the page spends it on one axis only: CleanStart + * is violet, Docker Hardened Images is neutral slate. A second accent hue for + * the comparator would read as a second brand. */ /* ─────────────────────────── tokens ─────────────────────────── */ @@ -23,10 +21,6 @@ import { cn } from "@/lib/cn"; export const BAND_DARK = "linear-gradient(180deg, #151021 0%, #131E8F 62.5%, #471EC0 100%)"; -/** Shorter dark band for sections that sit between two light ones. */ -export const BAND_DARK_SHORT = - "linear-gradient(180deg, #151021 0%, #1B1B6B 55%, #3A1BA8 100%)"; - /** Light section wash, matching `WhyMattersGrid` / `SbomAdvantage`. */ export const WASH_LIGHT = "#F6F6F6"; @@ -44,7 +38,6 @@ export const BRAND = { /** The single hairline weight used for structural lines on light sections. */ export const RULE_LIGHT = "1px solid rgba(17, 17, 17, 0.11)"; -export const RULE_DARK = "1px solid rgba(255, 255, 255, 0.16)"; /* ─────────────────────────── 3D icons ─────────────────────────── */ @@ -117,10 +110,7 @@ export function Icon3D({ /* ─────────────────────── decorative background ─────────────────────── */ -/** - * Ambient radial glow. Kept from the previous revision because `CompareHero` - * depends on it and the hero is frozen. - */ +/** Ambient radial glow, used to light the corners of the hero band. */ export function Glow({ color, size, diff --git a/apps/web/src/components/sections/financial-services/FinanceRequirements.tsx b/apps/web/src/components/sections/financial-services/FinanceRequirements.tsx index 2c9e3c257..f2625f6dd 100644 --- a/apps/web/src/components/sections/financial-services/FinanceRequirements.tsx +++ b/apps/web/src/components/sections/financial-services/FinanceRequirements.tsx @@ -217,9 +217,13 @@ export function FinanceRequirements(): React.ReactElement { {col.title} - {/* Capabilities List */} + {/* Capabilities List. The list is a shrink-wrapped block, so + the column's own alignment centres it under the title on + mobile; the ITEMS inside are always left-aligned, so the + checkmarks line up in one column instead of each row + centring on its own length. */}
      (
    • +// carries an explicit aria-label with the clean phrase instead. All motion +// lives in keyframes (never in base rules) so prefers-reduced-motion falls +// back to the final, static, fully-visible heading. Timing/keyframes: the +// cs-hh-* rules in globals.css. Typography stays on the role tokens // (--fs-display-home / --fs-display-ls) exactly as before. export function HeroHeading() { return ( @@ -21,12 +29,28 @@ export function HeroHeading() { letterSpacing: "var(--fs-display-ls)", lineHeight: 1.05, }} + aria-label="Verified. Secure. Built for the AI Era." > - {/* Caret lives on the wrapper so the inner clip-path doesn't crop it. */} - - Verified. Secure. - {" "} - Built for the AI Era. + ); } diff --git a/apps/web/src/components/sections/home/PlatformPipeline.tsx b/apps/web/src/components/sections/home/PlatformPipeline.tsx index 9b8aad7b1..d0bd4c6bd 100644 --- a/apps/web/src/components/sections/home/PlatformPipeline.tsx +++ b/apps/web/src/components/sections/home/PlatformPipeline.tsx @@ -155,8 +155,8 @@ function FactoryCard({ data, isFirst }: { data: CardData; isFirst: boolean }) { } // Intelligence Center bar — the original platform-bar treatment (indigo -// gradient, lavender stroke, diagonal hatch), opaque so it occludes the card -// exhaust-flare tails that bleed up from the gap above it. +// gradient, diagonal hatch), opaque so it occludes the card exhaust-flare +// tails that bleed up from the gap above it. function IntelligenceBar() { return (
      @@ -210,7 +209,6 @@ function FactoryEnclosure() { style={{ borderRadius: 32, background: "rgba(28, 28, 28, 0.7)", - border: "1px solid #dab6f3", boxShadow: "0px 4px 4px rgba(0,0,0,0.25)", }} > diff --git a/apps/web/src/components/sections/roi-calculator/RoiCTA.tsx b/apps/web/src/components/sections/impact-estimator/ImpactCTA.tsx similarity index 88% rename from apps/web/src/components/sections/roi-calculator/RoiCTA.tsx rename to apps/web/src/components/sections/impact-estimator/ImpactCTA.tsx index db9af59ca..80d4b2337 100644 --- a/apps/web/src/components/sections/roi-calculator/RoiCTA.tsx +++ b/apps/web/src/components/sections/impact-estimator/ImpactCTA.tsx @@ -2,11 +2,11 @@ import type React from "react"; import Link from "next/link"; /* - * Page-end CTA — paints inside the Footer's card slot (geometry owned by + * Page-end CTA. Paints inside the Footer's card slot (geometry owned by * Footer.tsx). Mirrors the CisoCTA recipe: white card, soft brand glows, a * mobile absolute layout and a desktop flex layout. */ -export function RoiCTA(): React.ReactElement { +export function ImpactCTA(): React.ReactElement { return (

      - The numbers above are modelled. Let’s measure the real reduction against your images. + The numbers above are modeled. Let’s measure the real reduction against your images.

      @@ -42,7 +42,7 @@ export function RoiCTA(): React.ReactElement {

      - The numbers above are modelled from your inputs. Let’s measure the real vulnerability, patch, and footprint reduction against your actual images. + The numbers above are modeled from your inputs. Let’s measure the real vulnerability, patch, and footprint reduction against your actual images.

      Book a demo diff --git a/apps/web/src/components/sections/impact-estimator/ImpactFAQ.tsx b/apps/web/src/components/sections/impact-estimator/ImpactFAQ.tsx new file mode 100644 index 000000000..6cc59d193 --- /dev/null +++ b/apps/web/src/components/sections/impact-estimator/ImpactFAQ.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useId, useState } from "react"; +import { Section, Container } from "@/components/layout"; +import { Reveal } from "@/components/ui/Reveal"; +import { FAQ_HEADING, FAQ_INTRO, FAQS, type ImpactFaq } from "./impact-content"; + +/* + * Single-open accordion, the same recipe as the compare page's FAQ. Questions + * are

      under the section H2 so screen-reader users can walk the list, and + * answers stay in the DOM when collapsed so the page source carries the same + * text as the FAQPage JSON-LD the route emits. + */ + +const ACCENT = "#471ec0"; + +function Row({ faq, open, onToggle, index }: { faq: ImpactFaq; open: boolean; onToggle: () => void; index: number }): React.ReactElement { + const panelId = `impact-faq-panel-${faq.id}`; + const buttonId = `impact-faq-trigger-${faq.id}`; + + return ( +
    • +

      + +

      + +
      +
      +
      +

      + {faq.answer} +

      +
      +
      +
      +
    • + ); +} + +export function ImpactFAQ(): React.ReactElement { + const [openId, setOpenId] = useState(FAQS[0]?.id ?? null); + const headingId = useId(); + + return ( + // Last section before the footer, so it owes the footer one CTA-card half of + // its own background to overlap (see the layout contract in Footer.tsx). +
      + +
      +
      + +

      + {FAQ_HEADING} +

      +

      + {FAQ_INTRO} +

      +
      +
      + + +
        + {FAQS.map((faq, index) => ( + setOpenId((current) => (current === faq.id ? null : faq.id))} /> + ))} +
      +
      +
      +
      +
      + ); +} diff --git a/apps/web/src/components/sections/roi-calculator/RoiHero.tsx b/apps/web/src/components/sections/impact-estimator/ImpactHero.tsx similarity index 80% rename from apps/web/src/components/sections/roi-calculator/RoiHero.tsx rename to apps/web/src/components/sections/impact-estimator/ImpactHero.tsx index 13ac02bd6..e81024fe9 100644 --- a/apps/web/src/components/sections/roi-calculator/RoiHero.tsx +++ b/apps/web/src/components/sections/impact-estimator/ImpactHero.tsx @@ -2,15 +2,15 @@ import type React from "react"; import { HeroReveal } from "@/components/ui/Reveal"; /* - * ROI calculator hero — LIGHT band. A soft brand-gradient wash plus a faint + * Impact Estimator hero, LIGHT band. A soft brand-gradient wash plus a faint * grid motif sit on white, keeping the premium look without the site's usual * dark hero. Eyebrow + headline (gradient accent word) + lead. Above the fold, * so it uses HeroReveal rather than FadeUp. */ -export function RoiHero(): React.ReactElement { +export function ImpactHero(): React.ReactElement { return (
      @@ -49,6 +49,22 @@ export function RoiHero(): React.ReactElement { paddingBottom: "clamp(32px, 4vw, 56px)", }} > + +

      + Impact Estimator +

      +
      +

      - See what hardened runtimes + See what hardened container images actually change

      @@ -79,7 +95,7 @@ export function RoiHero(): React.ReactElement { }} > Describe your setup and see what minimal, trusted container images - change — fewer vulnerabilities, faster releases, hours won back. + change: fewer vulnerabilities, faster releases, hours won back.

      diff --git a/apps/web/src/components/sections/roi-calculator/RoiHowItWorks.tsx b/apps/web/src/components/sections/impact-estimator/ImpactHowItWorks.tsx similarity index 82% rename from apps/web/src/components/sections/roi-calculator/RoiHowItWorks.tsx rename to apps/web/src/components/sections/impact-estimator/ImpactHowItWorks.tsx index 36cf8d405..7e257a101 100644 --- a/apps/web/src/components/sections/roi-calculator/RoiHowItWorks.tsx +++ b/apps/web/src/components/sections/impact-estimator/ImpactHowItWorks.tsx @@ -1,13 +1,14 @@ import type React from "react"; +import Link from "next/link"; import { Reveal } from "@/components/ui/Reveal"; /* - * "Why these numbers move together" — the input→output relationship rendered as + * "Why these numbers move together": the input-to-output relationship rendered as * a compounding chain (a metro-line on desktop, a vertical timeline on mobile). * Each station carries the calculator's own tier colour and a severity meter * that grows link-by-link, so the eye *sees* the burden amplify. A "CleanStart * cuts here" marker severs the spine after the first link; the dark banner below - * is the payoff. Server component — motion is CSS-only and reduced-motion aware. + * is the payoff. Server component; motion is CSS-only and reduced-motion aware. */ interface ChainLink { @@ -15,7 +16,7 @@ interface ChainLink { title: string; body: string; load: string; - /** severity-meter fill, 0–100 */ + /** severity-meter fill, 0 to 100 */ pct: number; /** dot gradient stops */ dot: [string, string]; @@ -32,30 +33,26 @@ const C4 = "#6b2ec9"; const C5 = "#8b1fc3"; const CHAIN: ChainLink[] = [ - { n: 1, title: "More production images", body: "Every image inherits its base-OS packages — and their CVEs.", load: "Low", pct: 16, dot: ["#43d0f2", C1], fill: C1, seg: [C1, C2] }, + { n: 1, title: "Inherited base-OS packages", body: "Every production image carries its base image's packages, and their CVEs.", load: "Low", pct: 16, dot: ["#43d0f2", C1], fill: C1, seg: [C1, C2] }, { n: 2, title: "Higher runtime complexity", body: "More surfaces to scan, patch, and keep compliant.", load: "Rising", pct: 36, dot: ["#5678ff", C2], fill: C2, seg: [C2, C3] }, { n: 3, title: "More vulnerability noise", body: "Scanners surface thousands of findings, most low-signal.", load: "High", pct: 58, dot: ["#5a35d6", C3], fill: C3, seg: [C3, C4] }, { n: 4, title: "Longer patch cycles", body: "Teams rebuild, re-test, and redeploy on every fix.", load: "Severe", pct: 79, dot: ["#7e3bd8", C4], fill: C4, seg: [C4, C5] }, { n: 5, title: "Engineering hours lost", body: "Toil that scales with your image and team count.", load: "Peak", pct: 100, dot: ["#a233d6", C5], fill: "linear-gradient(90deg,#8b1fc3,#c026d3)", seg: [C5, C5] }, ]; -export function RoiHowItWorks(): React.ReactElement { +export function ImpactHowItWorks(): React.ReactElement { return ( -
      - {/* pb = --spacing-section-cta so the Footer's floating CTA card (which - hangs half above the footer's top edge, per Footer.tsx §layout-contract) - overlaps this section's own white background instead of colliding with - the "CleanStart breaks the chain" dark banner at the section's tail. */} -
      +
      +
      -

      +

      Why these numbers move together

      - Inherited vulnerabilities compound down a predictable chain. Each link amplifies the next — which is exactly where the burden comes from. + Inherited vulnerabilities compound down a predictable chain. Each link amplifies the next, which is exactly where the burden comes from.

      @@ -89,7 +86,7 @@ export function RoiHowItWorks(): React.ReactElement { ))} - {/* cut marker — desktop only, between links 1 and 2 */} + {/* cut marker, desktop only, between links 1 and 2 */}
      @@ -123,7 +120,8 @@ export function RoiHowItWorks(): React.ReactElement { Cut the first link, and every number after it improves

      - Minimal, hardened images inherit far fewer CVEs at the source — so there is less to triage, patch, and re-test all the way downstream. + Minimal, hardened images inherit far fewer CVEs at the source, so there is less to triage, patch, and re-test all the way downstream.{" "} + See how CleanStart images are built

      diff --git a/apps/web/src/components/sections/impact-estimator/ImpactSimulator.tsx b/apps/web/src/components/sections/impact-estimator/ImpactSimulator.tsx new file mode 100644 index 000000000..d19a05253 --- /dev/null +++ b/apps/web/src/components/sections/impact-estimator/ImpactSimulator.tsx @@ -0,0 +1,649 @@ +"use client"; + +/* + * The interactive centrepiece of /impact-estimator. A four-part narrative: + * Your environment (inputs) → Operational Burden Score (gauge) + * → Expected improvements (KPIs) → Engineering Hours Recovered + * Numbers tween smoothly; technical terms carry accessible tooltips. Math and + * the client-owned naming both live in ./model.ts; the shareable-link state + * lives in ./url-state.ts. Inputs stay sticky beside the results on desktop; + * on smaller screens a fixed summary strip keeps the result in view while the + * user is still on the inputs card. + * + * Input labels stay sentence case (they are form fields); outcome labels are + * Title Case because they are the client's proper metric names; see model.ts. + */ + +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useReducedMotion } from "motion/react"; +import { copyText } from "@/lib/clipboard"; +import { + ANNUAL_ENG_HOURS, + BURDEN_SCALE, + computeImpact, + IMAGE_WEIGHT_THRESHOLDS, + INPUT_BOUNDS, + REMEDIATION_OPTIONS, + RELEASE_OPTIONS, + TEAM_WEIGHT_THRESHOLDS, + TIER_NAMES, + type RoiInput, + type TierName, +} from "./model"; +import { buildEstimatorSearch, DEFAULT_INPUT, INPUT_STEP, parseEstimatorSearch } from "./url-state"; + +/* ── colour system (AA-compliant text on white / #F6F6F6) ── */ +const INK = "#111111"; +const SUB = "#3a3f4c"; // ~9:1 +const MUTED = "#5b6070"; // ~6:1, safe for small captions +const ACCENT = "#3960F9"; + +const TIER_COLOR: Record = { + Low: "#2cc1eb", + Moderate: "#3960F9", + High: "#471ec0", + Extreme: "#8b1fc3", +}; +const TIER_SPAN: Record = { Low: 20, Moderate: 100, High: 100, Extreme: 40 }; +const SPAN_TOTAL = 260; +/* + * Verbatim from ROI 1.xlsx §"Background Scoring & Logic" item 1, which pairs one + * of these descriptions with each Runtime Complexity band. Only the terminal + * full stops are ours; the sheet omits them because they are cell values. + */ +const TIER_BLURB: Record = { + Low: "Small stable runtimes with limited inherited complexity.", + Moderate: "Growing container adoption with increasing remediation overhead.", + High: "Large runtime sprawl with frequent vulnerability management cycles.", + Extreme: "High-frequency enterprise delivery with significant inherited operational burden.", +}; + +/* ── tween: animates a display number toward `target`, retargeting on change ── */ +function useTweenNumber(target: number, active: boolean, duration = 500): number { + const [display, setDisplay] = useState(0); + const reduce = useReducedMotion(); + const current = useRef(0); + const raf = useRef(0); + + useEffect(() => { + if (!active) return; + if (reduce) { + current.current = target; + setDisplay(target); + return; + } + const from = current.current; + const start = performance.now(); + cancelAnimationFrame(raf.current); + const tick = (now: number): void => { + const p = Math.min((now - start) / duration, 1); + const eased = 1 - (1 - p) ** 3; + const value = from + (target - from) * eased; + current.current = value; + setDisplay(value); + if (p < 1) raf.current = requestAnimationFrame(tick); + }; + raf.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf.current); + }, [target, active, reduce, duration]); + + return display; +} + +/* ── accessible tooltip for jargon (hover + focus + tap, Esc to dismiss) ── */ +function InfoTip({ label, text }: { label: string; text: string }): React.ReactElement { + const [open, setOpen] = useState(false); + const id = useId(); + return ( + + + {open && ( + + {text} + + + )} + + ); +} + +/* ── SVG arc helpers for the radial gauge (angle: 0 = top, clockwise) ── */ +function polar(cx: number, cy: number, r: number, angle: number): { x: number; y: number } { + const a = ((angle - 90) * Math.PI) / 180; + return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }; +} +function arcPath(cx: number, cy: number, r: number, a1: number, a2: number): string { + const start = polar(cx, cy, r, a2); + const end = polar(cx, cy, r, a1); + const large = a2 - a1 <= 180 ? "0" : "1"; + return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`; +} + +const GAUGE = { w: 240, cx: 120, cy: 118, r: 94, stroke: 15 } as const; + +function RadialGauge({ progress, tier, burden }: { progress: number; tier: TierName; burden: number }): React.ReactElement { + const { cx, cy, r } = GAUGE; + const needleAngle = -90 + progress * 180; + const tip = polar(cx, cy, r - 20, needleAngle); + + let cursor = -90; + const zones = TIER_NAMES.map((name) => { + const start = cursor; + const end = cursor + (TIER_SPAN[name] / SPAN_TOTAL) * 180; + cursor = end; + return { name, start, end }; + }); + + return ( +
      +
      + + + {/* userSpaceOnUse, not the default objectBoundingBox: at burden 100 + and 360 the needle is exactly horizontal, so its bounding box has + zero height. Percentage filter regions resolve against that box, + making the region collapse and the needle disappear entirely at + both ends of the scale. A fixed region in user units is immune. */} + + + + + + {zones.map((z) => ( + + ))} + + + + +
      +
      {Math.round(burden)}
      +
      of {BURDEN_SCALE.max}
      +
      +
      + {/* proportional tier scale legend */} +
      + {TIER_NAMES.map((name) => ( + {name} + ))} +
      +
      + Operational Burden Score +
      +
      + ); +} + +/* + * Contextual descriptors so a raw number reads as a scale. These MUST stay on + * the model's own band edges: a caption that switches at a different count + * than the score does makes the gauge look broken ("it says Large estate but + * nothing moved"). One label per scoring band, in order. + */ +type BandLabels = readonly [string, string, string, string]; + +function bandLabel(value: number, thresholds: readonly number[], labels: BandLabels): string { + const [first, second, third, top] = labels; + const i = thresholds.findIndex((t) => value <= t); + return i === 0 ? first : i === 1 ? second : i === 2 ? third : top; +} + +const IMAGE_LABELS: BandLabels = ["Small footprint", "Growing estate", "Large estate", "Enterprise-scale"]; +const TEAM_LABELS: BandLabels = ["Small team", "Mid-sized org", "Large org", "Enterprise org"]; + +/* ── icons ── */ +const stroke = (d: string): React.ReactNode => ( + + + +); + +interface MetricDef { + key: "vuln" | "patch" | "release" | "footprint"; + title: string; + sub: string; + accent: string; + icon: React.ReactNode; +} +/* + * Titles are the client's outcome names, verbatim from ROI 1.xlsx §RESULTS and + * the Sheet2 "New CleanStart Model" column, the same words the sales deck uses. + * They are Title Case because they are proper metric names, not sentences. The + * `sub` line carries the plain-English gloss that the name alone doesn't give. + */ +const METRICS: MetricDef[] = [ + { key: "vuln", title: "Vulnerability Noise Reduction", sub: "Fewer false alarms to triage", accent: "#471ec0", icon: stroke("M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z") }, + { key: "patch", title: "Patch Cycle Overhead Reduction", sub: "Less time patching and re-testing", accent: "#3960F9", icon: stroke("M4 12a8 8 0 0 1 13.7-5.6L20 8M20 3v5h-5M20 12a8 8 0 0 1-13.7 5.6L4 16M4 21v-5h5") }, + { key: "release", title: "Faster Secure Releases", sub: "Ship trusted builds sooner", accent: "#2cc1eb", icon: stroke("M5 15c-1.5 1.3-2 5-2 5s3.7-.5 5-2c.7-.8.7-2 0-2.8a2 2 0 0 0-3 0zM8.5 13.5l2 2M13 20l2-4M8 11l-4 2M14.5 5.5a9 9 0 0 1 4 4l-6 6-4-4 6-6z") }, + { key: "footprint", title: "Runtime Footprint Reduction", sub: "Less to store, scan, and attack", accent: "#6b2ec9", icon: stroke("M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3zM12 3v18M20 7.5L12 12 4 7.5") }, +]; + +function clamp01(x: number): number { + return Math.max(0, Math.min(1, x)); +} + +/* + * Thumb is 22px wide and its centre travels from 11px to (track − 11px), so a + * bare `left: X%` would drift from the thumb by up to 11px at the ends. This + * matches the native thumb's travel exactly, which matters because the whole + * point of the ticks is to mark where the score steps. + */ +function thumbOffset(pct: number): string { + return `calc(${pct}% + ${(11 - pct * 0.22).toFixed(2)}px)`; +} + +function Slider({ label, tip, value, min, max, step, onChange, context, ticks }: { + label: string; tip: string; value: number; min: number; max: number; step: number; + onChange: (v: number) => void; context: string; ticks: readonly number[]; +}): React.ReactElement { + const pct = ((value - min) / (max - min)) * 100; + return ( +
      +
      + + {label} + + + {value} +
      + onChange(Number(e.target.value))} + style={{ ["--pct" as string]: `${pct}%` }} + /> + {/* Band markers: the score steps here, so telegraph it. Decorative: + the band name is already announced through aria-valuetext. */} +
      + {ticks.map((t) => { + const tickPct = ((t - min) / (max - min)) * 100; + const passed = value > t; + return ( + + + {t} + + ); + })} +
      +
      + {context} + {min} to {max} +
      +
      + ); +} + +function Segmented({ label, tip, options, value, onChange }: { + label: string; tip: string; options: readonly T[]; value: T; onChange: (v: T) => void; +}): React.ReactElement { + return ( +
      + + {label} + + +
      + {options.map((opt) => { + const on = opt === value; + return ( + + ); + })} +
      +
      + ); +} + +/* ── copy link: the four inputs travel in the URL, nothing else ── */ +function CopyResultsLink({ input }: { input: RoiInput }): React.ReactElement { + const [copied, setCopied] = useState(false); + const timer = useRef(0); + useEffect(() => () => window.clearTimeout(timer.current), []); + + const search = buildEstimatorSearch(input); + const copyLink = useCallback(async (): Promise => { + const ok = await copyText(`${window.location.origin}${window.location.pathname}${search}`); + if (!ok) return; + setCopied(true); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), 2000); + }, [search]); + + return ( + + ); +} + +/* ── mobile summary strip: keeps the result in view while the inputs are ── */ +function MobileSummary({ visible, tier, burden, hours, onJump }: { + visible: boolean; tier: TierName; burden: number; hours: number; onJump: () => void; +}): React.ReactElement { + return ( + + ); +} + +export function ImpactSimulator(): React.ReactElement { + const [input, setInput] = useState(DEFAULT_INPUT); + const [mounted, setMounted] = useState(false); + const touched = useRef(false); + const reduce = useReducedMotion(); + const inputsRef = useRef(null); + const resultsRef = useRef(null); + const gaugeRef = useRef(null); + const [inputsInView, setInputsInView] = useState(false); + const [gaugeInView, setGaugeInView] = useState(false); + + // Adopt a shared link's inputs once, after hydration, so the server and the + // client render the same defaults first. + useEffect(() => { + const fromUrl = parseEstimatorSearch(window.location.search); + if (Object.keys(fromUrl).length > 0) setInput((prev) => ({ ...prev, ...fromUrl })); + setMounted(true); + }, []); + + // Mirror the user's changes into the address bar so it is always shareable. + // Skipped for the initial state and for a link-driven load, which is already + // in the URL. + useEffect(() => { + if (!touched.current) return; + window.history.replaceState(window.history.state, "", `${window.location.pathname}${buildEstimatorSearch(input)}${window.location.hash}`); + }, [input]); + + // The strip shows while the user is working the inputs and the gauge, the + // actual readout, is not yet mostly on screen. The inputs observer trims the + // bottom 45% of the viewport so the card only counts once it has climbed into + // the working area, not the moment it peeks in under the hero. The gauge is + // watched rather than the whole results column because a tall column counts + // as visible long before its first number can be read. + useEffect(() => { + const inputs = inputsRef.current; + const gauge = gaugeRef.current; + if (!inputs || !gauge || typeof IntersectionObserver === "undefined") return; + const watchInputs = new IntersectionObserver(([e]) => setInputsInView(e?.isIntersecting ?? false), { rootMargin: "0px 0px -45% 0px" }); + const watchGauge = new IntersectionObserver(([e]) => setGaugeInView(e?.isIntersecting ?? false), { threshold: 0.6 }); + watchInputs.observe(inputs); + watchGauge.observe(gauge); + return () => { + watchInputs.disconnect(); + watchGauge.disconnect(); + }; + }, []); + + const update = (patch: Partial): void => { + touched.current = true; + setInput((prev) => ({ ...prev, ...patch })); + }; + const jumpToResults = (): void => { + resultsRef.current?.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" }); + }; + + const out = computeImpact(input); + + const vuln = useTweenNumber(out.vuln, mounted); + const patch = useTweenNumber(out.patch, mounted); + const releaseX = useTweenNumber(out.release, mounted); + const footprint = useTweenNumber(out.footprint, mounted); + const hours = useTweenNumber(out.hoursRecovered, mounted); + const fte = useTweenNumber(out.fteRecovered, mounted); + const meter = useTweenNumber(out.meterProgress, mounted, 650); + const burden = useTweenNumber(out.burden, mounted, 650); + const reduction = useTweenNumber(out.burdenReduction, mounted); + + const cardData: Record = { + vuln: { value: `${Math.round(vuln)}%`, raw: vuln, band: out.bands.vuln, suffix: "%" }, + patch: { value: `${Math.round(patch)}%`, raw: patch, band: out.bands.patch, suffix: "%" }, + release: { value: `${(Math.round(releaseX * 10) / 10).toFixed(1)}×`, raw: releaseX, band: out.bands.release, suffix: "×" }, + footprint: { value: `${Math.round(footprint)}%`, raw: footprint, band: out.bands.footprint, suffix: "%" }, + }; + + // ANNUAL_ENG_HOURS is a 40-hour-week working year net of leave, so dividing + // by 40 gives the weeks it spans. + const hoursPerWeek = out.hoursPerEngineer / (ANNUAL_ENG_HOURS / 40); + + // No overflow-hidden on the section: an overflow-clipping ancestor turns + // position: sticky off, and the inputs card relies on it. The decorative blob + // is clipped inside its own absolutely positioned layer instead. + return ( +
      +
      +
      +
      + + + +
      +
      + {/* ── inputs: sticky beside the results on desktop ── */} +
      +

      Your environment

      +

      Four signals describe your runtime.

      + + update({ images: v })} context={bandLabel(input.images, IMAGE_WEIGHT_THRESHOLDS, IMAGE_LABELS)} ticks={IMAGE_WEIGHT_THRESHOLDS} /> + update({ team: v })} context={bandLabel(input.team, TEAM_WEIGHT_THRESHOLDS, TEAM_LABELS)} ticks={TEAM_WEIGHT_THRESHOLDS} /> + update({ remediation: v })} /> + update({ release: v })} /> + +

      + + + + + Inputs stay in your browser. Nothing is sent or stored. +

      +
      + + {/* ── results narrative ── */} +
      + {/* The results have no visible title (the gauge is the title), so the + outline gets a screen-reader heading to sit level with "Your environment". */} +

      Your estimated outcomes

      + {/* operational burden: gauge, tier, reduction, breakdown */} +
      +
      +
      + +
      +
      +
      + {out.tier} + Runtime Complexity +
      +

      {TIER_BLURB[out.tier]}

      + + {/* Burden Reduction keys off the score, not the tier, so it + belongs beside the gauge rather than in the improvements grid. */} +
      + + {Math.round(reduction)}% + + {/* Plain span, NOT inline-flex: the label and its InfoTip have to + flow as one run of text so the icon trails the last word. As a + flex container the raw text became its own anonymous item and + the icon was pushed out to the pill's right edge. */} + + Burden Reduction on trusted images{" "} + + +
      +
      +
      +
      + + {/* expected improvements */} +
      + {METRICS.map((m) => { + const d = cardData[m.key]; + const pos = clamp01((d.raw - d.band[0]) / (d.band[1] - d.band[0])); + return ( +
      +
      + {m.icon} +
      {d.value}
      +
      {m.title}
      +
      {m.sub}
      +
      +
      +
      +
      +
      +
      + {d.band[0]}{d.suffix} + {out.tier} tier range + {d.band[1]}{d.suffix} +
      +
      +
      + ); + })} +
      + + {/* recovered engineering capacity */} +
      +
      +
      +
      + + {stroke("M12 7v5l3 2M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z")} + Hours recovered per year + +
      + {Math.round(hours).toLocaleString("en-US")} +
      +

      + Roughly {fte.toFixed(1)} full-time engineers of capacity, won back from vulnerability toil. +

      +
      + {/* supporting stats: a fixed 3-up so they never wrap 2 plus 1 */} +
      + {[ + { v: out.hoursPerEngineer.toLocaleString("en-US"), l: "hrs / engineer / yr" }, + { v: input.team.toString(), l: "engineers" }, + { v: hoursPerWeek.toFixed(1), l: "hrs / engineer / wk" }, + ].map((s, i) => ( +
      + {s.v} + {s.l} +
      + ))} +
      +
      +
      + + {/* trust line, with the share action beside it rather than inside the hours card */} + {/* Text left, action right on every width from sm up; only phones stack. */} +
      +

      + Estimated outcomes, modeled from industry benchmarks and organizations with similar runtime profiles. Directional, not a guarantee. + +

      + +
      +
      +
      +
      + + +
      + ); +} diff --git a/apps/web/src/components/sections/impact-estimator/impact-content.ts b/apps/web/src/components/sections/impact-estimator/impact-content.ts new file mode 100644 index 000000000..76e013e8f --- /dev/null +++ b/apps/web/src/components/sections/impact-estimator/impact-content.ts @@ -0,0 +1,54 @@ +/* + * FAQ copy for the page. Answers describe the model in words only; the numbers + * live in ./model and are not repeated here. + */ + +export interface ImpactFaq { + readonly id: string; + readonly question: string; + readonly answer: string; +} + +export const FAQ_HEADING = "Questions about the estimate"; + +export const FAQ_INTRO = + "What the numbers mean, where they come from, and what happens to the values you enter."; + +export const FAQS: readonly ImpactFaq[] = [ + { + id: "what-it-measures", + question: "What does the Impact Estimator measure?", + answer: + "It measures operational burden: the vulnerability triage, patching and re-testing load that inherited base-image packages create for a team. It reports the reductions that minimal, trusted container images typically deliver for a runtime of your shape, in percentages, release multiples and engineering hours. It does not price anything, so there is no currency figure.", + }, + { + id: "where-numbers-come-from", + question: "Where do the numbers come from?", + answer: + "The bands, weights and outcome ranges are CleanStart's operational model, built from customer environments and industry benchmarks for container vulnerability management. They describe what organizations with a comparable burden profile see. They are directional estimates, not a measurement of your images.", + }, + { + id: "why-jumps", + question: "Why do the figures jump at certain slider positions?", + answer: + "Counts are scored in bands rather than on a continuous curve, so the burden score changes only when a slider crosses a band edge. The tick marks under each slider show where those edges sit. Inside a tier the outcome figures move smoothly with your position in that tier.", + }, + { + id: "hours-formula", + question: "How are Engineering Hours Recovered calculated?", + answer: + "Each tier has a share of a working year that engineers lose to vulnerability toil. That share is multiplied by your Vulnerability Noise Reduction to give hours recovered per engineer per year, rounded to the nearest five, then multiplied by your team size. The full-time-engineer figure divides the total by the same working year.", + }, + { + id: "data-privacy", + question: "Is anything I enter sent to CleanStart?", + answer: + "No. The model runs entirely in your browser and nothing is stored on a server. The only place the four inputs travel is the address bar, so a copied link reproduces the same result for a teammate. Booking a demo is a separate form that you fill in yourself.", + }, + { + id: "real-numbers", + question: "How do I get numbers for my actual images?", + answer: + "Book a demo. CleanStart scans your real images and reports the measured vulnerability, patch and footprint reduction against the same outcome names used on this page, so the estimate and the measurement line up.", + }, +]; diff --git a/apps/web/src/components/sections/roi-calculator/model.test.ts b/apps/web/src/components/sections/impact-estimator/model.test.ts similarity index 96% rename from apps/web/src/components/sections/roi-calculator/model.test.ts rename to apps/web/src/components/sections/impact-estimator/model.test.ts index b67821d42..4b1fe6a46 100644 --- a/apps/web/src/components/sections/roi-calculator/model.test.ts +++ b/apps/web/src/components/sections/impact-estimator/model.test.ts @@ -16,7 +16,7 @@ import { /* * These tests transcribe the client's `ROI 1.xlsx` and exist to stop the model * drifting away from it again. A failure here is not necessarily a bug in the - * code — it means the code and the client's sheet now disagree, which is a + * code; it means the code and the client's sheet now disagree, which is a * question for the client before it is a fix for us. */ @@ -43,7 +43,7 @@ describe("input weight bands (sheet §Background Scoring & Logic)", () => { [11, 2], [50, 2], [51, 3], - // The sheet writes "51–100" and "100+"; the explicit range wins at 100. + // The sheet writes "51 to 100" and "100+"; the explicit range wins at 100. [100, 3], [101, 4], [200, 4], @@ -58,11 +58,10 @@ describe("input weight bands (sheet §Background Scoring & Logic)", () => { it("scores cadences in the order the sheet lists them", () => { // Ascending burden, straight from the sheet's weight tables. Deliberately - // NOT the *_OPTIONS constants — those carry the UI's display order, which - // for remediation runs the opposite way. + // NOT the *_OPTIONS constants, which carry the UI's display order. const remediationByBurden: readonly RemediationOption[] = ["Quarterly", "Monthly", "Weekly"]; const releaseByBurden: readonly ReleaseOption[] = ["Monthly", "Biweekly", "Continuous"]; - // Each cadence step is worth one level at 20% weight — 20 points. + // Each cadence step is worth one level at 20% weight, so 20 points. for (const [lower, higher] of pairs(remediationByBurden.map((r) => at({ remediation: r }).burden))) { expect(higher - lower).toBeCloseTo(20); } @@ -72,7 +71,7 @@ describe("input weight bands (sheet §Background Scoring & Logic)", () => { }); it("offers remediation most-frequent-first in the UI", () => { - expect(REMEDIATION_OPTIONS).toEqual(["Weekly", "Monthly", "Quarterly"]); + expect(REMEDIATION_OPTIONS).toEqual(["Quarterly", "Monthly", "Weekly"]); expect(RELEASE_OPTIONS).toEqual(["Monthly", "Biweekly", "Continuous"]); }); }); @@ -230,7 +229,7 @@ describe("outcome bands (sheet §Runtime Complexity table)", () => { describe("engineering hours recovered", () => { /* - * NOTE: the client's sheet leaves the formula for this output blank — only the + * NOTE: the client's sheet leaves the formula for this output blank; only the * per-tier "time lost" fraction (F) is given. The derivation below is ours and * is pending their confirmation, so these tests pin the behaviour we ship * rather than a client-stated rule. diff --git a/apps/web/src/components/sections/roi-calculator/model.ts b/apps/web/src/components/sections/impact-estimator/model.ts similarity index 88% rename from apps/web/src/components/sections/roi-calculator/model.ts rename to apps/web/src/components/sections/impact-estimator/model.ts index 86cea2ec3..64f7ec4d2 100644 --- a/apps/web/src/components/sections/roi-calculator/model.ts +++ b/apps/web/src/components/sections/impact-estimator/model.ts @@ -1,9 +1,9 @@ /* - * CleanStart Operational Impact model — the single source of truth for the - * ROI calculator's math. Pure, deterministic, dependency-free so it can be unit + * CleanStart Operational Impact model: the single source of truth for the + * Impact Estimator page's math. Pure, deterministic, dependency-free so it can be unit * tested and reasoned about in isolation from the UI. * - * Pipeline: four inputs → 1–4 weights → a blended Operational Burden Score → + * Pipeline: four inputs to 1 to 4 weights, to a blended Operational Burden Score, to * a Runtime Complexity tier → five interpolated operational outcomes. * * NAMING IS CLIENT-OWNED TOO. The outcome names surfaced in the UI (Vulnerability @@ -17,7 +17,7 @@ * * SCORING IS CLIENT-OWNED. Every band, weight and constant below is transcribed * from the client's `ROI 1.xlsx` §"Background Scoring & Logic" and must not be - * "improved" without their sign-off — the same tables drive their sales + * "improved" without their sign-off; the same tables drive their sales * collateral, so any divergence makes the site and the deck disagree. An earlier * revision replaced the discrete image/team bands with a continuous log curve; * it read better on a slider but silently moved 13% of inputs into a different @@ -33,9 +33,9 @@ export type ReleaseOption = "Monthly" | "Biweekly" | "Continuous"; export type TierName = "Low" | "Moderate" | "High" | "Extreme"; export interface RoiInput { - /** Production container images, 10–500. */ + /** Production container images, 10 to 500. */ images: number; - /** Engineering team size, 5–200. */ + /** Engineering team size, 5 to 200. */ team: number; remediation: RemediationOption; release: ReleaseOption; @@ -53,9 +53,9 @@ interface TierBand { export interface RoiOutput { burden: number; tier: TierName; - /** "Burden Reduction" — share of the score removed, as a percentage. Sheet §2. */ + /** "Burden Reduction": share of the score removed, as a percentage. Sheet §2. */ burdenReduction: number; - /** 0–1 position of the score across the full 100–360 scale (for the meter). */ + /** 0 to 1 position of the score across the full 100 to 360 scale (for the meter). */ meterProgress: number; /** Per-input breakdown of what drives the burden score (for transparency UI). */ contributions: BurdenContribution[]; @@ -76,7 +76,7 @@ export interface BurdenContribution { weightPct: number; /** This input's current level. */ level: number; - /** Highest level this input can reach — 4 for counts, 3 for the two cadences. */ + /** Highest level this input can reach: 4 for counts, 3 for the two cadences. */ maxLevel: number; /** Points this input contributes to the burden score. */ points: number; @@ -88,14 +88,14 @@ const INPUT_RANGE = { } as const; /* - * Client bands, verbatim from ROI 1.xlsx: images 0–25 / 26–100 / 101–250 / - * 251–500 and team 1–10 / 11–50 / 51–100 / 100+. Stored as the inclusive upper + * Client bands, verbatim from ROI 1.xlsx: images 0 to 25 / 26 to 100 / 101 to 250 / + * 251 to 500 and team 1 to 10 / 11 to 50 / 51 to 100 / 100+. Stored as the inclusive upper * bound of each band except the last, so a value's weight is just how many - * bounds it has passed, plus one — no sentinel band, no lookup table to keep in + * bounds it has passed, plus one: no sentinel band, no lookup table to keep in * step with the weights. * * The sheet writes the top team band as "100+" while the band below it is - * "51–100"; the explicit range wins, so 100 scores 3 and 101 first scores 4. + * "51 to 100"; the explicit range wins, so 100 scores 3 and 101 first scores 4. */ const IMAGE_THRESHOLDS: readonly number[] = [25, 100, 250]; const TEAM_THRESHOLDS: readonly number[] = [10, 50, 100]; @@ -132,7 +132,7 @@ const TIERS: readonly Tier[] = [TIER_LOW, TIER_MODERATE, TIER_HIGH, TIER_EXTREME /* * Counts reach 4; the two cadences only offer three settings, so they top out - * at 3. That asymmetry is what makes the scale 100–360 rather than 100–400. + * at 3. That asymmetry is what makes the scale 100 to 360 rather than 100 to 400. */ const MAX_COUNT_LEVEL = 4; const MAX_CADENCE_LEVEL = 3; @@ -153,7 +153,7 @@ function lerp(lo: number, hi: number, t: number): number { * * Two things to know before touching this. Its cut points sit exactly 30 above * the tier boundaries (150/250/350 against 120/220/320), which makes this figure - * disagree with the gauge at four reachable scores — a burden of 140 reads + * disagree with the gauge at four reachable scores: a burden of 140 reads * "Moderate" but reports the Low-tier reduction. Kept as written pending the * client's confirmation that the offset is deliberate. * @@ -240,7 +240,7 @@ export function computeImpact(input: RoiInput): RoiOutput { * reordering these is purely presentational and cannot move a score. * model.test.ts pins this display order and the sheet's scoring order apart. */ -export const REMEDIATION_OPTIONS: readonly RemediationOption[] = ["Weekly", "Monthly", "Quarterly"]; +export const REMEDIATION_OPTIONS: readonly RemediationOption[] = ["Quarterly", "Monthly", "Weekly"]; export const RELEASE_OPTIONS: readonly ReleaseOption[] = ["Monthly", "Biweekly", "Continuous"]; export const TIER_NAMES: readonly TierName[] = ["Low", "Moderate", "High", "Extreme"]; export const INPUT_BOUNDS = INPUT_RANGE; diff --git a/apps/web/src/components/sections/impact-estimator/url-state.test.ts b/apps/web/src/components/sections/impact-estimator/url-state.test.ts new file mode 100644 index 000000000..290559248 --- /dev/null +++ b/apps/web/src/components/sections/impact-estimator/url-state.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_INPUT, buildEstimatorSearch, parseEstimatorSearch } from "./url-state"; + +describe("parseEstimatorSearch", () => { + it("returns nothing for an empty query", () => { + expect(parseEstimatorSearch("")).toEqual({}); + expect(parseEstimatorSearch("?")).toEqual({}); + }); + + it("reads all four inputs", () => { + expect(parseEstimatorSearch("?images=120&team=25&remediation=Weekly&release=Biweekly")).toEqual({ + images: 120, + team: 25, + remediation: "Weekly", + release: "Biweekly", + }); + }); + + it("accepts a bare query string without the leading question mark", () => { + expect(parseEstimatorSearch("images=120")).toEqual({ images: 120 }); + }); + + it("matches option names case-insensitively", () => { + expect(parseEstimatorSearch("?remediation=quarterly&release=CONTINUOUS")).toEqual({ + remediation: "Quarterly", + release: "Continuous", + }); + }); + + it("drops unknown option values", () => { + expect(parseEstimatorSearch("?remediation=daily&release=yearly")).toEqual({}); + }); + + it("clamps counts to the slider bounds", () => { + expect(parseEstimatorSearch("?images=9999&team=0")).toEqual({ images: 500, team: 5 }); + }); + + it("snaps counts to the slider step", () => { + expect(parseEstimatorSearch("?images=123&team=37")).toEqual({ images: 120, team: 35 }); + }); + + it("drops counts that are not finite numbers", () => { + expect(parseEstimatorSearch("?images=abc&team=")).toEqual({}); + expect(parseEstimatorSearch("?images=NaN&team=Infinity")).toEqual({}); + }); + + it("ignores unrelated params", () => { + expect(parseEstimatorSearch("?utm_source=x&images=40")).toEqual({ images: 40 }); + }); +}); + +describe("buildEstimatorSearch", () => { + it("serialises every input in a stable order", () => { + expect(buildEstimatorSearch({ images: 120, team: 25, remediation: "Weekly", release: "Biweekly" })).toBe( + "?images=120&team=25&remediation=Weekly&release=Biweekly", + ); + }); + + it("round-trips through the parser", () => { + const input = { images: 380, team: 155, remediation: "Quarterly", release: "Monthly" } as const; + expect(parseEstimatorSearch(buildEstimatorSearch(input))).toEqual(input); + }); +}); + +describe("DEFAULT_INPUT", () => { + it("sits inside a band rather than on its edge", () => { + // 250 images was the last value of the Large band and 50 engineers the last + // of the Mid-sized band, which made the first slider step change the tier. + expect(DEFAULT_INPUT.images).toBe(200); + expect(DEFAULT_INPUT.team).toBe(40); + }); + + it("round-trips through the parser", () => { + expect(parseEstimatorSearch(buildEstimatorSearch(DEFAULT_INPUT))).toEqual(DEFAULT_INPUT); + }); +}); diff --git a/apps/web/src/components/sections/impact-estimator/url-state.ts b/apps/web/src/components/sections/impact-estimator/url-state.ts new file mode 100644 index 000000000..6f6de5f55 --- /dev/null +++ b/apps/web/src/components/sections/impact-estimator/url-state.ts @@ -0,0 +1,77 @@ +/* + * The estimator's inputs live in the URL so a result can be shared by link + * without anything leaving the browser. Parsing is deliberately forgiving on + * the way in (case, clamping, snapping) and strict on the way out (a stable + * key order), so a hand-edited or truncated link still lands on a valid state + * and two identical states always produce byte-identical links. + */ + +import { + INPUT_BOUNDS, + RELEASE_OPTIONS, + REMEDIATION_OPTIONS, + type ReleaseOption, + type RemediationOption, + type RoiInput, +} from "./model"; + +/** Slider steps. Kept beside the parser because snapping depends on them. */ +export const INPUT_STEP = { images: 10, team: 5 } as const; + +/* + * Mid-band on both counts. 250 images and 50 engineers were the top values of + * their bands, so the first nudge of either slider changed the tier, which read + * as staged. + */ +export const DEFAULT_INPUT: RoiInput = { + images: 200, + team: 40, + remediation: "Monthly", + release: "Continuous", +}; + +function readCount( + raw: string | null, + bounds: { readonly min: number; readonly max: number }, + step: number, +): number | undefined { + if (raw === null || raw.trim() === "") return undefined; + const n = Number(raw); + if (!Number.isFinite(n)) return undefined; + const clamped = Math.min(bounds.max, Math.max(bounds.min, n)); + return Math.round(clamped / step) * step; +} + +function readOption(raw: string | null, options: readonly T[]): T | undefined { + if (raw === null) return undefined; + const needle = raw.trim().toLowerCase(); + return options.find((o) => o.toLowerCase() === needle); +} + +export function parseEstimatorSearch(search: string): Partial { + const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + const out: Partial = {}; + + const images = readCount(params.get("images"), INPUT_BOUNDS.images, INPUT_STEP.images); + if (images !== undefined) out.images = images; + + const team = readCount(params.get("team"), INPUT_BOUNDS.team, INPUT_STEP.team); + if (team !== undefined) out.team = team; + + const remediation = readOption(params.get("remediation"), REMEDIATION_OPTIONS); + if (remediation !== undefined) out.remediation = remediation; + + const release = readOption(params.get("release"), RELEASE_OPTIONS); + if (release !== undefined) out.release = release; + + return out; +} + +export function buildEstimatorSearch(input: RoiInput): string { + const params = new URLSearchParams(); + params.set("images", String(input.images)); + params.set("team", String(input.team)); + params.set("remediation", input.remediation); + params.set("release", input.release); + return `?${params.toString()}`; +} diff --git a/apps/web/src/components/sections/roi-calculator/RoiSimulator.tsx b/apps/web/src/components/sections/roi-calculator/RoiSimulator.tsx deleted file mode 100644 index 8c03c5dff..000000000 --- a/apps/web/src/components/sections/roi-calculator/RoiSimulator.tsx +++ /dev/null @@ -1,516 +0,0 @@ -"use client"; - -/* - * The interactive centrepiece of /roi-calculator. A four-step narrative: - * 1 Your environment (inputs) → 2 Operational Burden Score (gauge) - * 3 Expected improvements (KPIs) → 4 Engineering Hours Recovered - * Numbers tween smoothly; technical terms carry accessible tooltips. Math and - * the client-owned naming both live in ./model.ts. - * - * Input labels stay sentence case (they are form fields); outcome labels are - * Title Case because they are the client's proper metric names — see model.ts. - */ - -import { useEffect, useId, useRef, useState } from "react"; -import { useReducedMotion } from "motion/react"; -import { - BURDEN_SCALE, - computeImpact, - IMAGE_WEIGHT_THRESHOLDS, - INPUT_BOUNDS, - REMEDIATION_OPTIONS, - RELEASE_OPTIONS, - TEAM_WEIGHT_THRESHOLDS, - TIER_NAMES, - type ReleaseOption, - type RemediationOption, - type TierName, -} from "./model"; - -/* ── colour system (AA-compliant text on white / #F6F6F6) ── */ -const INK = "#111111"; -const SUB = "#3a3f4c"; // ~9:1 -const MUTED = "#5b6070"; // ~6:1 — safe for small captions -const ACCENT = "#3960F9"; - -const TIER_COLOR: Record = { - Low: "#2cc1eb", - Moderate: "#3960F9", - High: "#471ec0", - Extreme: "#8b1fc3", -}; -const TIER_SPAN: Record = { Low: 20, Moderate: 100, High: 100, Extreme: 40 }; -const SPAN_TOTAL = 260; -/* - * Verbatim from ROI 1.xlsx §"Background Scoring & Logic" item 1, which pairs one - * of these descriptions with each Runtime Complexity band. Only the terminal - * full stops are ours — the sheet omits them because they are cell values. - */ -const TIER_BLURB: Record = { - Low: "Small stable runtimes with limited inherited complexity.", - Moderate: "Growing container adoption with increasing remediation overhead.", - High: "Large runtime sprawl with frequent vulnerability management cycles.", - Extreme: "High-frequency enterprise delivery with significant inherited operational burden.", -}; - -/* ── tween: animates a display number toward `target`, retargeting on change ── */ -function useTweenNumber(target: number, active: boolean, duration = 500): number { - const [display, setDisplay] = useState(0); - const reduce = useReducedMotion(); - const current = useRef(0); - const raf = useRef(0); - - useEffect(() => { - if (!active) return; - if (reduce) { - current.current = target; - setDisplay(target); - return; - } - const from = current.current; - const start = performance.now(); - cancelAnimationFrame(raf.current); - const tick = (now: number): void => { - const p = Math.min((now - start) / duration, 1); - const eased = 1 - (1 - p) ** 3; - const value = from + (target - from) * eased; - current.current = value; - setDisplay(value); - if (p < 1) raf.current = requestAnimationFrame(tick); - }; - raf.current = requestAnimationFrame(tick); - return () => cancelAnimationFrame(raf.current); - }, [target, active, reduce, duration]); - - return display; -} - -/* ── accessible tooltip for jargon (hover + focus + tap, Esc to dismiss) ── */ -function InfoTip({ label, text }: { label: string; text: string }): React.ReactElement { - const [open, setOpen] = useState(false); - const id = useId(); - return ( - - - {open && ( - - {text} - - - )} - - ); -} - -/* ── SVG arc helpers for the radial gauge (angle: 0 = top, clockwise) ── */ -function polar(cx: number, cy: number, r: number, angle: number): { x: number; y: number } { - const a = ((angle - 90) * Math.PI) / 180; - return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }; -} -function arcPath(cx: number, cy: number, r: number, a1: number, a2: number): string { - const start = polar(cx, cy, r, a2); - const end = polar(cx, cy, r, a1); - const large = a2 - a1 <= 180 ? "0" : "1"; - return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`; -} - -const GAUGE = { w: 240, cx: 120, cy: 118, r: 94, stroke: 15 } as const; - -function RadialGauge({ progress, tier, burden }: { progress: number; tier: TierName; burden: number }): React.ReactElement { - const { cx, cy, r } = GAUGE; - const needleAngle = -90 + progress * 180; - const tip = polar(cx, cy, r - 20, needleAngle); - - let cursor = -90; - const zones = TIER_NAMES.map((name) => { - const start = cursor; - const end = cursor + (TIER_SPAN[name] / SPAN_TOTAL) * 180; - cursor = end; - return { name, start, end }; - }); - - return ( -
      -
      - - - {/* userSpaceOnUse, not the default objectBoundingBox: at burden 100 - and 360 the needle is exactly horizontal, so its bounding box has - zero height. Percentage filter regions resolve against that box, - making the region collapse and the needle disappear entirely at - both ends of the scale. A fixed region in user units is immune. */} - - - - - - {zones.map((z) => ( - - ))} - - - - -
      -
      {Math.round(burden)}
      -
      of {BURDEN_SCALE.max}
      -
      -
      - {/* proportional tier scale legend */} -
      - {TIER_NAMES.map((name) => ( - {name} - ))} -
      -
      - ); -} - -/* - * Contextual descriptors so a raw number reads as a scale. These MUST stay on - * the model's own band edges — a caption that switches at a different count - * than the score does makes the gauge look broken ("it says Large estate but - * nothing moved"). One label per scoring band, in order. - */ -type BandLabels = readonly [string, string, string, string]; - -function bandLabel(value: number, thresholds: readonly number[], labels: BandLabels): string { - const [first, second, third, top] = labels; - const i = thresholds.findIndex((t) => value <= t); - return i === 0 ? first : i === 1 ? second : i === 2 ? third : top; -} - -const IMAGE_LABELS: BandLabels = ["Small footprint", "Growing estate", "Large estate", "Enterprise-scale"]; -const TEAM_LABELS: BandLabels = ["Small team", "Mid-sized org", "Large org", "Enterprise org"]; - -/* ── icons ── */ -const stroke = (d: string): React.ReactNode => ( - - - -); - -interface MetricDef { - key: "vuln" | "patch" | "release" | "footprint"; - title: string; - sub: string; - accent: string; - icon: React.ReactNode; -} -/* - * Titles are the client's outcome names, verbatim from ROI 1.xlsx §RESULTS and - * the Sheet2 "New CleanStart Model" column — the same words the sales deck uses. - * They are Title Case because they are proper metric names, not sentences. The - * `sub` line carries the plain-English gloss that the name alone doesn't give. - */ -const METRICS: MetricDef[] = [ - { key: "vuln", title: "Vulnerability Noise Reduction", sub: "Fewer false alarms to triage", accent: "#471ec0", icon: stroke("M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z") }, - { key: "patch", title: "Patch Cycle Overhead Reduction", sub: "Less time patching and re-testing", accent: "#3960F9", icon: stroke("M4 12a8 8 0 0 1 13.7-5.6L20 8M20 3v5h-5M20 12a8 8 0 0 1-13.7 5.6L4 16M4 21v-5h5") }, - { key: "release", title: "Faster Secure Releases", sub: "Ship trusted builds sooner", accent: "#0e7fa8", icon: stroke("M5 15c-1.5 1.3-2 5-2 5s3.7-.5 5-2c.7-.8.7-2 0-2.8a2 2 0 0 0-3 0zM8.5 13.5l2 2M13 20l2-4M8 11l-4 2M14.5 5.5a9 9 0 0 1 4 4l-6 6-4-4 6-6z") }, - { key: "footprint", title: "Runtime Footprint Reduction", sub: "Less to store, scan, and attack", accent: "#6b2ec9", icon: stroke("M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3zM12 3v18M20 7.5L12 12 4 7.5") }, -]; - -function clamp01(x: number): number { - return Math.max(0, Math.min(1, x)); -} - -function StepLabel({ text, color = ACCENT }: { text: string; color?: string }): React.ReactElement { - return {text}; -} - -/* - * Thumb is 22px wide and its centre travels from 11px to (track − 11px), so a - * bare `left: X%` would drift from the thumb by up to 11px at the ends. This - * matches the native thumb's travel exactly, which matters because the whole - * point of the ticks is to mark where the score steps. - */ -function thumbOffset(pct: number): string { - return `calc(${pct}% + ${(11 - pct * 0.22).toFixed(2)}px)`; -} - -function Slider({ label, tip, value, min, max, step, onChange, context, ticks }: { - label: string; tip: string; value: number; min: number; max: number; step: number; - onChange: (v: number) => void; context: string; ticks: readonly number[]; -}): React.ReactElement { - const pct = ((value - min) / (max - min)) * 100; - return ( -
      -
      - - {label} - - - {value} -
      - onChange(Number(e.target.value))} - style={{ ["--pct" as string]: `${pct}%` }} - /> - {/* Band markers — the score steps here, so telegraph it. Decorative: - the band name is already announced through aria-valuetext. */} -
      - {ticks.map((t) => { - const tickPct = ((t - min) / (max - min)) * 100; - const passed = value > t; - return ( - - - {t} - - ); - })} -
      -
      - {context} - {min}–{max} -
      -
      - ); -} - -function Segmented({ label, tip, options, value, onChange }: { - label: string; tip: string; options: readonly T[]; value: T; onChange: (v: T) => void; -}): React.ReactElement { - return ( -
      - - {label} - - -
      - {options.map((opt) => { - const on = opt === value; - return ( - - ); - })} -
      -
      - ); -} - -export function RoiSimulator(): React.ReactElement { - const [images, setImages] = useState(250); - const [team, setTeam] = useState(50); - const [remediation, setRemediation] = useState("Monthly"); - const [release, setRelease] = useState("Continuous"); - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - - const out = computeImpact({ images, team, remediation, release }); - - const vuln = useTweenNumber(out.vuln, mounted); - const patch = useTweenNumber(out.patch, mounted); - const releaseX = useTweenNumber(out.release, mounted); - const footprint = useTweenNumber(out.footprint, mounted); - const hours = useTweenNumber(out.hoursRecovered, mounted); - const fte = useTweenNumber(out.fteRecovered, mounted); - const meter = useTweenNumber(out.meterProgress, mounted, 650); - const burden = useTweenNumber(out.burden, mounted, 650); - const reduction = useTweenNumber(out.burdenReduction, mounted); - - const cardData: Record = { - vuln: { value: `${Math.round(vuln)}%`, raw: vuln, band: out.bands.vuln, suffix: "%" }, - patch: { value: `${Math.round(patch)}%`, raw: patch, band: out.bands.patch, suffix: "%" }, - release: { value: `${(Math.round(releaseX * 10) / 10).toFixed(1)}×`, raw: releaseX, band: out.bands.release, suffix: "×" }, - footprint: { value: `${Math.round(footprint)}%`, raw: footprint, band: out.bands.footprint, suffix: "%" }, - }; - - return ( -
      -
      - - - -
      -
      - {/* ── STEP 1: inputs ── */} -
      - -

      Your environment

      -

      Four signals describe your runtime.

      - - - - - - -

      - 🔒 Inputs stay in your browser — nothing is sent or stored. -

      -
      - - {/* ── RIGHT: results narrative ── */} -
      - {/* STEP 2 — operational burden */} -
      - -
      -
      - -
      -
      -
      - {out.tier} - Runtime Complexity -
      -

      {TIER_BLURB[out.tier]}

      - - {/* Burden Reduction keys off the score, not the tier, so it - belongs beside the gauge rather than in the Step 3 grid. */} -
      - - {Math.round(reduction)}% - - {/* Plain span, NOT inline-flex: the label and its InfoTip have to - flow as one run of text so the icon trails the last word. As a - flex container the raw text became its own anonymous item and - the icon was pushed out to the pill's right edge. */} - - Burden Reduction on trusted images{" "} - - -
      -
      -
      -
      - - {/* bridge line — connects burden to outcomes */} -

      - Higher burden means more vulnerability noise, longer patch cycles, and lost engineering time. Here’s what changes on minimal, trusted images: -

      - - {/* STEP 3 — expected improvements */} -
      - -
      - {METRICS.map((m) => { - const d = cardData[m.key]; - const pos = clamp01((d.raw - d.band[0]) / (d.band[1] - d.band[0])); - return ( -
      -
      - {m.icon} -
      {d.value}
      -
      {m.title}
      -
      {m.sub}
      -
      -
      -
      -
      -
      -
      - typical {d.band[0]}{d.suffix}{d.band[1]}{d.suffix} -
      -
      -
      - ); - })} -
      -
      - - {/* STEP 4 — recovered engineering capacity */} -
      - -
      -
      -
      -
      - - {stroke("M12 7v5l3 2M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z")} - Engineering Hours Recovered / year - -
      - {Math.round(hours).toLocaleString("en-US")} -
      -

      - Roughly {fte.toFixed(1)} full-time engineers of capacity, won back from vulnerability toil. -

      -
      - {/* supporting stats */} -
      - {[ - { v: out.hoursPerEngineer.toLocaleString("en-US"), l: "hrs / engineer" }, - { v: team.toString(), l: "engineers" }, - { v: fte.toFixed(1), l: "FTE equivalent" }, - ].map((s, i) => ( -
      - {s.v} - {s.l} -
      - ))} -
      -
      -
      -
      - - {/* trust line */} -

      - Estimated outcomes, modeled from industry benchmarks and organizations with similar runtime profiles — directional, not a guarantee. - -

      -
      -
      -
      -
      - ); -} diff --git a/apps/web/src/components/sections/saas/SaasCleanroomReactor.module.css b/apps/web/src/components/sections/saas/SaasCleanroomReactor.module.css deleted file mode 100644 index f8c77dd15..000000000 --- a/apps/web/src/components/sections/saas/SaasCleanroomReactor.module.css +++ /dev/null @@ -1,485 +0,0 @@ -.stage { - position: relative; - isolation: isolate; - width: 100%; - max-width: 1080px; - margin-inline: auto; -} - -.desktopReactor { - display: none; - width: 100%; - height: auto; - filter: drop-shadow(0 34px 72px rgba(0, 0, 0, 0.46)); -} - -/* Mobile is a stack of cards, not a scaled scene — see SaasCleanroomReactor. */ -.mobileFlow { - display: flex; - flex-direction: column; - gap: 16px; -} - -.mobileCard { - padding: 20px 18px; - border-radius: 20px; - border: 1px solid rgba(127, 227, 255, 0.24); - background: linear-gradient(180deg, rgba(16, 26, 53, 0.92) 0%, rgba(7, 13, 29, 0.92) 100%); -} - -.mobileCardRefused { - border-color: rgba(255, 135, 149, 0.3); - background: linear-gradient(180deg, rgba(38, 16, 30, 0.9) 0%, rgba(12, 10, 24, 0.92) 100%); -} - -.mobileHead { - display: flex; - align-items: center; - gap: 12px; -} - -.mobileHeadLabel { - font-family: var(--font-display); - font-size: var(--fs-h5); - font-weight: 600; - letter-spacing: -0.02em; - color: #ffffff; -} - -/* Short spine between head, stages and gate — the only connective tissue the - card needs, and it stays on the type grid instead of a scaled viewBox. */ -.mobileRail { - display: block; - width: 1px; - height: 14px; - margin: 10px 0 10px 19px; - background: rgba(127, 227, 255, 0.4); -} - -.mobileStages { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.mobileStage { - padding: 7px 12px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.14); - background: rgba(255, 255, 255, 0.05); - font-family: var(--font-display); - font-size: var(--fs-body-sm); - font-weight: 600; - letter-spacing: -0.01em; - color: rgba(255, 255, 255, 0.82); -} - -.mobileGate { - display: flex; - align-items: center; - gap: 9px; - padding: 11px 14px; - border-radius: 12px; - border: 1.5px solid #5fe3c0; - background: rgba(95, 227, 192, 0.13); - font-family: var(--font-display); - font-size: var(--fs-body-sm); - font-weight: 600; - letter-spacing: -0.01em; - color: #5fe3c0; -} - -.mobileGateRefused { - border-color: #ff6b6b; - background: rgba(255, 107, 107, 0.13); - color: #ff8795; -} - -.sourceLabel, -.rejectLabel, -.layerLabel, -.perimeterLabel { - font-family: var(--font-display); - font-weight: 600; -} - -.sourceLabel { - fill: rgba(240, 248, 255, 0.94); - font-size: 17px; - letter-spacing: -0.02em; -} - -.rejectLabel { - fill: rgba(255, 214, 219, 0.94); - font-size: 20px; - letter-spacing: -0.01em; -} - -.layerLabel { - fill: rgba(240, 248, 255, 0.9); - font-size: 17px; - letter-spacing: -0.02em; -} - -.perimeterLabel { - fill: rgba(240, 248, 255, 0.94); - font-size: 16px; - letter-spacing: -0.01em; -} - -.sourcePulse { - transform-box: fill-box; - transform-origin: center; - animation: sourcePulse 10.8s ease-in-out infinite; -} - -.intakeFlow { - animation: intakeFlow 10.8s linear infinite; -} - -.intakeParticle, -.intakeParticleSecondary { - transform-box: fill-box; - transform-origin: center; - animation: intakeParticle 10.8s cubic-bezier(0.55, 0, 0.3, 1) infinite; -} - -.intakeParticleSecondary { - animation-delay: 0.24s; -} - -.energyColumn { - animation: energyRise 10.8s linear infinite; -} - -.layerPlate { - transform-box: fill-box; - transform-origin: center; - opacity: 0.48; -} - -.layerCode { - animation: layerCode 10.8s ease-out infinite; -} - -.layerBuild { - animation: layerBuild 10.8s ease-out infinite; -} - -.layerTest { - animation: layerTest 10.8s ease-out infinite; -} - -.layerDeploy { - animation: layerDeploy 10.8s ease-out infinite; -} - -.securityPerimeter { - opacity: 0.62; - animation: perimeterResolve 10.8s ease-in-out infinite; -} - -.perimeterTrace { - animation: perimeterTrace 10.8s linear infinite; -} - -.scanBeam { - transform-box: fill-box; - transform-origin: center; - opacity: 0; - animation: scanBeam 10.8s cubic-bezier(0.7, 0, 0.3, 1) infinite; -} - -.lateArtifact { - transform-box: fill-box; - transform-origin: center; - animation: lateImpact 10.8s cubic-bezier(0.7, 0, 0.3, 1) infinite; -} - -.fractureShard { - transform-box: fill-box; - transform-origin: left center; - animation: fractureReject 10.8s ease-out infinite; -} - -@keyframes sourcePulse { - 0% { - opacity: 0.7; - transform: scale(0.97); - } - 10% { - opacity: 1; - transform: scale(1.045); - } - 24%, - 88% { - opacity: 0.94; - transform: scale(1); - } - 100% { - opacity: 0.7; - transform: scale(0.97); - } -} - -@keyframes intakeFlow { - 0% { - opacity: 0.24; - stroke-dashoffset: 0; - } - 9%, - 30% { - opacity: 1; - } - 100% { - opacity: 0.24; - stroke-dashoffset: -96; - } -} - -@keyframes intakeParticle { - 0% { - opacity: 0; - transform: translateX(-26px) scale(0.5); - } - 7% { - opacity: 1; - transform: translateX(-10px) scale(1); - } - 19% { - opacity: 1; - transform: translateX(44px) scale(1); - } - 25%, - 99% { - opacity: 0; - transform: translateX(62px) scale(0.5); - } - 100% { - opacity: 0; - transform: translateX(-26px) scale(0.5); - } -} - -@keyframes energyRise { - 0% { - opacity: 0.18; - stroke-dashoffset: 0; - } - 22%, - 86% { - opacity: 1; - } - 100% { - opacity: 0.18; - stroke-dashoffset: -152; - } -} - -@keyframes layerCode { - 0%, - 19% { - opacity: 0.34; - filter: none; - } - 24%, - 86% { - opacity: 1; - filter: drop-shadow(0 0 12px rgba(154, 81, 255, 0.3)); - } - 92%, - 100% { - opacity: 0.34; - filter: none; - } -} - -@keyframes layerBuild { - 0%, - 27% { - opacity: 0.34; - filter: none; - } - 32%, - 89% { - opacity: 1; - filter: drop-shadow(0 0 12px rgba(154, 81, 255, 0.3)); - } - 95%, - 100% { - opacity: 0.34; - filter: none; - } -} - -@keyframes layerTest { - 0%, - 35% { - opacity: 0.34; - filter: none; - } - 40%, - 92% { - opacity: 1; - filter: drop-shadow(0 0 12px rgba(154, 81, 255, 0.3)); - } - 98%, - 100% { - opacity: 0.34; - filter: none; - } -} - -@keyframes layerDeploy { - 0%, - 43% { - opacity: 0.34; - filter: none; - } - 48%, - 94% { - opacity: 1; - filter: drop-shadow(0 0 12px rgba(154, 81, 255, 0.3)); - } - 100%, - 100% { - opacity: 0.34; - filter: none; - } -} - -@keyframes perimeterResolve { - 0%, - 50% { - opacity: 0.44; - } - 60%, - 88% { - opacity: 1; - } - 100% { - opacity: 0.44; - } -} - -@keyframes perimeterTrace { - from { - stroke-dashoffset: 140; - } - to { - stroke-dashoffset: -172; - } -} - -@keyframes scanBeam { - 0%, - 52% { - opacity: 0; - transform: translateY(-104px) scaleY(0.7); - } - 57% { - opacity: 0.85; - transform: translateY(-76px) scaleY(1); - } - 68% { - opacity: 0.85; - transform: translateY(318px) scaleY(1); - } - 72%, - 99% { - opacity: 0; - transform: translateY(348px) scaleY(0.7); - } - 100% { - opacity: 0; - transform: translateY(-104px) scaleY(0.7); - } -} - -@keyframes lateImpact { - 0%, - 68% { - transform: translateX(24px); - } - 76% { - transform: translateX(-2px); - } - 81% { - transform: translateX(32px); - } - 86% { - transform: translateX(18px); - } - 92%, - 100% { - transform: translateX(24px); - } -} - -@keyframes fractureReject { - 0%, - 72% { - opacity: 0; - transform: translateX(6px); - } - 78% { - opacity: 1; - transform: translateX(-4px); - } - 88% { - opacity: 0.45; - transform: translateX(0); - } - 94%, - 100% { - opacity: 0; - transform: translateX(6px); - } -} - -@media (min-width: 1024px) { - .desktopReactor { - display: block; - } - - .mobileFlow { - display: none; - } -} - -@media (prefers-reduced-motion: reduce) { - .sourcePulse, - .intakeFlow, - .intakeParticle, - .intakeParticleSecondary, - .energyColumn, - .layerPlate, - .securityPerimeter, - .perimeterTrace, - .scanBeam, - .lateArtifact, - .fractureShard { - animation: none !important; - } - - .sourcePulse, - .layerPlate, - .securityPerimeter { - opacity: 1 !important; - transform: none !important; - } - - .scanBeam { - opacity: 0 !important; - } - - .lateArtifact { - opacity: 1 !important; - transform: none !important; - } - - .fractureShard { - opacity: 0.65 !important; - transform: none !important; - } -} diff --git a/apps/web/src/components/sections/saas/SaasCleanroomReactor.tsx b/apps/web/src/components/sections/saas/SaasCleanroomReactor.tsx deleted file mode 100644 index d5a8dc581..000000000 --- a/apps/web/src/components/sections/saas/SaasCleanroomReactor.tsx +++ /dev/null @@ -1,580 +0,0 @@ -import type React from 'react'; -import styles from './SaasCleanroomReactor.module.css'; - -type ReactorLayerId = 'code' | 'build' | 'test' | 'deploy'; - -interface ReactorLayer { - readonly id: ReactorLayerId; - readonly label: 'Code' | 'Build' | 'Test' | 'Deploy'; -} - -interface LayerProps { - readonly layer: ReactorLayer; - readonly index: number; -} - -const REACTOR_LAYERS: readonly ReactorLayer[] = [ - { id: 'code', label: 'Code' }, - { id: 'build', label: 'Build' }, - { id: 'test', label: 'Test' }, - { id: 'deploy', label: 'Deploy' }, -]; - -export function SaasCleanroomReactor(): React.ReactElement { - return ( -
      - - -
      - ); -} - -function DesktopReactor(): React.ReactElement { - return ( - - ); -} - -function DesktopDefinitions(): React.ReactElement { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -function FloorGrid(): React.ReactElement { - return ( - - - - - - - ); -} - -function VerifiedSource(): React.ReactElement { - return ( - - {/* Halo, as a radial falloff rather than flat-filled ellipses. Two solid - ellipses plus a blur-merge filter on the opaque core below rendered as - a hard-edged disc that swallowed both the hexagon and its label. */} - - - - - - - Verified Components - - - ); -} - -function IntakeConduit(): React.ReactElement { - return ( - - - - - - - ); -} - -function ReactorChamber(): React.ReactElement { - return ( - - - - - - - - - - - - - {REACTOR_LAYERS.map((layer, index) => ( - - ))} - - - - - - - - ); -} - -function DesktopLayer({ layer, index }: LayerProps): React.ReactElement { - const y = 473 - index * 82; - const animationClass = getLayerAnimationClass(layer.id); - - return ( - - - - - - - {layer.label} - - - ); -} - -function getLayerAnimationClass(layer: ReactorLayerId): string { - switch (layer) { - case 'code': - return styles.layerCode ?? ''; - case 'build': - return styles.layerBuild ?? ''; - case 'test': - return styles.layerTest ?? ''; - case 'deploy': - return styles.layerDeploy ?? ''; - } -} - -function SecurityPerimeter(): React.ReactElement { - return ( - <> - - - - - - - - {/* Chip deliberately OUTSIDE the perimeter group. That group animates its - opacity (0.44 -> 1), and a label nested inside inherits it — going - translucent for most of the cycle and letting the chamber border show - straight through the text. A label is not part of the animated - boundary, so it keeps its own full opacity. */} - - - - - Security Review - - - - ); -} - -/* - * The refused artifact — the other half of the comparison, and the piece that - * was hardest to read. - * - * It used to be an irregular blob carrying a jagged crack glyph, which scanned - * as a broken clock face rather than as software, and it faded in from off-stage - * and back out again every cycle so it looked like a stray fragment drifting - * past. Neither the object nor its motion said anything. - * - * It is now the mirror of the verified source: the same regular hexagon, at the - * same size, on the same centre line, so the two read as one object type with - * two outcomes. One is admitted with a check; this one is refused with a cross. - * That symmetry is what makes the comparison legible without a caption. - * - * It never leaves. It presses toward the perimeter, is repelled, and settles — - * a short shove and recoil rather than an entrance and an exit. - */ -function RejectedLateArtifact(): React.ReactElement { - return ( - - - - - - - - {/* Cross, at the same weight as the source's check. */} - - - - {/* Permanent approach line to the perimeter, and the stop it runs into. - The mirror of the verified source's intake conduit: that one enters, - this one is turned back at the boundary. Drawn at all times so the - relationship survives the quiet part of the cycle. */} - - - - {/* Repelled: chevrons on the perimeter-facing side, flashing on impact. */} - - - - - - - Unverified Components - - - - ); -} - -/* - * Mobile is not the desktop scene made small. - * - * The first attempt shrank the reactor into a 360x880 viewBox. On a 375px - * screen that renders 799px tall — taller than the viewport — so a - * left-to-centre-to-right choreography played while the reader could only see a - * third of it, thirteen infinite animations ran on the weakest devices, and - * every label was SVG text scaled by 0.908 and therefore off the --fs-* scale. - * - * A phone is a column you travel down, not a stage you take in at a glance. So - * the argument is delivered as content instead of as a scene: two cards with - * IDENTICAL middles and different ends, which is precisely what the proposal's - * two chains are. Same four stages both times; the only difference is what sits - * at the head, and therefore what happens at the gate. The comparison lands in - * one glance rather than over eleven seconds. - * - * Deliberately static. There is nothing here whose meaning needs motion, and - * dropping it takes thirteen running animations off phones. - */ - -function MobileBadge({ refused }: { refused: boolean }): React.ReactElement { - return ( - - ); -} - -function MobileRun({ refused }: { refused: boolean }): React.ReactElement { - return ( -
      -
      - - - {refused ? 'Unverified Components' : 'Verified Components'} - -
      - -
      - ); -} - -function MobileReactor(): React.ReactElement { - return ( - - ); -} diff --git a/apps/web/src/components/sections/saas/SaasFoundation.tsx b/apps/web/src/components/sections/saas/SaasFoundation.tsx index 9730bdf87..0f9d499a6 100644 --- a/apps/web/src/components/sections/saas/SaasFoundation.tsx +++ b/apps/web/src/components/sections/saas/SaasFoundation.tsx @@ -91,7 +91,7 @@ const STEPS: readonly [Step, Step, Step, Step] = [ { icon: '/images/ciso/enterprise-icon-compliance.svg', text: 'Validate. Govern.' }, { icon: '/images/ciso/enterprise-icon-security-ops.svg', - text: 'Establish trust. Reduce risk. Deliver with confidence.', + text: 'Deliver with confidence.', }, ]; diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index 64e8687dc..3f59536ff 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -1,32 +1,19 @@ import type React from 'react'; -import Image from 'next/image'; import Link from 'next/link'; +import { SaasHeroAppSurface } from './SaasHeroAppSurface'; +import { SaasHeroParallax } from './SaasHeroParallax'; import { HeroReveal } from '@/components/ui/Reveal'; /* * SaaS hero — the site's standard solution-page hero shell (FipsHero / - * CisoHero): bg-cs-hero mesh, a gridline overlay, left-aligned copy, a 3D + * CisoHero): bg-cs-hero mesh, a gridline overlay, left-aligned copy, an * artifact on the right, and a bottom fade into the white section below. Copy * is the proposal's, verbatim. * - * The artifact is commissioned for this page, not borrowed: an application panel - * with a steadily rising chart, seated on a layered platform. - * - * It faces LOWER-LEFT on purpose. The render is pinned to the right of the - * viewport with the headline and CTA on the left, so a subject facing right - * would point the reader off the edge of the page; facing left, it turns back - * into the copy. - * - * Rendered deliberately WITHOUT motion streaks. Three attempts at generating - * them produced trails that fired out of the panel edge like beams or ran the - * wrong way relative to the implied travel, because the generator has no model - * of which way the object is going. If motion is wanted, it belongs in CSS - * behind this image, where direction and colour are a one-line change. - * - * Rendered on white and matted afterwards — the generator ignores requests for - * a transparent background, and a threshold knockout leaves white fringing on - * the soft shadow. Corners confirmed 0,0,0,0, so it composites straight onto - * the gradient with no blend mode. + * The artifact is the one departure from that shell. Every other hero on the + * site carries a 3D render; this one is drawn in code + * (SaasHeroAppSurface.tsx), because the render it replaced was rejected and + * regenerating it kept landing on stock illustration. */ export function SaasHero(): React.ReactElement { return ( @@ -46,22 +33,31 @@ export function SaasHero(): React.ReactElement { decoding="async" /> - {/* Deliberately NOT `priority`. Next emits a `` - for a priority image with no `media` attribute, but this wrapper is - `hidden xl:block`, so every phone and tablet was preloading a hero it - never paints. It is also decorative (aria-hidden) and is not the LCP - element: the H1 below carries the `lcp` prop for that. Without - `priority` it still loads promptly at xl, because a lazy image already - inside the viewport is fetched immediately. */} - {/* Hero artifact, pinned right and only at xl+. Below 1280px there is no - width that holds both the render and the headline without one of them - being squeezed, and shrinking the render past its floor turns the - panel's UI detail into noise. Same "hide it when there is no room" - call the sibling hero makes. */} + {/* Hero artifact, pinned right, visible from md up. It is hidden on phones + only: there is genuinely no room beside the headline at 375px, and the + artifact is decorative, so it drops rather than stacking. + + It used to be xl+ (1280px), which left anyone on a 1024-1279px window + looking at an empty right half. Making it work at those widths means + scaling the artifact AND capping the copy column, since both are + competing for the same row. + + Built, not rendered: this was hero-app-platform.webp until the client + rejected it. See SaasHeroAppSurface.tsx for the reasoning. There is no + `priority` preload left to get wrong — the old render was preloading on + phones that never painted it. */}
      - A SaaS application dashboard showing a steadily rising chart, seated on a layered software platform with a further module sliding into its base + + +
      @@ -116,10 +112,16 @@ export function SaasHero(): React.ReactElement { paddingBottom: 'clamp(56px, 6vw, 96px)', }} > - {/* Below xl the artifact does not render, so the column runs to its own - measure. At xl+ the budget is fitted to the render's measured left - edge at each width, with a margin. */} -
      + {/* The copy column has to yield room to the artifact from md up, or the + two overlap. The calc subtracts the artifact's own width and a 56px + gap from the padded container, so the budget tracks the artifact + automatically instead of being re-guessed per breakpoint. + + 100% of the container, NOT 100vw: vw includes the scrollbar, which + handed the copy 15px it did not have and left a 24px gap to the + artifact at 1030px. The xl rule stays as the tighter cap above + 1280. */} +

      - {/* The SEO team's H1, verbatim. The gradient splits the phrase - rather than adding words, so the rendered text is exactly - "Container Security for SaaS Companies". */} - Container Security for{' '} - SaaS Companies + {/* Client's headline, replacing the SEO team's "Container Security + for SaaS Companies". The title tag still carries that phrase, so + the page keeps the keyword in the SERP; the H1 no longer + contains it. Same trade the financial services page made, and + the same parallel construction. + + Each sentence gets its own block, because left to wrap + naturally the break landed mid-clause — "Move Faster. Security" + on one line — which destroys the parallel the headline is built + on. Same treatment FinanceStack's heading uses; not a
      . */} + Applications Move Faster. + Security Must Be Smarter.

      diff --git a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx new file mode 100644 index 000000000..4cf0594b9 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx @@ -0,0 +1,867 @@ +import type React from 'react'; + +/* + * SaaS hero artifact — the application, and everything it is assembled from. + * + * Client direction with a reference: no container, no base object. A central + * application surface carrying the complexity of a real product, with the pieces + * it is built from floating around it. + * + * THE SURFACE. Earlier passes drew it as grey bars on white, which read as a + * wireframe: stat tiles holding no value, identical list rows, a rail of + * featureless blobs, no header and no primary action. Hierarchy and colour are + * what make a UI look real, so it now carries a header with a filled action and + * a tab strip, drawn rail icons with one active, three stat tiles with values, a + * list whose rows differ from one another with one selected, and a side panel so + * the body is not one flat column. + * + * THE PIECES. Overlapping the surface and each other, in front and behind; + * translucent, so the surface reads through them; each on its own tint rather + * than one fill repeated, which was flattening them into a single material. + * Variety of object, not just of position: code badge, cog panel, package, + * avatar row, manifest, image grid, selection frame, terminal, dependency graph. + * + * Depth is built in three ways: the back row is blurred slightly so it sits + * behind rather than merely under, dashed tethers tie every piece back to the + * surface so they are not confetti, and a particle field fills the corners the + * cards do not reach. + * + * Built in code, not as an image: rounded rectangles, flat fills, small glyphs + * and rotations are native to SVG. Code holds exact brand colour, stays crisp at + * every DPR and costs a few KB. It also sidesteps the reference asset itself, + * which is a free stock vector — attribution-bound and already on thousands of + * sites. + * + * The security story lives in WHAT floats, and the ticks follow a rule rather + * than being sprinkled for balance: they mark the SUPPLY-CHAIN pieces only — + * source, container images, packages and the signed manifest. The users panel, + * the build config and the terminal stay unmarked, because CleanStart verifies + * components, not your tenants or your CLI, and a tick on those would be a claim + * the product does not make. + * + * That is also why the ticks are not on everything. The page's own sections + * frame this complexity as RISK — AI-generated code, open source dependencies, + * public container images, component visibility — so the artifact has to say two + * things at once: look how much is packed in here, AND the parts that came from + * outside are accounted for. + * + * No numerals or words anywhere. The artifact scales from 599px down to 316px, + * where real type would be illegible, so hierarchy is carried by weight, size + * and colour instead. + */ + +const VIEW_W = 580; +const VIEW_H = 460; + +const APP_X = 140; +const APP_Y = 78; +const APP_W = 312; +const APP_H = 302; + +const RAIL_W = 44; +const PAD = 14; +const BODY_X = APP_X + RAIL_W + PAD; +const BODY_W = APP_W - RAIL_W - PAD * 2; +const LIST_W = 152; +const SIDE_X = BODY_X + LIST_W + 12; +const SIDE_W = BODY_W - LIST_W - 12; + +const BLUE = '#005be3'; +const VIOLET = '#7c34e8'; +const TEAL = '#0f9fd0'; +const CYAN = '#4fe3ff'; + +const INK_STRONG = 'rgba(16,19,34,0.44)'; +const INK_SOFT = 'rgba(16,19,34,0.17)'; +const HAIR = 'rgba(16,19,34,0.08)'; + +const ROWS = [ + { chip: BLUE, name: 78, sub: 50, selected: true }, + { chip: VIOLET, name: 62, sub: 42, selected: false }, + { chip: TEAL, name: 86, sub: 36, selected: false }, +] as const; + +const STATS = [ + { accent: BLUE, value: 24 }, + { accent: VIOLET, value: 17 }, + { accent: TEAL, value: 28 }, +] as const; + +/** Small drifting marks in the corners the cards never reach. Fixed positions, + not random: the artifact renders on the server and must match on the client. */ +const PARTICLES = [ + { x: 26, y: 32, r: 2.4, o: 0.3 }, + { x: 62, y: 14, r: 1.6, o: 0.2 }, + { x: 546, y: 60, r: 2.2, o: 0.26 }, + { x: 566, y: 132, r: 1.5, o: 0.18 }, + { x: 18, y: 300, r: 1.8, o: 0.22 }, + { x: 38, y: 430, r: 2.3, o: 0.26 }, + { x: 300, y: 440, r: 1.6, o: 0.18 }, + { x: 556, y: 400, r: 2, o: 0.22 }, + { x: 522, y: 246, r: 1.5, o: 0.16 }, +] as const; + +function Tick({ x, y, r = 9 }: { x: number; y: number; r?: number }): React.ReactElement { + return ( + + + + + ); +} + +function Lines({ + x, + y, + widths, + gap = 8, + opacity = 0.3, +}: { + x: number; + y: number; + widths: readonly number[]; + gap?: number; + opacity?: number; +}): React.ReactElement { + return ( + + {widths.map((w, i) => ( + + ))} + + ); +} + +function Cog({ x, y, r, fill }: { x: number; y: number; r: number; fill: string }) { + return ( + + {Array.from({ length: 8 }, (_, i) => ( + + ))} + + + + ); +} + +/** Rail glyphs, actually drawn. Grey blobs made the rail read as a placeholder + rather than as navigation. */ +function RailIcon({ i, active }: { i: number; active: boolean }): React.ReactElement { + const c = active ? '#ffffff' : 'rgba(16,19,34,0.34)'; + if (i === 0) { + return ( + + {[0, 1, 2, 3].map((k) => ( + + ))} + + ); + } + if (i === 1) { + return ( + + {[0, 1, 2].map((k) => ( + + ))} + + ); + } + if (i === 2) { + return ( + + ); + } + if (i === 3) { + return ( + + ); + } + return ( + + + + + ); +} + +function Shell({ + w, + h, + fill, + accent, + radius = 10, +}: { + w: number; + h: number; + fill: string; + accent: string; + radius?: number; +}): React.ReactElement { + return ; +} + +function Handles({ w, h }: { w: number; h: number }): React.ReactElement { + return ( + + + {( + [ + [0, 0], + [w, 0], + [0, h], + [w, h], + ] as const + ).map(([px, py]) => ( + + ))} + + ); +} + +/** Dependency graph: the one piece that is a relationship rather than an object, + which is what a supply chain actually is. */ +function Graph(): React.ReactElement { + const nodes = [ + { x: 12, y: 30, r: 7, fill: CYAN }, + { x: 48, y: 12, r: 5, fill: 'rgba(255,255,255,0.6)' }, + { x: 52, y: 48, r: 6, fill: '#b47cff' }, + { x: 86, y: 28, r: 4.5, fill: 'rgba(255,255,255,0.5)' }, + { x: 84, y: 60, r: 5.5, fill: CYAN }, + ] as const; + const edges = [ + [0, 1], + [0, 2], + [1, 3], + [2, 3], + [2, 4], + ] as const; + return ( + + {edges.map(([a, b]) => { + const na = nodes[a]; + const nb = nodes[b]; + if (!na || !nb) return null; + return ( + + ); + })} + {nodes.map((n) => ( + + ))} + + ); +} + +/* + * A source tree. Indentation is the point: it is the one card whose structure + * says "this is nested", which no flat list of bars can express. + */ +function FileTree(): React.ReactElement { + const rows = [ + { indent: 0, w: 54, dot: CYAN, caret: true }, + { indent: 11, w: 42, dot: 'rgba(255,255,255,0.42)', caret: false }, + { indent: 11, w: 60, dot: '#b47cff', caret: true }, + { indent: 22, w: 38, dot: 'rgba(255,255,255,0.42)', caret: false }, + { indent: 22, w: 50, dot: CYAN, caret: false }, + ] as const; + return ( + + {rows.map((r, i) => { + const y = 14 + i * 13; + return ( + + {r.caret && ( + + )} + + + + ); + })} + + ); +} + +/* + * Image layers with their weights. The one card that shows a container image's + * internals rather than referring to it — narrow bars for thin layers, wide for + * fat ones, each with its size read off to the right. + */ +function LayerStack(): React.ReactElement { + const layers = [ + { w: 76, size: 16, fill: '#7de9ff' }, + { w: 54, size: 11, fill: 'rgba(255,255,255,0.34)' }, + { w: 88, size: 21, fill: '#b47cff' }, + { w: 44, size: 8, fill: 'rgba(255,255,255,0.28)' }, + { w: 66, size: 14, fill: 'rgba(122,197,255,0.85)' }, + ] as const; + return ( + + {layers.map((l, i) => { + const y = 14 + i * 12; + return ( + + + + + ); + })} + + ); +} + +export function SaasHeroAppSurface(): React.ReactElement { + const cx = APP_X + APP_W / 2; + const cy = APP_Y + APP_H / 2; + + return ( + // The viewBox is deliberately larger than the artwork: 26px of slack left + // and right, 22px top and bottom. Parallax translates the front plane by up + // to 18px, and with the cards' rotation overhang on top of that the terminal + // reached x 592 in a 580-wide frame, where the SVG viewport clipped it. The + // artwork's own coordinates are unchanged; only the window onto it grew. + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Bright at the BASE, fading to the tip. Inverted stops lit the far ends + and turned the fan into a dark crown ringing the surface. */} + + + + + + + + + + {/* Depth-of-field on the back row only. One shared filter, small stdDev: + enough to push those cards behind the surface, cheap enough not to + matter. */} + + + + + + + + + {[-62, -40, -20, 0, 20, 40, 62].map((a) => ( + + ))} + {PARTICLES.map((p) => ( + + ))} + + + {/* Tethers. Without them the pieces read as confetti; with them every one + belongs to the application in the middle. */} + + {( + [ + [96, 120], + [462, 108], + [478, 322], + [126, 262], + [356, 372], + [318, 82], + [512, 192], + [88, 40], + ] as const + ).map(([px, py]) => ( + + ))} + + + {/* BEHIND the surface, blurred back. */} + + + + + + {[0, 1, 2].map((i) => ( + + ))} + + + + + + + + + + + + + + + + + + + {/* Nudged down off the frame edge so it overlaps the surface top rather + than hugging y=2, and moved off csa-deep, which was invisible against + the background it sat on. */} + + + + + + + {/* THE APPLICATION SURFACE */} + + + + {[0, 1, 2].map((i) => ( + + ))} + + + + + + + {[0, 1, 2, 3, 4].map((i) => ( + + + + + ))} + + {/* Tab strip: another layer of real product structure. */} + {[36, 30, 26].map((w, i) => ( + + + {i === 0 && ( + + )} + + ))} + + + {STATS.map((s, i) => ( + + + + + + + ))} + + + + {ROWS.map((row, i) => { + const y = APP_Y + 148 + i * 36; + return ( + + {row.selected && ( + <> + + + + )} + + + + + + ); + })} + + {/* Side panel, so the body is not one flat column. */} + + + {[0, 1, 2, 3].map((i) => ( + + + + + ))} + + + + + {/* IN FRONT, overlapping the surface. */} + + {/* Package, moved out of the blurred back row. It sits in FRONT of the + surface now, so it is sharp and overlaps the window's right edge + rather than washing out behind it. Placed first in this group so the + manifest below still crosses over it and the depth stack keeps more + than two planes. */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} + + + + {/* Terminal: the one piece that says a human builds this. */} + + + + {[0, 1, 2].map((i) => ( + + ))} + + + + + + + + + + + + {/* Lifted: at translate(452 376) this ran to y 454 in a 460 frame and the + rotation pushed a corner past it. */} + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/sections/saas/SaasHeroParallax.tsx b/apps/web/src/components/sections/saas/SaasHeroParallax.tsx new file mode 100644 index 000000000..4c7902529 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasHeroParallax.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { type ReactNode, useEffect, useRef } from 'react'; + +/* + * Cursor parallax for the SaaS hero artifact. + * + * The artifact already has three depth planes — a blurred back row, the + * application surface, and the front cards. This ties them to the pointer at + * different rates so that depth is real rather than implied. + * + * It publishes the pointer position as two CSS custom properties and stops + * there. The layers read them in globals.css. Nothing is held in React state on + * purpose: state would re-render the entire SVG on every mousemove, where custom + * properties let the compositor do the work. + * + * Reads are rAF-throttled, so a burst of pointermove events collapses to one + * write per frame. + * + * Gated three ways, and it attaches no listener at all when any gate fails: + * - `(hover: hover) and (pointer: fine)`, so touch devices do not carry a dead + * listener for an effect they can never trigger. + * - `prefers-reduced-motion`, where the whole thing is off. + * - The listener sits on the hero SECTION rather than the window, so it only + * runs while the cursor is over the hero at all. + * + * Within that, the effect is keyed to the ARTIFACT, not the section: position is + * normalised against the artifact's own box, and the layers return to rest the + * moment the cursor leaves it. Tracking the whole section meant the illustration + * drifted while the cursor was over the headline on the other side of the hero, + * which reads as the page moving on its own rather than as a response to + * pointing at the thing. + * + * The idle drift in globals.css is unaffected and keeps running underneath, so + * the artifact still moves when the cursor is elsewhere on the page. + */ +export function SaasHeroParallax({ children }: { children: ReactNode }): React.ReactElement { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const fine = window.matchMedia('(hover: hover) and (pointer: fine)'); + const still = window.matchMedia('(prefers-reduced-motion: reduce)'); + if (!fine.matches || still.matches) return; + + const section = el.closest('section'); + if (!section) return; + + let frame = 0; + let px = 0; + let py = 0; + + const write = (): void => { + frame = 0; + el.style.setProperty('--cs-px', px.toFixed(3)); + el.style.setProperty('--cs-py', py.toFixed(3)); + }; + + const schedule = (): void => { + if (frame === 0) frame = requestAnimationFrame(write); + }; + + const onMove = (event: PointerEvent): void => { + // The artifact's own box, not the section's. The artifact is + // pointer-events-none, so hover cannot be detected by events; it is + // computed from geometry instead. + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + const inside = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (!inside) { + if (px === 0 && py === 0) return; + px = 0; + py = 0; + schedule(); + return; + } + + // Normalised to -1..1 from the artifact's centre. + px = ((event.clientX - rect.left) / rect.width) * 2 - 1; + py = ((event.clientY - rect.top) / rect.height) * 2 - 1; + schedule(); + }; + + const onLeave = (): void => { + px = 0; + py = 0; + schedule(); + }; + + section.addEventListener('pointermove', onMove, { passive: true }); + section.addEventListener('pointerleave', onLeave, { passive: true }); + + return () => { + section.removeEventListener('pointermove', onMove); + section.removeEventListener('pointerleave', onLeave); + if (frame !== 0) cancelAnimationFrame(frame); + }; + }, []); + + return ( +
      + {children} +
      + ); +} diff --git a/apps/web/src/components/sections/saas/SaasHeroStack.tsx b/apps/web/src/components/sections/saas/SaasHeroStack.tsx new file mode 100644 index 000000000..36f2842f0 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasHeroStack.tsx @@ -0,0 +1,238 @@ +import Image from 'next/image'; +import type React from 'react'; + +/* + * SaaS hero artifact — the customer's own runtime stack, hardened. + * + * Replaces hero-app-platform.webp (a 3D dashboard render the client rejected) + * and a hexagon lattice that preceded it. Both failed the same way: an + * illustration OF security is inherently generic, so it reads as stock art + * whatever the craft level. + * + * The category has already settled this. Chainguard's homepage hero is a row of + * real image cards (Python, Node.js) carrying FIPS VALIDATED / STIG HARDENED + * badges and live CVE-reduction counts; Docker Hardened Images does the same + * thing with OS / Architecture / Compliance rows. Neither market leader uses an + * illustration. Product truth is the convention here, because it is the one + * thing a competitor cannot copy and a reader cannot dismiss. + * + * SaaS-specific through WHICH images: nginx at the edge, redis for cache, + * postgres for data, node for the runtime is the canonical SaaS service stack. + * The finance page could not run this artifact. + * + * Everything asserted here is sourced from images.cleanstart.com: all four + * images exist in the 947-image catalogue (verified against a control — a real + * detail page returns ~210KB, a non-existent one ~81KB), and "Security + * Hardened", "FIPS Available", SBOM, Signature and Provenance are the badges + * and per-image tabs that catalogue actually publishes. No per-image CVE counts + * appear below, deliberately: the catalogue does not publish them per image, so + * any number here would be invented. + * + * Deliberately NOT the /for-developers treatment. That hero is a scrolling + * marquee arguing breadth ("we cover your whole stack"); this one is a still, + * detailed column arguing assurance ("and every one carries its paperwork"). + */ + +interface StackImage { + readonly name: string; + readonly role: string; + readonly logoUrl: string; + /** nginx's devicon has heavy internal whitespace and sits small in the plate. */ + readonly logoScale?: number; +} + +function deviconLogo(folder: string, variant: string): string { + return `https://cdn.jsdelivr.net/gh/devicons/devicon/icons/${folder}/${folder}-${variant}.svg`; +} + +/* + * Edge / cache / data / runtime, top to bottom: a SaaS service stack, not a + * list of popular images. Four rather than three because three cards stood only + * 185px tall against a 627px hero and left the right half of the composition + * empty. + */ +const BEHIND: readonly [StackImage, StackImage, StackImage] = [ + { + name: 'nginx', + role: 'Edge', + logoUrl: deviconLogo('nginx', 'original'), + // The devicon wordmark carries heavy internal whitespace. 1.5 (the value + // /for-developers uses on a wide plate) overflows this square one and crops + // the mark; 1.2 fills it without clipping. + logoScale: 1.2, + }, + { + name: 'redis', + role: 'Cache', + logoUrl: deviconLogo('redis', 'original'), + }, + { + name: 'postgres', + role: 'Data', + logoUrl: deviconLogo('postgresql', 'original'), + }, +]; + +const FRONT: StackImage = { + name: 'node', + role: 'Runtime', + logoUrl: deviconLogo('nodejs', 'original'), +}; + +const CARD_SURFACE = + 'linear-gradient(158deg, rgba(255,255,255,0.115) 0%, rgba(255,255,255,0.038) 100%)'; + +function LogoPlate({ image, size }: { image: StackImage; size: number }): React.ReactElement { + return ( +
      + +
      + ); +} + +function Chip({ label, tone }: { label: string; tone: 'seal' | 'quiet' }): React.ReactElement { + return ( + + {label} + + ); +} + +export function SaasHeroStack(): React.ReactElement { + return ( +
      + {BEHIND.map((image, i) => ( +
      +
      + + + {image.name} + + + {image.role} + +
      +
      + ))} + +
      +
      + + + {FRONT.name} + + + {FRONT.role} + +
      + +
      + + +
      + +
      + +
      + + + +
      +
      +
      + ); +} diff --git a/apps/web/src/components/sections/saas/SaasOutcomes.tsx b/apps/web/src/components/sections/saas/SaasOutcomes.tsx index 14d85eea9..87232b2bf 100644 --- a/apps/web/src/components/sections/saas/SaasOutcomes.tsx +++ b/apps/web/src/components/sections/saas/SaasOutcomes.tsx @@ -185,33 +185,19 @@ export function SaasOutcomes(): React.ReactElement { - {/* Desktop (lg+) — one row of four, held apart by light. Same layout - at every desktop width; the type scales, the arrangement does not. */} - + {/* One tree for every width, so each H3 exists once in the DOM. Stacked, + then two-up, then at lg a single row of four held apart by light: + the separators are display:none below lg, so they leave the grid. */} + {OUTCOMES.map((outcome, i) => ( - + {i < OUTCOMES.length - 1 ? : null} ))} - - {/* Tablet and below — two-up, then stacked. */} - - {OUTCOMES.map((outcome) => ( - - - - ))} -
      ); diff --git a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx index cd1317fd2..6494731a4 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx @@ -1,4 +1,3 @@ -import { existsSync, readFileSync } from 'node:fs'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import { SaasShiftLeft } from './SaasShiftLeft'; @@ -17,81 +16,49 @@ function renderSection(): string { describe('SaasShiftLeft', () => { it('keeps the supplied heading and supporting paragraph exact', () => { const html = renderSection(); - const headingMarkup = html.match(/]*>([\s\S]*?)<\/h2>/)?.[1] ?? ''; - const paragraphMarkup = html.match(/]*>([\s\S]*?)<\/p>/)?.[1] ?? ''; + const heading = html.match(/]*>([\s\S]*?)<\/h2>/)?.[1] ?? ''; + const paragraph = html.match(/]*>([\s\S]*?)<\/p>/)?.[1] ?? ''; - expect(toText(headingMarkup)).toBe('Move Beyond Shift Left'); - expect(toText(paragraphMarkup)).toBe( + expect(toText(heading)).toBe('Move Beyond Shift Left'); + expect(toText(paragraph)).toBe( 'Modern applications require security to be built into the software components developers use, not added after applications are created.', ); }); - it('replaces the two-lane release gate with desktop and mobile cleanroom reactors', () => { + it('exposes the pipeline to assistive tech as an ordered list, source first', () => { const html = renderSection(); - - expect(html).toContain('data-cleanroom-reactor="desktop"'); - expect(html).toContain('data-cleanroom-reactor="mobile"'); - expect(html).not.toContain('data-release-gate='); - expect(html).not.toContain('data-release-path='); + const list = html.match(/]*class="sr-only"[^>]*>([\s\S]*?)<\/ol>/)?.[1] ?? ''; + const items = [...list.matchAll(/]*>([\s\S]*?)<\/li>/g)].map((m) => toText(m[1] ?? '')); + + expect(items).toEqual([ + 'Verified Components', + 'Code', + 'Build', + 'Test', + 'Deploy', + 'Security Review', + 'Trusted Release', + ]); }); - it('places every application stage inside one reactor chamber', () => { + it('renders one decorative scene per orientation, in pipeline order', () => { const html = renderSection(); + const openTags = [...html.matchAll(/]*data-scene="(horizontal|vertical)"[^>]*>/g)]; + expect(openTags.map((m) => m[1])).toEqual(['horizontal', 'vertical']); + for (const tag of openTags) { + expect(tag[0]).toContain('aria-hidden="true"'); + } - expect(html).toContain('data-reactor-chamber="application"'); - expect(html).toMatch( - /data-reactor-layer="code"[\s\S]*data-reactor-layer="build"[\s\S]*data-reactor-layer="test"[\s\S]*data-reactor-layer="deploy"/, - ); - }); - - it('makes verified components the source, security review the perimeter, and late review external', () => { - const html = renderSection(); - - expect(html).toContain('data-reactor-source="verified-components"'); - expect(html).toContain('data-security-review="perimeter"'); - expect(html).toContain('data-late-artifact="rejected"'); - // The refused artifact is named, not just drawn — without a label it read - // as a stray fragment. Both breakpoints must carry it. - expect(html.match(/Unverified Components/g)?.length).toBe(2); - }); - - it('delivers mobile as cards rather than a scaled copy of the desktop scene', () => { - const html = renderSection(); - const mobile = html.slice(html.indexOf('data-cleanroom-reactor="mobile"')); - - // Mobile used to be the desktop reactor in a 360x880 viewBox, which renders - // taller than a phone viewport. It must not go back to being a scaled scene. - expect(mobile).not.toContain('viewBox="0 0 360'); - - // Two runs, each ending at its own gate. - expect(mobile.match(/Security Review/g)?.length).toBe(2); - - // Identical middles: both runs carry the same four stages, so the only - // difference the reader sees is the head and the verdict. - for (const stage of ['Code', 'Build', 'Test', 'Deploy']) { - expect(mobile.match(new RegExp(`>${stage}<`, 'g'))?.length).toBe(2); + for (const scene of html.split('data-scene=').slice(1)) { + const order = [...scene.matchAll(/data-stage="([a-z]+)"/g)].map((m) => m[1]); + expect(order).toEqual(['code', 'build', 'test', 'deploy', 'review', 'release']); } }); - it('keeps both visuals decorative and exposes one accessible process description', () => { + it('server-renders the settled state so the diagram reads without JS', () => { const html = renderSection(); - - expect(html).toMatch(/data-cleanroom-reactor="desktop"[^>]*aria-hidden="true"/); - expect(html).toMatch(/data-cleanroom-reactor="mobile"[^>]*aria-hidden="true"/); - expect( - html.match(/aria-label="Verified Components, Code, Build, Test, Deploy, Security Review"/g), - ).toHaveLength(1); - expect(html).toContain('preserveAspectRatio="xMidYMid meet"'); - expect(html).not.toMatch(/preserveAspectRatio=.none./); - }); - - it('provides a complete reduced-motion state for the reactor', () => { - const stylesheetPath = new URL('./SaasCleanroomReactor.module.css', import.meta.url); - const stylesheet = existsSync(stylesheetPath) ? readFileSync(stylesheetPath, 'utf8') : ''; - - expect(stylesheet).toContain('@media (prefers-reduced-motion: reduce)'); - expect(stylesheet).toMatch(/\.sourcePulse[\s\S]*animation: none !important/); - expect(stylesheet).toMatch(/\.layerPlate[\s\S]*opacity: 1 !important/); - expect(stylesheet).toMatch(/\.scanBeam[\s\S]*opacity: 0 !important/); + const phases = [...html.matchAll(/data-phase="([a-z]+)"/g)].map((m) => m[1]); + expect(phases).toEqual(['settled', 'settled']); + expect(html.match(/data-fill="true"/g)).toHaveLength(2); }); }); diff --git a/apps/web/src/components/sections/saas/SaasShiftLeft.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.tsx index d42e745e6..550ba38ba 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.tsx @@ -1,10 +1,8 @@ import type React from 'react'; import { Container, Section } from '@/components/layout'; import { Reveal } from '@/components/ui/Reveal'; -import { SaasCleanroomReactor } from './SaasCleanroomReactor'; - -const PROCESS_DESCRIPTION = - 'Verified Components, Code, Build, Test, Deploy, Security Review' as const; +import { SaasTrustPipeline } from './SaasTrustPipeline'; +import { PIPELINE_STAGES, SOURCE_LABEL } from './saasPipelineStages'; export function SaasShiftLeft(): React.ReactElement { return ( @@ -61,18 +59,16 @@ export function SaasShiftLeft(): React.ReactElement {
      -
        -
      1. Verified Components
      2. -
      3. Code
      4. -
      5. Build
      6. -
      7. Test
      8. -
      9. Deploy
      10. -
      11. Security Review
      12. +
          +
        1. {SOURCE_LABEL}
        2. + {PIPELINE_STAGES.map((stage) => ( +
        3. {stage.label}
        4. + ))}
        -
        - +
        +
        diff --git a/apps/web/src/components/sections/saas/SaasTrustPipeline.module.css b/apps/web/src/components/sections/saas/SaasTrustPipeline.module.css new file mode 100644 index 000000000..2526a54cc --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasTrustPipeline.module.css @@ -0,0 +1,396 @@ +/* + * SaasTrustPipeline: every visual beat of the scene. + * + * The component only flips `data-*` flags on the root svg in order: + * flying → approach → docked → fill → settled → exit + * Each flag stays set for the rest of the cycle, so a rule keyed on + * `[data-docked]` describes "from docking onwards". `data-phase="idle"` kills + * every transition so the reset after `exit` is an instant rewind rather than + * a replay in reverse. + * + * Rail timing is positional: `--frac` is a stage's 0..1 position along the + * rail and the sweep takes `--sweep`, so a badge lands the moment the fill + * front reaches its tile. + */ + +.deck { + --green: #34e3a6; + --green-soft: #7cf3c6; + --sweep: 3.2s; + --glide: 1.8s; + --drop: 0.75s; + position: relative; + overflow: hidden; + border-radius: 28px; + border: 1px solid rgba(140, 160, 255, 0.16); + background: + radial-gradient(60% 80% at 18% 60%, rgba(52, 227, 166, 0.05) 0%, rgba(52, 227, 166, 0) 70%), + linear-gradient(180deg, rgba(13, 17, 50, 0.88) 0%, rgba(8, 11, 36, 0.96) 100%); + box-shadow: + 0 34px 90px -40px rgba(0, 0, 0, 0.75), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +/* The svg must not clip: the hero glow spills past its box and the hero + itself starts off-canvas. The deck's overflow does the clipping, at the + card edge, where it reads as light leaving the card. */ +.scene { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.horizontal { + display: none; +} + +.vertical { + display: block; + max-width: 420px; + margin: 0 auto; +} + +/* Below lg the card hugs the vertical scene instead of spanning the + container, so there is no dead space beside it at tablet widths. */ +@media (max-width: 1023px) { + .deck { + max-width: 440px; + margin-inline: auto; + } +} + +@media (min-width: 1024px) { + .horizontal { + display: block; + } + + .vertical { + display: none; + } +} + +/* Instant rewind. */ +.scene[data-phase='idle'] * { + transition: none !important; +} + +/* ---------------------------------------------------------------- statics */ + +/* Opaque so the rail passes behind the tile, not across its icon. */ +.tile { + fill: #151a3d; + stroke: rgba(255, 255, 255, 0.14); + stroke-width: 1.25; + transition: stroke 0.4s ease; + transition-delay: calc(var(--frac) * var(--sweep)); +} + +.scene[data-fill] .tile { + stroke: rgba(255, 255, 255, 0.3); +} + +.icon { + color: rgba(255, 255, 255, 0.86); +} + +.label { + fill: rgba(226, 232, 255, 0.78); + transition: fill 0.4s ease; + transition-delay: calc(var(--frac) * var(--sweep)); +} + +.scene[data-fill] .label { + fill: rgba(240, 244, 255, 0.96); +} + +/* ------------------------------------------------------------------ socket */ + +.socketGlow { + opacity: 0; + transition: opacity 0.8s ease; +} + +.scene[data-docked] .socketGlow { + opacity: 1; +} + +.burst { + fill: none; + stroke: var(--green); + stroke-width: 2; + opacity: 0; + transform-box: fill-box; + transform-origin: center; +} + +.scene[data-docked] .burst { + animation: burst 0.9s ease-out forwards; +} + +.sourceLabel, +.sourceNote { + opacity: 0; + transform: translateY(6px); + transition: + opacity 0.5s ease 0.15s, + transform 0.5s ease 0.15s; +} + +.sourceLabel { + fill: var(--green-soft); +} + +.sourceNote { + fill: rgba(190, 245, 223, 0.6); + transition-delay: 0.3s; +} + +.scene[data-docked] .sourceLabel, +.scene[data-docked] .sourceNote { + opacity: 1; + transform: none; +} + +/* -------------------------------------------------------------------- hero */ + +/* Two legs on one path: a long decelerating glide into the hover point, + then a short ease-in-out drop onto the socket. Retargeting the same + transition keeps the motion continuous between the legs. */ +.hero { + offset-rotate: 0deg; + offset-distance: 0%; + transition: offset-distance var(--glide) cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.scene[data-flying] .hero { + offset-distance: var(--hover); +} + +.scene[data-descend] .hero { + offset-distance: 100%; + transition-duration: var(--drop); + transition-timing-function: cubic-bezier(0.45, 0, 0.25, 1); +} + +/* Gentle bob while hovering. Its period divides the hover + absorb beats so + it ends at rest as the descent begins. */ +.heroFloat { + transform-box: fill-box; + transform-origin: center; +} + +.scene[data-hover]:not([data-descend]) .heroFloat { + animation: bob 1.1s ease-in-out infinite; +} + +.heroBody { + transform-box: fill-box; + transform-origin: center; + transform: scale(1.06); + transition: transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.scene[data-docked] .heroBody { + transform: scale(1); +} + +.trail { + opacity: 0; + transition: opacity 0.3s ease; +} + +.scene[data-flying] .trail { + opacity: 1; +} + +.scene[data-hover] .trail { + opacity: 0; +} + +/* The chips ride in with the hero, sit beside it through the hover, and + fold into it before the descent. */ +.chip { + transition: + transform 0.6s cubic-bezier(0.65, 0, 0.35, 1), + opacity 0.45s ease-in; + transition-delay: calc(var(--i) * 90ms); +} + +.chipFace { + fill: rgba(52, 227, 166, 0.12); + stroke: rgba(52, 227, 166, 0.5); + stroke-width: 1; +} + +.chipText { + fill: #bdf5df; +} + +.scene[data-absorb] .chip { + transform: translate(var(--dx), var(--dy)) scale(0.3); + opacity: 0; +} + +/* --------------------------------------------------------------- the sweep */ + +.railFill { + fill: none; + stroke-width: 3; + stroke-dasharray: var(--len); + stroke-dashoffset: var(--len); + transition: stroke-dashoffset var(--sweep) linear; +} + +.scene[data-fill] .railFill { + stroke-dashoffset: 0; +} + +.badge { + opacity: 0; + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + transition: + transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), + opacity 0.25s ease; + transition-delay: calc(var(--frac) * var(--sweep)); +} + +.scene[data-fill] .badge { + opacity: 1; + transform: scale(1); +} + +.amber { + transition: opacity 0.3s ease; + transition-delay: calc(var(--frac) * var(--sweep)); +} + +.amberRing { + fill: none; + stroke: #f5b94a; + stroke-width: 1.5; + transform-box: fill-box; + transform-origin: center; + animation: pulse 1.8s ease-out infinite; +} + +.scene[data-fill] .amber { + opacity: 0; +} + +.bloom { + fill: none; + stroke: var(--green); + stroke-width: 2; + opacity: 0; + transform-box: fill-box; + transform-origin: center; +} + +.scene[data-fill] .bloom { + animation: burst 0.9s ease-out var(--sweep) forwards; +} + +.releaseFill { + opacity: 0; + transition: opacity 0.55s ease var(--sweep); +} + +.scene[data-fill] .releaseFill { + opacity: 1; +} + +.releaseIcon { + color: rgba(255, 255, 255, 0.86); + transition: color 0.4s ease calc(var(--sweep) + 0.05s); +} + +.scene[data-fill] .releaseIcon { + color: #05231a; +} + +.releaseLabel { + fill: rgba(226, 232, 255, 0.78); + transition: fill 0.4s ease var(--sweep); +} + +.scene[data-fill] .releaseLabel { + fill: var(--green-soft); + font-weight: 600; +} + +/* -------------------------------------------------------------------- exit */ + +.scene[data-exit] .hero, +.scene[data-exit] .railFill, +.scene[data-exit] .badge, +.scene[data-exit] .sourceLabel, +.scene[data-exit] .sourceNote, +.scene[data-exit] .releaseFill, +.scene[data-exit] .socketGlow { + opacity: 0; + transition: opacity 0.45s ease; + transition-delay: 0s; +} + +.scene[data-exit] .releaseIcon { + color: rgba(255, 255, 255, 0.86); + transition-delay: 0s; +} + +.scene[data-exit] .releaseLabel { + fill: rgba(226, 232, 255, 0.78); + transition-delay: 0s; +} + +/* -------------------------------------------------------------- keyframes */ + +@keyframes burst { + 0% { + transform: scale(0.7); + opacity: 0.8; + } + + 100% { + transform: scale(1.9); + opacity: 0; + } +} + +@keyframes bob { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-5px); + } +} + +@keyframes pulse { + 0% { + transform: scale(0.6); + opacity: 0.9; + } + + 100% { + transform: scale(1.8); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .heroFloat, + .amberRing, + .burst, + .bloom { + animation: none !important; + } + + .scene * { + transition: none !important; + } +} diff --git a/apps/web/src/components/sections/saas/SaasTrustPipeline.test.tsx b/apps/web/src/components/sections/saas/SaasTrustPipeline.test.tsx new file mode 100644 index 000000000..125fe6a4c --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasTrustPipeline.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('motion/react', () => ({ + useInView: () => true, + useReducedMotion: () => false, +})); + +import { SaasTrustPipeline } from './SaasTrustPipeline'; + +/* + * The scene is a phase machine on a frame clock. These tests pin the order of + * beats and the invariant the choreography depends on: the chips are absorbed + * before the descent begins, so nothing is still folding in at touchdown. + */ + +const FRAME = 16; + +function scene(container: HTMLElement): HTMLElement { + const el = container.querySelector('[data-scene="horizontal"]'); + if (!el) throw new Error('horizontal scene not rendered'); + return el; +} + +function advance(ms: number): void { + act(() => { + vi.advanceTimersByTime(ms + FRAME); + }); +} + +beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + ], + }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('SaasTrustPipeline clock', () => { + it('rewinds from the server-rendered settled frame to idle on mount', () => { + const { container } = render(); + expect(scene(container).dataset.phase).toBe('idle'); + expect(scene(container).dataset.fill).toBeUndefined(); + }); + + it('walks the beats in order and loops', () => { + const { container } = render(); + const svg = scene(container); + + advance(400); + expect(svg.dataset.phase).toBe('flight'); + advance(1800); + expect(svg.dataset.phase).toBe('hover'); + advance(1500); + expect(svg.dataset.phase).toBe('absorb'); + advance(700); + expect(svg.dataset.phase).toBe('descend'); + advance(750); + expect(svg.dataset.phase).toBe('docked'); + advance(400); + expect(svg.dataset.phase).toBe('fill'); + advance(4200); + expect(svg.dataset.phase).toBe('settled'); + advance(1400); + expect(svg.dataset.phase).toBe('exit'); + advance(400); + expect(svg.dataset.phase).toBe('idle'); + }); + + it('absorbs the chips before the descent, and lands with them gone', () => { + const { container } = render(); + const svg = scene(container); + + advance(400 + 1800); + expect(svg.dataset.phase).toBe('hover'); + expect(svg.dataset.absorb).toBeUndefined(); + expect(svg.dataset.descend).toBeUndefined(); + + advance(1500); + expect(svg.dataset.phase).toBe('absorb'); + expect(svg.dataset.absorb).toBe('true'); + expect(svg.dataset.descend).toBeUndefined(); + + advance(700); + expect(svg.dataset.phase).toBe('descend'); + expect(svg.dataset.absorb).toBe('true'); + expect(svg.dataset.docked).toBeUndefined(); + }); + + it('holds the hover for at least two seconds of chip-reading time', () => { + const { container } = render(); + const svg = scene(container); + + advance(400 + 1800); + const hoverStart = performance.now(); + while (svg.dataset.phase !== 'descend') advance(50); + /* Chips are legible from the start of the hover until the descent. */ + expect(performance.now() - hoverStart).toBeGreaterThanOrEqual(2000); + }); +}); diff --git a/apps/web/src/components/sections/saas/SaasTrustPipeline.tsx b/apps/web/src/components/sections/saas/SaasTrustPipeline.tsx new file mode 100644 index 000000000..4beb54991 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasTrustPipeline.tsx @@ -0,0 +1,621 @@ +'use client'; + +import type React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useInView, useReducedMotion } from 'motion/react'; +import { PIPELINE_STAGES, type StageId } from './saasPipelineStages'; +import styles from './SaasTrustPipeline.module.css'; + +/* + * "Move Beyond Shift Left" scene. + * + * A standard delivery pipeline (Code, Build, Test, Deploy, Security Review, + * Trusted Release) stands in one neutral treatment. Verified Components is a + * separate object: it flies in from off-canvas left, docks into an empty socket + * at the head of the rail, and only then does green run through the pipeline. + * Each stage keeps its colour and earns a check as the front passes; Security + * Review's amber "unverified" marker resolves the same way; Trusted Release is + * the one stage that turns green, because it inherits what the components + * brought in. + * + * The story is a phase machine driven by a JS clock. Every visual change is a + * CSS transition keyed off `data-*` flags on the root, so the stylesheet owns + * timing and easing and this file owns only the order of beats. Stage timing + * along the rail is positional (`--frac`), so the check badges land exactly as + * the fill front crosses them. + * + * Two orientations render from the same geometry description: horizontal for + * `lg` and up, vertical below. Both are decorative (`aria-hidden`); the ordered + * list in SaasShiftLeft is the accessible reading. + */ + +const PHASES = [ + 'idle', + 'flight', + 'hover', + 'absorb', + 'descend', + 'docked', + 'fill', + 'settled', + 'exit', +] as const; +type Phase = (typeof PHASES)[number]; + +/* Beat length (ms) for each phase. + `flight` matches the 1.8 s glide in the stylesheet: the hero decelerates + into a hover directly above the landing spot. `hover` + `absorb` is the + reading window for the chips; they fold into the hero during `absorb`, so + they are gone before touchdown. Their sum is two cycles of the hover bob so + the bob ends at rest. `descend` matches the 0.75 s drop. `fill` is the + 3.2 s rail sweep plus the 0.9 s release bloom, so the hold starts on a + still frame. */ +const BEATS: Record = { + idle: 400, + flight: 1800, + hover: 1500, + absorb: 700, + descend: 750, + docked: 400, + fill: 4200, + settled: 1400, + exit: 400, +}; + +const SOURCE_LABEL = 'Verified Components'; +const SOURCE_NOTE = 'Verified before the pipeline starts'; + +const CHIPS = ['Base image', 'Runtime', 'Libraries'] as const; + +type Orientation = 'horizontal' | 'vertical'; + +interface Point { + readonly x: number; + readonly y: number; +} + +interface TextAnchor extends Point { + readonly anchor: 'middle' | 'start'; +} + +interface Layout { + readonly width: number; + readonly height: number; + readonly socket: Point; + readonly socketR: number; + /** Hero flight: off-canvas, to a hover point, to the socket centre. The + second-to-last point is the hover. */ + readonly flight: readonly Point[]; + readonly rail: { readonly from: Point; readonly to: Point }; + readonly stages: readonly Point[]; + readonly stageLabel: (stage: Point) => TextAnchor; + readonly sourceLabel: TextAnchor; + readonly sourceNote: TextAnchor; + /** The note, split into lines where the orientation has no room for one. */ + readonly sourceNoteLines: readonly string[]; + readonly labelSize: number; +} + +const TILE = 84; +const TILE_R = 22; + +const HORIZONTAL: Layout = { + width: 1200, + height: 380, + socket: { x: 216, y: 224 }, + socketR: 56, + flight: [ + { x: -190, y: 96 }, + { x: 216, y: 96 }, + { x: 216, y: 224 }, + ], + rail: { from: { x: 278, y: 224 }, to: { x: 1064, y: 224 } }, + stages: [392, 524, 656, 788, 920, 1064].map((x) => ({ x, y: 224 })), + stageLabel: (s) => ({ x: s.x, y: s.y + TILE / 2 + 28, anchor: 'middle' }), + sourceLabel: { x: 216, y: 310, anchor: 'middle' }, + sourceNote: { x: 216, y: 333, anchor: 'middle' }, + sourceNoteLines: [SOURCE_NOTE], + labelSize: 15, +}; + +const VERTICAL: Layout = { + width: 360, + height: 860, + socket: { x: 78, y: 150 }, + socketR: 56, + flight: [ + { x: -190, y: 62 }, + { x: 78, y: 62 }, + { x: 78, y: 150 }, + ], + rail: { from: { x: 78, y: 212 }, to: { x: 78, y: 782 } }, + stages: [270, 370, 470, 570, 670, 782].map((y) => ({ x: 78, y })), + stageLabel: (s) => ({ x: s.x + TILE / 2 + 20, y: s.y + 6, anchor: 'start' }), + sourceLabel: { x: 150, y: 146, anchor: 'start' }, + sourceNote: { x: 150, y: 170, anchor: 'start' }, + sourceNoteLines: ['Verified before', 'the pipeline starts'], + labelSize: 16, +}; + +const LAYOUTS: Record = { horizontal: HORIZONTAL, vertical: VERTICAL }; + +function polyline(points: readonly Point[]): string { + return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '); +} + +/** 0..1 distance along the flight at which the hover point sits. */ +function hoverFraction(points: readonly Point[]): number { + let total = 0; + let toHover = 0; + for (let i = 1; i < points.length; i += 1) { + const a = points[i - 1]; + const b = points[i]; + if (!a || !b) continue; + total += Math.hypot(b.x - a.x, b.y - a.y); + if (i === points.length - 2) toHover = total; + } + return toHover / total; +} + +function railLength(rail: Layout['rail']): number { + return Math.hypot(rail.to.x - rail.from.x, rail.to.y - rail.from.y); +} + +/** 0..1 position of a stage centre along the rail. */ +function railFraction(rail: Layout['rail'], stage: Point): number { + const along = Math.hypot(stage.x - rail.from.x, stage.y - rail.from.y); + return along / railLength(rail); +} + +/** Pointy-top hexagon centred on the origin. */ +function hexagon(r: number): string { + const pts: string[] = []; + for (let i = 0; i < 6; i += 1) { + const a = (Math.PI / 180) * (-90 + 60 * i); + pts.push(`${(r * Math.cos(a)).toFixed(2)} ${(r * Math.sin(a)).toFixed(2)}`); + } + return `M ${pts.join(' L ')} Z`; +} + +/* Lucide glyphs on the 24 grid, drawn at 34 in an 84 tile (about 40%, the + usual icon-in-tile ratio). None of the pipeline glyphs carries a check: the + badge that lands on each tile is the check, and a tile that already shows + one would look verified before the sweep arrives. */ +const ICON = 34; + +const ICON_PROPS = { + width: ICON, + height: ICON, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.6, + strokeLinecap: 'round', + strokeLinejoin: 'round', +} as const; + +function StageIcon({ id, at }: { id: StageId; at: Point }): React.ReactElement { + const frame = { ...ICON_PROPS, x: at.x - ICON / 2, y: at.y - ICON / 2 }; + switch (id) { + case 'code': + return ( + + + + + ); + case 'build': + return ( + + + + + + ); + case 'test': + return ( + + + + + + ); + case 'deploy': + return ( + + + + + + + ); + case 'review': + return ( + + + + + + ); + case 'release': + return ( + + + + + + + + ); + } +} + +interface SceneProps { + readonly orientation: Orientation; + readonly phase: Phase; +} + +function Scene({ orientation, phase }: SceneProps): React.ReactElement { + const L = LAYOUTS[orientation]; + const idx = PHASES.indexOf(phase); + const reached = (p: Phase): true | undefined => (idx >= PHASES.indexOf(p) ? true : undefined); + const uid = orientation === 'horizontal' ? 'h' : 'v'; + const len = railLength(L.rail); + const railPath = `M ${L.rail.from.x} ${L.rail.from.y} L ${L.rail.to.x} ${L.rail.to.y}`; + const withChips = orientation === 'horizontal'; + + return ( + + ); +} + +export function SaasTrustPipeline(): React.ReactElement { + const ref = useRef(null); + const reduce = useReducedMotion() === true; + const inView = useInView(ref, { amount: 0.2 }); + /* Fail-open, as in Reveal: if the observer has not reported an element that + is already on screen shortly after mount (a race seen on client-side + navigation), start from geometry rather than leave the socket empty. */ + const [forced, setForced] = useState(false); + /* Server-render the settled state so the diagram reads without JS and under + reduced motion; the clock rewinds to idle on mount when it can animate. */ + const [phase, setPhase] = useState('settled'); + + useEffect(() => { + if (inView) { + setForced(false); + return; + } + const id = window.setTimeout(() => { + const el = ref.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const vh = window.innerHeight || document.documentElement.clientHeight; + if (rect.top < vh && rect.bottom > 0) setForced(true); + }, 200); + return () => window.clearTimeout(id); + }, [inView]); + + const active = inView || forced; + + useEffect(() => { + if (reduce) { + setPhase('settled'); + return; + } + setPhase('idle'); + if (!active) return; + + /* Frame-driven rather than setTimeout: background tabs throttle timers to + one-second bursts, which would fast-forward the phases while the CSS + transitions sit frozen, then replay them as a jumble on return. Frames + stop while hidden, and the story restarts from idle when the tab comes + back, so a viewer never joins mid-transition. */ + let i = 0; + let startedAt = performance.now(); + let frame = 0; + const tick = (now: number): void => { + const current = PHASES[i] ?? 'idle'; + if (now - startedAt >= BEATS[current]) { + i = (i + 1) % PHASES.length; + startedAt = now; + setPhase(PHASES[i] ?? 'idle'); + } + frame = window.requestAnimationFrame(tick); + }; + const restart = (): void => { + if (document.visibilityState !== 'visible') return; + i = 0; + startedAt = performance.now(); + setPhase('idle'); + }; + frame = window.requestAnimationFrame(tick); + document.addEventListener('visibilitychange', restart); + return () => { + window.cancelAnimationFrame(frame); + document.removeEventListener('visibilitychange', restart); + }; + }, [active, reduce]); + + return ( +
        + + +
        + ); +} diff --git a/apps/web/src/components/sections/saas/saasPipelineStages.ts b/apps/web/src/components/sections/saas/saasPipelineStages.ts new file mode 100644 index 000000000..9037912df --- /dev/null +++ b/apps/web/src/components/sections/saas/saasPipelineStages.ts @@ -0,0 +1,23 @@ +/* + * Shared between the server-rendered section (accessible ordered list) and the + * client-rendered scene. Kept out of the 'use client' module: exports crossing + * that boundary become client references, not values. + */ + +export type StageId = 'code' | 'build' | 'test' | 'deploy' | 'review' | 'release'; + +export interface PipelineStage { + readonly id: StageId; + readonly label: string; +} + +export const PIPELINE_STAGES: readonly PipelineStage[] = [ + { id: 'code', label: 'Code' }, + { id: 'build', label: 'Build' }, + { id: 'test', label: 'Test' }, + { id: 'deploy', label: 'Deploy' }, + { id: 'review', label: 'Security Review' }, + { id: 'release', label: 'Trusted Release' }, +]; + +export const SOURCE_LABEL = 'Verified Components'; diff --git a/apps/web/src/components/sections/teams/TeamsCTA.tsx b/apps/web/src/components/sections/teams/TeamsCTA.tsx index fbe55c95b..f5ebfdfcc 100644 --- a/apps/web/src/components/sections/teams/TeamsCTA.tsx +++ b/apps/web/src/components/sections/teams/TeamsCTA.tsx @@ -62,7 +62,7 @@ export function TeamsCTA() { {/* eslint-disable-next-line @next/next/no-img-element */}
        diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index 33399096c..71c980203 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -115,11 +115,10 @@ export const NAV_TREE: NavItem[] = [ icon: "minimize", }, { - label: "ROI Calculator", - href: "/roi-calculator", + label: "Impact Estimator", + href: "/impact-estimator", description: "Estimate the operational impact of hardened images.", - icon: "radar", - built: false, + icon: "gauge", }, ], }, @@ -158,21 +157,12 @@ export const NAV_TREE: NavItem[] = [ ], }, { - // /industries/financial-services-container-security is live: indexable - // and listed in the sitemap. - // - // Its sibling /industries/saas-container-security is deliberately NOT - // listed: the page is built and complete (metadata, JSON-LD graph and - // pageRegistry row all in place) but its copy is not approved, so it - // stays reachable by direct URL only. Restore the row below when it is: - // { label: "SaaS", - // href: "/industries/saas-container-security", - // description: "Ship faster on a verified software foundation.", - // icon: "cloud" } + // Both /industries/* pages are live: indexable and listed in the sitemap. // - // Labels stay short. The page is titled "Container Security for - // Financial Services" for search, but the nav is navigation, not a - // ranking surface, and the group heading already says "By industry". + // Labels stay short. The pages are titled "Container Security for + // Financial Services" / "... for SaaS Companies" for search, but the nav + // is navigation, not a ranking surface, and the group heading already + // says "By industry". title: "By industry", items: [ { @@ -181,6 +171,12 @@ export const NAV_TREE: NavItem[] = [ description: "Verified components for regulated financial software.", icon: "bank", }, + { + label: "Modern Applications", + href: "/industries/modern-applications", + description: "Ship faster on a verified software foundation.", + icon: "layers", + }, ], }, ], diff --git a/apps/web/tests/e2e/impact-estimator.spec.ts b/apps/web/tests/e2e/impact-estimator.spec.ts new file mode 100644 index 000000000..5c73e16ab --- /dev/null +++ b/apps/web/tests/e2e/impact-estimator.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from "@playwright/test"; + +/** + * Impact Estimator: shareable-link state. + * + * The four inputs round-trip through the URL so a result can be forwarded by + * link. A link-driven load must land on that exact state, and moving a control + * must update the address bar without a navigation. Numbers come from the + * client's ROI 1.xlsx bands: 400 images / 120 engineers / Weekly / Continuous + * is every input at its top weight, which is the 360-point Extreme tier. + * + * @phase-web-impact-estimator + */ + +const ROUTE = "/impact-estimator"; + +test.describe("impact estimator @phase-web-impact-estimator", () => { + test("adopts the inputs from a shared link", async ({ page }) => { + await page.goto(`${ROUTE}?images=400&team=120&remediation=Weekly&release=Continuous`); + + const gauge = page.locator('[data-section="ImpactSimulator"] svg[role="img"]'); + await expect(gauge).toHaveAttribute("aria-label", /Operational Burden Score 360 of 360, Runtime Complexity Extreme/); + + await expect(page.getByRole("slider", { name: "Production images" })).toHaveValue("400"); + await expect(page.getByRole("slider", { name: "Engineering team size" })).toHaveValue("120"); + await expect(page.getByRole("button", { name: "Weekly", pressed: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Continuous", pressed: true })).toBeVisible(); + }); + + test("mirrors a control change into the address bar", async ({ page }) => { + await page.goto(ROUTE); + await expect(page).toHaveURL(new RegExp(`${ROUTE}$`)); + + await page.getByRole("button", { name: "Quarterly" }).click(); + + await expect(page).toHaveURL(/\?images=200&team=40&remediation=Quarterly&release=Continuous$/); + await expect(page.getByRole("button", { name: "Quarterly", pressed: true })).toBeVisible(); + }); + + test("ignores an invalid shared link and falls back to the defaults", async ({ page }) => { + await page.goto(`${ROUTE}?images=abc&team=99999&remediation=daily`); + + await expect(page.getByRole("slider", { name: "Production images" })).toHaveValue("200"); + await expect(page.getByRole("slider", { name: "Engineering team size" })).toHaveValue("200"); + await expect(page.getByRole("button", { name: "Monthly", pressed: true }).first()).toBeVisible(); + }); +}); + +test.describe("impact estimator copy link @phase-web-impact-estimator", () => { + test("copies a link that carries the current inputs", async ({ page, context, browserName }) => { + test.skip(browserName !== "chromium", "clipboard permissions are only grantable in Chromium"); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.goto(`${ROUTE}?images=60&team=15&remediation=Quarterly&release=Monthly`); + + const button = page.getByRole("button", { name: "Copy link to results" }); + await button.click(); + await expect(page.getByRole("button", { name: "Link copied" })).toBeVisible(); + + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toBe(`${new URL(page.url()).origin}${ROUTE}?images=60&team=15&remediation=Quarterly&release=Monthly`); + }); +}); + +test.describe("impact estimator mobile summary @phase-web-impact-estimator", () => { + test("shows a live summary strip while the inputs are on screen and the results are not", async ({ page, viewport }) => { + test.skip(!viewport || viewport.width >= 1024, "the strip only exists below the lg breakpoint"); + await page.goto(ROUTE); + + const strip = page.locator('button[aria-label^="Jump to your results"]'); + await expect(strip).toHaveAttribute("aria-hidden", "true"); + + // Park the inputs card just under the header: the sliders are usable and + // the gauge sits below the fold on every phone viewport. + await page.evaluate(() => { + const card = document.querySelector('[data-section="ImpactSimulator"] .lg\\:sticky'); + if (card) window.scrollTo({ top: card.getBoundingClientRect().top + window.scrollY - 80, behavior: "instant" }); + }); + await expect(strip).toHaveAttribute("aria-hidden", "false"); + await expect(strip).toHaveAttribute("aria-label", /High runtime complexity, burden 260, 7,800 hours/); + + await strip.click(); + await expect(strip).toHaveAttribute("aria-hidden", "true"); + await expect(page.locator('[data-section="ImpactSimulator"] svg[role="img"]')).toBeInViewport(); + }); +}); + +test.describe("impact estimator sticky inputs @phase-web-impact-estimator", () => { + test("keeps the inputs card pinned beside the results while the column scrolls", async ({ page, viewport }) => { + test.skip(!viewport || viewport.width < 1024, "inputs are only sticky from the lg breakpoint"); + await page.goto(ROUTE); + + const card = page.locator('[data-section="ImpactSimulator"] .lg\\:sticky'); + const results = page.locator('[data-section="ImpactSimulator"] .lg\\:sticky + div'); + + // At rest both columns start on the same line. + const rest = await page.evaluate(() => { + const a = document.querySelector('[data-section="ImpactSimulator"] .lg\\:sticky'); + const b = a?.nextElementSibling; + return a && b ? Math.round(a.getBoundingClientRect().top - b.getBoundingClientRect().top) : Number.NaN; + }); + expect(rest).toBe(0); + + // Bring the section under the header, then scroll a further 150px: the card + // pins at header + 24 while the results keep moving. 150px keeps the card + // inside its grid row, which is only as tall as the results column. + const headerH = await page.evaluate(() => Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--cs-header-h"))); + await page.evaluate((h) => { + const section = document.querySelector('[data-section="ImpactSimulator"]'); + if (section) window.scrollTo({ top: section.getBoundingClientRect().top + window.scrollY - h, behavior: "instant" }); + }, headerH); + const before = await results.evaluate((el) => el.getBoundingClientRect().top); + await page.evaluate(() => window.scrollBy({ top: 150, behavior: "instant" })); + const cardTop = await card.evaluate((el) => el.getBoundingClientRect().top); + const after = await results.evaluate((el) => el.getBoundingClientRect().top); + + expect(Math.round(cardTop)).toBe(Math.round(headerH + 24)); + expect(Math.round(before - after)).toBe(150); + }); +}); diff --git a/docs/seo/03-onpage-and-metadata.md b/docs/seo/03-onpage-and-metadata.md index cc351e6b9..dddb22252 100644 --- a/docs/seo/03-onpage-and-metadata.md +++ b/docs/seo/03-onpage-and-metadata.md @@ -212,7 +212,7 @@ Evidence: confirmed by direct fetch of the live homepage: zero `meta name="keywo - **Acceptance:** - Pages requiring snippet suppression return the correct `robots` meta directive in HTML source - No snippet text appears in a `site:` search for that URL -- **Verify:** `curl -s https://www.cleanstart.com/roi-calculator | grep -o 'name="robots" content="[^"]*"'` +- **Verify:** `curl -s https://www.cleanstart.com/impact-estimator | grep -o 'name="robots" content="[^"]*"'` - **Reference:** `apps/web/src/lib/seo/canonical.ts:161-177` (`robots` field construction — currently only expresses `noindex`/`nofollow`, no `nosnippet`/`max-snippet` param) - **Source:** [Tier 1] https://developers.google.com/search/docs/appearance/snippet ("Control your snippets"); [Tier 1] https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag (AI Overviews/AI Mode extension) - **Anti-patterns:** applying `nosnippet` site-wide by mistake (kills all organic snippets, hurting CTR); using `data-nosnippet` around content that should actually be searchable. diff --git a/docs/seo/07-rendering-and-delivery.md b/docs/seo/07-rendering-and-delivery.md index 404aa0ee4..19deec166 100644 --- a/docs/seo/07-rendering-and-delivery.md +++ b/docs/seo/07-rendering-and-delivery.md @@ -142,7 +142,7 @@ - **Source:** [Tier 1] "Some JavaScript sites may use the app shell model where the initial HTML does not contain the actual content and Google needs to execute JavaScript before being able to see the actual page content... server-side or pre-rendering is still a great idea." / "Googlebot queues pages for both crawling and rendering... Google also uses the rendered HTML to index the page." — [JavaScript SEO basics](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics), Google Search Central. - **Tools:** Lighthouse's SEO category does not distinguish "content present in raw HTML" from "content present after hydration" as a scored audit; this remains a manual Search Console check. - **Anti-patterns:** Confirming indexability by `curl`-ing a page and reading the raw HTML alone — an app-shell page can look empty even though Google may eventually render and index it, and vice versa, a raw-HTML check can miss a rendering failure that silently breaks indexing. -- **Evidence:** Every route's `page.tsx` entry point is an `async` server component — no `"use client"` directive was found at the top of any `app/**/page.tsx` file (`codebase-inventory.md`, "Server- vs. client-rendered content"). Primary content (article bodies, hero data, listings) is fetched server-side and passed as props. The only primary-surface `"use client"` component is `RoiSimulator.tsx:1`, and the `/roi-calculator` page it lives on is `noindex: true, nofollow: true` — this client-only interactive centerpiece sits on a page not intended to be indexed regardless. +- **Evidence:** Every route's `page.tsx` entry point is an `async` server component — no `"use client"` directive was found at the top of any `app/**/page.tsx` file (`codebase-inventory.md`, "Server- vs. client-rendered content"). Primary content (article bodies, hero data, listings) is fetched server-side and passed as props. The only primary-surface `"use client"` component is `ImpactSimulator.tsx:1`, and the `/impact-estimator` page it lives on is `noindex: true, nofollow: true` — this client-only interactive centerpiece sits on a page not intended to be indexed regardless. - **CleanStart:** Pass --- diff --git a/docs/superpowers/plans/2026-09-01-hero-verified-hardened.md b/docs/superpowers/plans/2026-09-01-hero-verified-hardened.md new file mode 100644 index 000000000..40b94a71a --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-hero-verified-hardened.md @@ -0,0 +1,583 @@ +# Hero "Verified over struck-through Hardened" Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the homepage hero H1 into a three-line, one-shot animated sequence — "Verified" / "Hardened. Secure." (Hardened struck through) / "Built for the AI Era." — per the approved design at `docs/superpowers/specs/2026-09-01-hero-verified-hardened-design.md`. + +**Architecture:** Single server component (`HeroHeading.tsx`) restructured from two text spans into three block-level lines wrapped in one `aria-hidden` container (with a clean `aria-label` on the `

        ` for the accessible name), driven entirely by CSS keyframes in `globals.css` — no client JS, no new dependencies, no new typography tokens. + +**Tech Stack:** Next.js 16 server component, plain CSS keyframes (Tailwind v4 project, but this block is hand-written CSS like its predecessor). + +--- + +## File Structure + +- Modify: `apps/web/src/components/sections/home/HeroHeading.tsx` — JSX structure + accessibility attributes. +- Modify: `apps/web/src/app/globals.css:1471-1623` — replace the `.cs-hh-*` rule block with the new three-line sequence (keeps the same rule-block location and naming prefix). +- No test files: this is a presentational, CSS-only animation with no business logic to unit test. Verification is lint/typecheck/build (per `CLAUDE.md`'s mandatory pre-completion checks) plus a manual visual check in the browser preview (per `CLAUDE.md`'s "test the golden path in a browser" rule for UI changes) — there is nothing here TDD applies to. + +--- + +### Task 1: Restructure `HeroHeading.tsx` into three accessible lines + +**Files:** +- Modify: `apps/web/src/components/sections/home/HeroHeading.tsx` (full file, currently 33 lines) + +- [ ] **Step 1: Replace the component** + +Replace the entire file contents with: + +```tsx +// Home hero H1 with a layered, on-brand motion sequence: +// 1. "Hardened. Secure." (brand cyan→purple gradient) reveals via a +// stepped clip "type-on" + blinking caret, then snaps from soft blur +// into sharp focus. +// 2. "Hardened." gets struck through and desaturates to muted gray — +// the industry's claim, rejected. +// 3. "Verified" rises into focus on its own line above, in the same +// gradient as "Secure." — the correction, and the brand's actual +// claim. A continuous gradient shine starts once it lands. +// 4. "Built for the AI Era." (white) focus-settles in last. +// +// Server component (no "use client") — the effect is pure CSS, so zero client JS +// ships for it and the FULL heading text is present in the server HTML (the +// type-on is a clip reveal, not character insertion). That is load-bearing: the +// H1 is the LCP element and screen readers must read the whole phrase. The +// struck-through "Hardened" is a visual/rhetorical device, not part of the +// coherent accessible name, so the visual markup is aria-hidden and the

        +// carries an explicit aria-label with the clean phrase instead. All motion +// lives in keyframes (never in base rules) so prefers-reduced-motion falls +// back to the final, static, fully-visible heading. Timing/keyframes: the +// cs-hh-* rules in globals.css. Typography stays on the role tokens +// (--fs-display-home / --fs-display-ls) exactly as before. +export function HeroHeading() { + return ( +

        + +

        + ); +} +``` + +- [ ] **Step 2: Sanity-check the JSX compiles** + +Run: `pnpm --filter @cleanstart/web typecheck` +Expected: no new errors from this file (pre-existing unrelated errors, if any, are out of scope). + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/sections/home/HeroHeading.tsx +git commit -m "feat(web): restructure hero H1 into Verified/Hardened three-line markup" +``` + +--- + +### Task 2: Replace the `.cs-hh-*` CSS with the three-line sequence + +**Files:** +- Modify: `apps/web/src/app/globals.css:1471-1623` + +- [ ] **Step 1: Replace the CSS block** + +Find the block starting at the comment `/* Home hero H1 — "type-on → true-focus" then "focus-settle + living accent"` (globals.css:1471) and ending at the closing `}` of the second `@media (prefers-reduced-motion: reduce)` block that contains `.cs-hh-type,\n .cs-hh-phrase { animation: none; }` (globals.css:1623) — this is the exact current block: + +```css +/* Home hero H1 — "type-on → true-focus" then "focus-settle + living accent" + (see HeroHeading.tsx). Pure CSS so the LCP H1 text paints from the server + HTML; clip/opacity/blur live only in keyframes (never base rules), so + reduced-motion (animation: none) falls back to the final, fully-visible + heading. Timeline: 0.15s type-on starts → ~1.05s typed → focus snap → + 1.25s line 2 focus-settles → ~2.2s accent shimmer + settle glint. */ +.cs-hero-h1 { + position: relative; +} + +/* 1 · "Verified. Secure." — stepped clip reveal (type-on) held soft, then a + smooth blur→sharp focus snap once the line is fully revealed. */ +.cs-hh-typewrap { + display: inline-block; + position: relative; + white-space: nowrap; +} + +.cs-hh-type { + display: inline-block; + /* Brand cyan→purple gradient (shared H2 stops, .cs-text-gradient-impact), + mirrored periodic so the continuous drift loops seamlessly. */ + background-image: linear-gradient(100deg, + #2cc1eb 0%, + #9a51ff 50%, + #2cc1eb 100%); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + animation: + cs-hh-reveal 0.9s steps(17) 0.15s both, + cs-hh-snap 0.5s ease-out 1.05s both, + cs-hh-shine 6s linear 1.6s infinite; + /* No will-change: the clip/blur entrance is one-shot, and the looping + background-position shine is a paint (not a compositable) property, so a + layer hint buys nothing while holding memory on the LCP heading. */ +} + +@keyframes cs-hh-reveal { + from { + clip-path: inset(0 100% 0 0); + } + + to { + clip-path: inset(0 0 0 0); + } +} + +@keyframes cs-hh-snap { + from { + filter: blur(5px); + } + + to { + filter: blur(0); + } +} + +/* Blinking caret that travels with the reveal edge, then fades as focus runs. + Lives on the wrapper so the inner element's clip-path doesn't crop it. */ +.cs-hh-typewrap::after { + content: ""; + position: absolute; + top: 0.12em; + left: 0; + width: 0.06em; + height: 0.82em; + background: currentColor; + animation: + cs-hh-caret-move 0.9s steps(17) 0.15s both, + cs-hh-blink 0.7s step-end 0.15s infinite, + cs-hh-caret-hide 0.25s linear 1.05s forwards; +} + +@keyframes cs-hh-caret-move { + from { + left: 0; + } + + to { + left: 100%; + } +} + +@keyframes cs-hh-blink { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0; + } +} + +@keyframes cs-hh-caret-hide { + to { + opacity: 0; + } +} + +/* 2 · "Built for the AI Era." — focus-settle entrance after the type-on line. */ +.cs-hh-phrase { + display: inline-block; + animation: cs-hh-focus 0.8s cubic-bezier(0.16, 1, 0.3, 1) 1.25s both; + /* One-shot focus-settle — no persistent compositor-layer hint. */ +} + +@keyframes cs-hh-focus { + from { + opacity: 0; + filter: blur(12px); + transform: translateY(0.18em); + } + + to { + opacity: 1; + filter: blur(0); + transform: none; + } +} + +/* Continuous seamless drift for the colored "Verified. Secure." line: scroll + the periodic gradient by exactly one tile width so it loops with no snap. */ +@keyframes cs-hh-shine { + from { + background-position: 0 0; + } + + to { + background-position: -200% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + + .cs-hh-type, + .cs-hh-phrase { + animation: none; + } + + /* Hold a static cyan→purple gradient on line 1 when motion is off. */ + .cs-hh-type { + background-position: 0 0; + } + + .cs-hh-typewrap::after { + display: none; + } +} +``` + +Replace it with: + +```css +/* Home hero H1 — "reject, then correct": the industry's "Hardened. Secure." + types on and gets struck through, then "Verified" rises above it as the + brand's actual claim (see HeroHeading.tsx). Pure CSS so the LCP H1 text + paints from the server HTML; clip/opacity/blur live only in keyframes + (never base rules where avoidable), so reduced-motion (animation: none) + falls back to the final, fully-corrected, fully-visible heading. Timeline: + 0.15s type-on starts → ~1.05s "Hardened. Secure." typed → 1.1s strike + + desaturate lands on "Hardened" → 1.4s "Verified" rises → ~1.95s shine + starts, line 3 focus-settles → ~2.5s fully settled. */ +.cs-hero-h1 { + position: relative; +} + +/* Shared brand cyan→purple gradient text treatment (shared H2 stops, + .cs-text-gradient-impact), mirrored periodic so the continuous drift + loops seamlessly. Used by "Verified", "Secure.", and "Hardened." while + it's still typing (before the strike desaturates it). */ +.cs-hh-verified, +.cs-hh-secure, +.cs-hh-hardened-grad { + background-image: linear-gradient(100deg, + #2cc1eb 0%, + #9a51ff 50%, + #2cc1eb 100%); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; +} + +/* 1 · "Verified" — the correction line. Rises into focus after the + "Hardened" strike lands, then joins "Secure." in the continuous shine. */ +.cs-hh-verified { + display: block; + animation: + cs-hh-verified-rise 0.55s cubic-bezier(0.16, 1, 0.3, 1) 1.4s both, + cs-hh-shine 6s linear 1.95s infinite; +} + +@keyframes cs-hh-verified-rise { + from { + opacity: 0; + filter: blur(10px); + transform: translateY(0.15em); + } + + to { + opacity: 1; + filter: blur(0); + transform: none; + } +} + +/* 2 · "Hardened. Secure." — stepped clip reveal (type-on) for the whole + line, held soft, then a smooth blur→sharp focus snap once fully + revealed. */ +.cs-hh-typewrap { + display: block; + position: relative; + white-space: nowrap; +} + +.cs-hh-line2 { + display: inline-block; + animation: + cs-hh-reveal 0.9s steps(17) 0.15s both, + cs-hh-snap 0.5s ease-out 1.05s both; + /* No will-change: the clip/blur entrance is one-shot, so a layer hint + buys nothing while holding memory on the LCP heading. */ +} + +@keyframes cs-hh-reveal { + from { + clip-path: inset(0 100% 0 0); + } + + to { + clip-path: inset(0 0 0 0); + } +} + +@keyframes cs-hh-snap { + from { + filter: blur(5px); + } + + to { + filter: blur(0); + } +} + +/* "Hardened." types on in the gradient (so the type-on still reads as one + phrase), then — once the strike lands — crossfades to a muted gray twin + stacked in the same position. */ +.cs-hh-hardened { + position: relative; + display: inline-block; +} + +.cs-hh-hardened::after { + content: ""; + position: absolute; + left: -2%; + right: -2%; + top: 50%; + height: 3px; + background: #ff5468; + border-radius: 2px; + transform-origin: left center; + animation: cs-hh-strike 0.4s ease-out 1.1s both; +} + +@keyframes cs-hh-strike { + from { + transform: scaleX(0); + } + + to { + transform: scaleX(1); + } +} + +.cs-hh-hardened-grad { + animation: cs-hh-fade-out 0.2s ease-out 1.1s both; +} + +.cs-hh-hardened-gray { + position: absolute; + inset: 0; + color: #8892a4; + opacity: 0; + animation: cs-hh-fade-in 0.2s ease-out 1.1s both; +} + +@keyframes cs-hh-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes cs-hh-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.cs-hh-secure { + animation: cs-hh-shine 6s linear 1.95s infinite; +} + +/* Blinking caret that travels with the reveal edge, then fades as focus runs. + Lives on the wrapper so the inner element's clip-path doesn't crop it. */ +.cs-hh-typewrap::after { + content: ""; + position: absolute; + top: 0.12em; + left: 0; + width: 0.06em; + height: 0.82em; + background: currentColor; + animation: + cs-hh-caret-move 0.9s steps(17) 0.15s both, + cs-hh-blink 0.7s step-end 0.15s infinite, + cs-hh-caret-hide 0.25s linear 1.05s forwards; +} + +@keyframes cs-hh-caret-move { + from { + left: 0; + } + + to { + left: 100%; + } +} + +@keyframes cs-hh-blink { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0; + } +} + +@keyframes cs-hh-caret-hide { + to { + opacity: 0; + } +} + +/* 3 · "Built for the AI Era." — focus-settle entrance after the correction + has landed. */ +.cs-hh-phrase { + display: block; + animation: cs-hh-focus 0.8s cubic-bezier(0.16, 1, 0.3, 1) 1.7s both; + /* One-shot focus-settle — no persistent compositor-layer hint. */ +} + +@keyframes cs-hh-focus { + from { + opacity: 0; + filter: blur(12px); + transform: translateY(0.18em); + } + + to { + opacity: 1; + filter: blur(0); + transform: none; + } +} + +/* Continuous seamless drift for the gradient "Verified"/"Secure." text: + scroll the periodic gradient by exactly one tile width so it loops with + no snap. */ +@keyframes cs-hh-shine { + from { + background-position: 0 0; + } + + to { + background-position: -200% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + + .cs-hh-verified, + .cs-hh-line2, + .cs-hh-hardened::after, + .cs-hh-hardened-grad, + .cs-hh-hardened-gray, + .cs-hh-secure, + .cs-hh-phrase { + animation: none; + } + + /* Hold the final, fully-corrected state when motion is off: "Verified" + and "Secure." visible in a static gradient, "Hardened." shown as its + already-struck-through gray twin. */ + .cs-hh-verified, + .cs-hh-secure { + background-position: 0 0; + } + + .cs-hh-hardened-grad { + opacity: 0; + } + + .cs-hh-hardened-gray { + opacity: 1; + } + + .cs-hh-hardened::after { + transform: scaleX(1); + } + + .cs-hh-typewrap::after { + display: none; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/web/src/app/globals.css +git commit -m "feat(web): animate hero H1 Hardened-strikethrough to Verified correction" +``` + +--- + +### Task 3: Verify + +**Files:** none (verification only) + +- [ ] **Step 1: Run the mandatory pre-completion checks (apps/web touched)** + +```bash +pnpm --filter @cleanstart/web lint +pnpm --filter @cleanstart/web typecheck +pnpm --filter @cleanstart/web build +``` + +Expected: all three exit 0. Fix and re-run if anything fails (per `CLAUDE.md` — never skip, never report success on a red check). + +- [ ] **Step 2: Visual check in the browser preview** + +Start (or reuse, per `CLAUDE.md`'s Dev Server Policy) the `apps/web` dev server, open the homepage, and confirm on first load: +- Three left-aligned lines render in order: "Verified" (gradient) / "Hardened. Secure." (Hardened struck through, then gray; Secure. matches Verified's gradient) / "Built for the AI Era." (white). +- The sequence plays once (no loop/blink) and settles by ~2.5s; the gradient continues a slow shine drift on "Verified"/"Secure." afterward. +- No layout shift or overlap between the three lines. + +In the browser devtools, emulate `prefers-reduced-motion: reduce` and reload — confirm all three lines are immediately visible in their final state (Hardened already struck through and gray, Verified and Secure already in gradient, no caret, no shine), with no flash of the pre-strike gradient "Hardened." + +- [ ] **Step 3: Confirm the accessible name** + +In devtools, inspect the `

        ` and confirm its computed accessible name is exactly "Verified. Secure. Built for the AI Era." (from the `aria-label`), not a concatenation that includes "Hardened." + +--- + +## Self-Review Notes + +- **Spec coverage:** layout (3 lines, left-aligned) → Task 1. Shared type-on for "Hardened. Secure." → Task 2 `.cs-hh-line2`. Strike + desaturate on "Hardened." only → Task 2 `.cs-hh-hardened*`. Shared gradient for "Verified"/"Secure." → Task 2 shared rule + shine. "Built for the AI Era." unchanged styling, later delay → Task 2 `.cs-hh-phrase`. Accessibility (`aria-label` + `aria-hidden`) → Task 1. Reduced-motion fallback → Task 2 media query. Out-of-scope items (HeroAwardSlide, sibling timing) → untouched, no task references them. +- **Character count check:** "Hardened. Secure." and "Verified. Secure." are both 17 characters, so `steps(17)` on the type-on/caret carries over unchanged from the original — no retiming needed there. +- **Color values:** `#ff5468` (strike) and `#8892a4` (muted gray) are the exact values already visually approved in the browser-companion mockups during design review, not new arbitrary picks. The existing `.cs-hh-*` rules already hardcode the brand gradient hex values directly (not through `--muted`/`--destructive` tokens, which belong to the separate shadcn light/dark theme system) — these two new values follow that same established local pattern rather than introducing token usage inconsistent with the surrounding code. diff --git a/docs/superpowers/plans/2026-09-01-saas-3d-process-deck.md b/docs/superpowers/plans/2026-09-01-saas-3d-process-deck.md new file mode 100644 index 000000000..5657af9e5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-saas-3d-process-deck.md @@ -0,0 +1,46 @@ +# SaaS 3D Process Deck Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace outlined SaaS diagram containers with a cohesive three-dimensional process deck without changing the verified pipeline’s meaning. + +**Architecture:** Preserve the typed React stage model and responsive render trees. Remove obsolete chrome markup, then implement the 3D system entirely in the co-located CSS module using filled surfaces, pseudo-element faces, occlusion, and offset shadows. + +**Tech Stack:** Next.js 16, React 19 server components, TypeScript strict mode, CSS Modules, Vitest, Playwright visual inspection. + +--- + +### Task 1: Lock the 3D material contract + +**Files:** +- Modify: `apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx` + +- [ ] Add a test that verifies the source, stage, route, mobile surface, and desktop surface rules do not use perimeter borders. +- [ ] Assert that source and stage modules expose filled top/underside pseudo-elements and use offset soft shadows. +- [ ] Assert that the scanner frame uses no border strokes and exposes a filled arch pseudo-element. +- [ ] Run `pnpm --filter @cleanstart/web test -- src/components/sections/saas/SaasShiftLeft.test.tsx` and confirm RED against the current outlined implementation. + +### Task 2: Build the process deck + +**Files:** +- Modify: `apps/web/src/components/sections/saas/SaasVerifiedCore.tsx` +- Modify: `apps/web/src/components/sections/saas/SaasVerifiedCore.module.css` + +- [ ] Remove `SurfaceChrome` and its corner/measurement markup. +- [ ] Replace the outer desktop and mobile panel borders with deep filled bases and directional shadows. +- [ ] Replace the verified route outline with a raised top deck and recessed bottom face. +- [ ] Replace source and stage chamfer shells with borderless material faces, illuminated top edges, lower faces, and soft offset elevation. +- [ ] Remove the icon-port and release-badge borders while preserving rail masking and contrast. +- [ ] Rebuild the scanner frame as a solid U-shaped arch with filled pseudo-element geometry. +- [ ] Run the focused SaaS test and confirm GREEN. + +### Task 3: Visual refinement and verification + +**Files:** +- Inspect: `/industries/saas-container-security` + +- [ ] Capture desktop at 1440 × 900 and mobile at 390 × 844 in one bounded inspection pass. +- [ ] Fix any hierarchy, clipping, overflow, or depth defects in one scoped correction batch and perform at most one confirmation pass. +- [ ] Run the Impeccable detector once on the changed UI targets and evaluate findings in context. +- [ ] Run `pnpm --filter @cleanstart/web lint`, `pnpm --filter @cleanstart/web typecheck`, `pnpm --filter @cleanstart/web test`, and `pnpm --filter @cleanstart/web build`. +- [ ] Request focused code review, address actionable findings, and commit only the SaaS diagram files and these documents. diff --git a/docs/superpowers/plans/2026-09-01-saas-remove-late-route.md b/docs/superpowers/plans/2026-09-01-saas-remove-late-route.md new file mode 100644 index 000000000..300b9b503 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-saas-remove-late-route.md @@ -0,0 +1,41 @@ +# SaaS Late Route Removal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the upper late-review route from the SaaS diagram while preserving the verified-components pipeline for the next concept iteration. + +**Architecture:** Delete the obsolete decorative route from both responsive render trees and remove its unused styling. Keep the verified route’s component structure and CSS intact, changing only the outer desktop surface height and the accessible sequence list. + +**Tech Stack:** Next.js 16, React 19 server components, TypeScript strict mode, CSS Modules, Vitest. + +--- + +### Task 1: Lock the single-route contract + +**Files:** +- Modify: `apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx` + +- [ ] Add a test that rejects `data-late-review-path`, `data-security-review="closed"`, and the late-review accessible label while preserving the verified source, ordered stages, open scanner, and approved release exit. +- [ ] Run `pnpm --filter @cleanstart/web test -- src/components/sections/saas/SaasShiftLeft.test.tsx` and confirm the new assertion fails because the late route still renders. + +### Task 2: Remove the late route + +**Files:** +- Modify: `apps/web/src/components/sections/saas/SaasVerifiedCore.tsx` +- Modify: `apps/web/src/components/sections/saas/SaasVerifiedCore.module.css` +- Modify: `apps/web/src/components/sections/saas/SaasShiftLeft.tsx` + +- [ ] Delete the desktop and mobile late-route render helpers and closed-scanner code paths. +- [ ] Remove the obsolete late-route CSS and return animation from normal and reduced-motion states. +- [ ] Collapse the desktop surface minimum height around the unchanged verified route. +- [ ] Remove the obsolete screen-reader-only late-review sequence. +- [ ] Rerun the focused test and confirm it passes. + +### Task 3: Verify and review + +**Files:** +- Inspect: `/industries/saas-container-security` + +- [ ] Visually inspect the section at 1440 × 900 and a representative mobile viewport. +- [ ] Run `pnpm --filter @cleanstart/web lint`, `pnpm --filter @cleanstart/web typecheck`, `pnpm --filter @cleanstart/web test`, and `pnpm --filter @cleanstart/web build`. +- [ ] Review the scoped diff, request code review, address actionable findings, and commit only the SaaS diagram files and these two documents. diff --git a/docs/superpowers/plans/2026-09-01-saas-verified-core.md b/docs/superpowers/plans/2026-09-01-saas-verified-core.md new file mode 100644 index 000000000..e2cf41929 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-saas-verified-core.md @@ -0,0 +1,399 @@ +# SaaS Verified Core Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the confusing SaaS cleanroom reactor with the approved Verified Core comparison while preserving the supplied copy and both source sequences. + +**Architecture:** Keep the section shell and exact copy in `SaasShiftLeft.tsx`. Move the decorative desktop/mobile geometry into a focused `SaasVerifiedCore.tsx` component and a co-located CSS module. Express the source sequences once as accessible ordered lists; keep duplicated visual labels hidden from assistive technology. + +**Tech Stack:** Next.js 16, React 19 server components, TypeScript strict mode, CSS Modules, SVG, Vitest, React server rendering. + +--- + +## File Map + +- Create `apps/web/src/components/sections/saas/SaasVerifiedCore.tsx`: verified-first and late-review desktop/mobile diagram structure. +- Create `apps/web/src/components/sections/saas/SaasVerifiedCore.module.css`: responsive geometry, color, motion, and reduced-motion state. +- Modify `apps/web/src/components/sections/saas/SaasShiftLeft.tsx`: replace the old reactor import and expose both exact source sequences. +- Modify `apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx`: replace reactor assertions with the approved diagram contract. +- Delete `apps/web/src/components/sections/saas/SaasCleanroomReactor.tsx` and `apps/web/src/components/sections/saas/SaasCleanroomReactor.module.css`: remove the superseded visual. + +### Task 1: Lock the new diagram contract with a failing test + +**Files:** +- Modify: `apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx` + +- [ ] **Step 1: Preserve the exact-copy assertion and replace the reactor-specific assertions** + +Add assertions for these concrete contracts: + +```tsx +it('renders structurally distinct desktop and mobile Verified Core diagrams', () => { + const html = renderSection(); + + expect(html).toContain('data-verified-core="desktop"'); + expect(html).toContain('data-verified-core="mobile"'); + expect(html).not.toContain('data-cleanroom-reactor='); + expect(html).not.toContain('data-reactor-chamber='); +}); + +it('carries one verified core through every delivery stage', () => { + const html = renderSection(); + + expect(html).toContain('data-verified-source="verified-components"'); + expect(html).toContain('data-trust-ribbon="continuous"'); + expect(html).toMatch( + /data-core-stage="code"[\s\S]*data-core-stage="build"[\s\S]*data-core-stage="test"[\s\S]*data-core-stage="deploy"/, + ); +}); + +it('contrasts an open release with a closed late-review return', () => { + const html = renderSection(); + + expect(html).toContain('data-security-review="open"'); + expect(html).toContain('data-release-exit="approved"'); + expect(html).toContain('data-security-review="closed"'); + expect(html).toContain('data-late-review-path="return"'); +}); + +it('exposes both exact source sequences once and hides duplicate visuals', () => { + const html = renderSection(); + + expect(html.match(/aria-label="Code, Build, Test, Deploy, Security Review"/g)).toHaveLength(1); + expect( + html.match(/aria-label="Verified Components, Code, Build, Test, Deploy, Security Review"/g), + ).toHaveLength(1); + expect(html).toMatch(/data-verified-core="desktop"[^>]*aria-hidden="true"/); + expect(html).toMatch(/data-verified-core="mobile"[^>]*aria-hidden="true"/); + expect(html).toContain('preserveAspectRatio="xMidYMid meet"'); + expect(html).not.toMatch(/preserveAspectRatio=.none./); +}); +``` + +- [ ] **Step 2: Add the reduced-motion stylesheet contract** + +```tsx +it('provides a complete reduced-motion state for the Verified Core', () => { + const stylesheetPath = new URL('./SaasVerifiedCore.module.css', import.meta.url); + const stylesheet = existsSync(stylesheetPath) ? readFileSync(stylesheetPath, 'utf8') : ''; + + expect(stylesheet).toContain('@media (prefers-reduced-motion: reduce)'); + expect(stylesheet).toMatch(/\.verifiedPulse[\s\S]*animation: none !important/); + expect(stylesheet).toMatch(/\.returnPulse[\s\S]*animation: none !important/); + expect(stylesheet).toMatch(/\.scannerBeam[\s\S]*animation: none !important/); +}); +``` + +- [ ] **Step 3: Run the focused test and verify RED** + +Run: + +```bash +pnpm --filter @cleanstart/web test -- src/components/sections/saas/SaasShiftLeft.test.tsx +``` + +Expected: FAIL because the old component lacks `data-verified-core`, `data-trust-ribbon`, open/closed scanner, and release/return contracts. + +### Task 2: Implement the Verified Core component + +**Files:** +- Create: `apps/web/src/components/sections/saas/SaasVerifiedCore.tsx` +- Create: `apps/web/src/components/sections/saas/SaasVerifiedCore.module.css` + +- [ ] **Step 1: Create typed, static stage data and focused render helpers** + +Use this public and internal shape: + +```tsx +type DeliveryStageId = 'code' | 'build' | 'test' | 'deploy'; + +interface DeliveryStage { + readonly id: DeliveryStageId; + readonly label: 'Code' | 'Build' | 'Test' | 'Deploy'; +} + +const DELIVERY_STAGES: readonly DeliveryStage[] = [ + { id: 'code', label: 'Code' }, + { id: 'build', label: 'Build' }, + { id: 'test', label: 'Test' }, + { id: 'deploy', label: 'Deploy' }, +]; + +export function SaasVerifiedCore(): React.ReactElement { + return ( +
        + + +
        + ); +} +``` + +Implement `DesktopVerifiedCore`, `LateReviewRoute`, `VerifiedRoute`, `StageHousing`, `SecurityScanner`, `MobileVerifiedCore`, `MobileLateRoute`, and `MobileVerifiedRoute`. Each helper returns `React.ReactElement`; no client state or runtime input is introduced. + +- [ ] **Step 2: Build the desktop surface** + +The desktop DOM must include: + +```tsx + +``` + +The late route uses the four stage labels, a closed `SecurityScanner`, and an SVG return curve marked `data-late-review-path="return"`. The verified route uses a source marked `data-verified-source="verified-components"`, a continuous rail marked `data-trust-ribbon="continuous"`, four ordered `data-core-stage` housings, an open scanner, and `data-release-exit="approved"`. + +- [ ] **Step 3: Build the dedicated mobile composition** + +Render a compact late-review card followed by a dominant vertical verified route: + +```tsx + +``` + +The mobile route duplicates only decorative labels. Its structure must be independent of the desktop SVG and introduce no horizontal scrolling. + +- [ ] **Step 4: Implement the CSS visual system** + +Use the approved palette and responsibilities: + +```css +.stage { width: 100%; max-width: 1120px; margin-inline: auto; } +.desktopSurface { display: none; } +.mobileSurface { display: grid; gap: 18px; } + +@media (min-width: 1024px) { + .desktopSurface { display: block; } + .mobileSurface { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .verifiedPulse, + .returnPulse, + .scannerBeam { + animation: none !important; + } +} +``` + +Complete the surface, chamfered housings, embedded core, scanner, release, return, technical grid, and responsive mobile styles in the same module. Visible labels consume `--fs-*` and project font tokens; coral is restricted to the closed scanner/return, and cyan-to-mint is restricted to the verified core/open scanner. + +### Task 3: Integrate the new component and remove the superseded reactor + +**Files:** +- Modify: `apps/web/src/components/sections/saas/SaasShiftLeft.tsx` +- Delete: `apps/web/src/components/sections/saas/SaasCleanroomReactor.tsx` +- Delete: `apps/web/src/components/sections/saas/SaasCleanroomReactor.module.css` + +- [ ] **Step 1: Replace the import and render call** + +```tsx +import { SaasVerifiedCore } from './SaasVerifiedCore'; +``` + +Replace `` with ``. + +- [ ] **Step 2: Expose both exact source sequences** + +```tsx +const LATE_REVIEW_DESCRIPTION = 'Code, Build, Test, Deploy, Security Review' as const; +const VERIFIED_FIRST_DESCRIPTION = + 'Verified Components, Code, Build, Test, Deploy, Security Review' as const; +``` + +Render one visually hidden ordered list for each sequence, using the corresponding value as its `aria-label`. + +- [ ] **Step 3: Delete the two old reactor files** + +Remove only the superseded SaaS-scoped component and stylesheet. Confirm there are no remaining `SaasCleanroomReactor` imports. + +### Task 4: Complete the red-green-refactor cycle + +**Files:** +- Test: `apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx` + +- [ ] **Step 1: Run the focused test and verify GREEN** + +```bash +pnpm --filter @cleanstart/web test -- src/components/sections/saas/SaasShiftLeft.test.tsx +``` + +Expected: all `SaasShiftLeft` tests pass. + +- [ ] **Step 2: Run the complete web test suite** + +```bash +pnpm --filter @cleanstart/web test +``` + +Expected: zero failing tests. + +- [ ] **Step 3: Refactor only while the focused test remains green** + +Remove duplicated geometry or unclear names discovered during review, then rerun the focused test. Do not change other sections or shared tokens. + +### Task 5: Visual and responsive verification + +**Files:** +- Inspect: `/industries/saas-container-security` + +- [ ] **Step 1: Start the web app and capture the target section at 1440 × 900** + +Run the existing app on port 3001, force reveal wrappers visible, and shift the complete document so the section sits inside the viewport. Confirm the source capsule, embedded core, stage labels, open scanner, late closed scanner, and return curve are immediately legible. + +- [ ] **Step 2: Inspect a representative mobile viewport** + +Confirm the dedicated mobile composition has no horizontal overflow, maintains stage order, and preserves readable labels. + +- [ ] **Step 3: Apply only SaaS-scoped visual corrections** + +Adjust `SaasVerifiedCore.tsx` and its CSS module if needed. Do not modify global styles, shared layout components, or unrelated pages. + +### Task 6: Baseline gates and final review + +**Files:** +- Review all changed SaaS files and the implementation plan/spec. + +- [ ] **Step 1: Run mandatory package checks** + +```bash +pnpm --filter @cleanstart/web lint +pnpm --filter @cleanstart/web typecheck +pnpm --filter @cleanstart/web build +``` + +Expected: all three commands exit successfully. + +- [ ] **Step 2: Run the code-review checklist** + +Check strict typing, exported return types, semantics, reduced motion, color-independent meaning, label contrast, no external assets, no stale reactor references, no unrelated changes, and no `preserveAspectRatio="none"`. + +- [ ] **Step 3: Review the final diff** + +```bash +git diff --check +git status --short +git diff -- apps/web/src/components/sections/saas docs/superpowers/plans/2026-09-01-saas-verified-core.md +``` + +Expected: no whitespace errors and only the approved SaaS implementation plus its plan. + +### Task 7: Add distinct delivery-stage icons + +**Files:** +- Modify: `apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx` +- Modify: `apps/web/src/components/sections/saas/SaasVerifiedCore.tsx` +- Modify: `apps/web/src/components/sections/saas/SaasVerifiedCore.module.css` + +- [ ] **Step 1: Write a failing structural test** + +Assert that the rendered primary route contains a unique icon marker for every typed delivery-stage ID: + +```tsx +it('gives every delivery stage its own recognizable icon', () => { + const html = renderSection(); + + for (const stage of ['code', 'build', 'test', 'deploy']) { + expect(html).toContain(`data-stage-icon="${stage}"`); + } +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +```bash +pnpm --filter @cleanstart/web test -- src/components/sections/saas/SaasShiftLeft.test.tsx +``` + +Expected: FAIL because the stage housings currently render the same core node without `data-stage-icon` markers. + +- [ ] **Step 3: Add a typed icon renderer** + +Add a focused `StageIcon` helper that accepts `DeliveryStageId` and returns one decorative inline SVG per stage: code brackets, an isometric package, a check-in-ring, and an upward release arrow. Mark each SVG with `data-stage-icon={stage}` and keep the parent diagram `aria-hidden`. + +- [ ] **Step 4: Integrate icons without breaking the verified ribbon** + +Replace the generic core node inside `StageHousing` with ``. Preserve `coreLine` behind the icon, and style every icon with one cyan-to-mint monoline treatment, identical dimensions, round joins, and a restrained glow. + +- [ ] **Step 5: Verify GREEN and run package gates** + +```bash +pnpm --filter @cleanstart/web test -- src/components/sections/saas/SaasShiftLeft.test.tsx +pnpm --filter @cleanstart/web test +pnpm --filter @cleanstart/web lint +pnpm --filter @cleanstart/web typecheck +pnpm --filter @cleanstart/web build +``` + +Expected: all focused and package checks pass, with Code, Build, Test, and Deploy remaining legible at desktop and mobile sizes. + +### Task 8: Layer the provenance rail behind stage icons + +**Files:** +- Modify: `apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx` +- Modify: `apps/web/src/components/sections/saas/SaasVerifiedCore.module.css` + +- [ ] **Step 1: Write a failing paint-order test** + +Read the CSS module and assert that `.coreLine` uses `z-index: 0`, `.coreWindow::after` uses `z-index: 1`, and `.stageIcon` uses `z-index: 2`. + +- [ ] **Step 2: Run the focused test and verify RED** + +```bash +pnpm --filter @cleanstart/web test -- src/components/sections/saas/SaasShiftLeft.test.tsx +``` + +Expected: FAIL because the rail currently has no explicit layer, there is no inner icon-plate mask, and the icon uses `z-index: 1`. + +- [ ] **Step 3: Implement the three-layer paint order** + +Set the rail to layer 0. Add a `coreWindow::after` pseudo-element inset inside the circular border with an opaque navy radial background at layer 1. Move `.stageIcon` to layer 2. The mask must cover only the circle, leaving the rail visible on either side. + +- [ ] **Step 4: Verify GREEN and inspect both responsive diagrams** + +Run the focused test, then inspect `/industries/saas-container-security` at 1440 × 900 and 390 × 844. Confirm the rail disappears beneath every circular plate without hiding or shrinking any icon. + +- [ ] **Step 5: Run package gates** + +```bash +pnpm --filter @cleanstart/web test +pnpm --filter @cleanstart/web lint +pnpm --filter @cleanstart/web typecheck +pnpm --filter @cleanstart/web build +``` + +Expected: all checks pass and the production build generates the complete site. + +### Task 9: Mask the scanner rail and fade the desktop grid + +**Files:** +- Modify: `apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx` +- Modify: `apps/web/src/components/sections/saas/SaasVerifiedCore.module.css` + +- [ ] **Step 1: Write failing scanner and grid-style tests** + +Assert that `.scannerFrame` uses a fully opaque background. Assert that the desktop surface no longer paints grid lines directly in its main background and that `.desktopSurface::after` owns a lower-opacity two-axis grid with both standard and WebKit radial masks. + +- [ ] **Step 2: Run the focused test and verify RED** + +```bash +pnpm --filter @cleanstart/web test -- src/components/sections/saas/SaasShiftLeft.test.tsx +``` + +Expected: FAIL because the scanner background is translucent and the grid is currently painted uniformly in `.desktopSurface`. + +- [ ] **Step 3: Make the scanner body opaque** + +Replace the translucent scanner background with an opaque navy gradient. Preserve the open scanner geometry, beam, state icon, barrier, glow, and approved release exit. + +- [ ] **Step 4: Move the desktop grid to a faded overlay** + +Remove the two grid gradients from `.desktopSurface`. Add them to `.desktopSurface::after` at lower alpha, set their 42px cell size, and apply matching `mask-image` and `-webkit-mask-image` radial gradients that feather the grid to transparency near every edge. Keep the overlay non-interactive and beneath `.routeStack`. + +- [ ] **Step 5: Verify both breakpoints and package gates** + +Run the focused and complete test suites, inspect desktop and mobile, then run lint, typecheck, and production build. Confirm the rail is hidden inside the scanner, the grid fades without affecting labels, and no mobile overflow is introduced. diff --git a/docs/superpowers/specs/2026-09-01-hero-verified-hardened-design.md b/docs/superpowers/specs/2026-09-01-hero-verified-hardened-design.md new file mode 100644 index 000000000..435f74dfd --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-hero-verified-hardened-design.md @@ -0,0 +1,50 @@ +# Home hero H1: "Verified" correction over struck-through "Hardened" + +## Summary + +Extend the homepage hero H1 (`apps/web/src/components/sections/home/HeroHeading.tsx`) from its current two-part text into a three-line, one-shot animated sequence that stakes out a competitive position: the industry sells "hardened," CleanStart sells "verified." + +## Final copy and layout + +Three left-aligned lines, matching the H1's existing left alignment and typography tokens: + +``` +Verified +Hardened. Secure. +Built for the AI Era. +``` + +- Line 1, "Verified": the brand cyan→purple gradient treatment (currently used on "Verified. Secure."), on its own line, directly above line 2. No overlap with any other text. +- Line 2, "Hardened. Secure.": both words type on together as one unit (same as today's "Verified. Secure." type-on) — "Secure." is not pre-rendered/static, it reveals with the same clip-path animation as "Hardened." Only after both have typed on does "Hardened." get a red strikethrough drawn across it and render in a muted gray. "Secure." carries the SAME brand gradient as "Verified" (line 1) — the two affirmative claims read as one visual pair, while struck-through "Hardened" stands apart as the rejected term. +- Line 3, "Built for the AI Era.": unchanged from today — plain white, focus-settle entrance. + +This is a straight three-line heading (real DOM lines via `display: block` spans), not an absolutely-positioned overlay stamp — confirmed against a reference screenshot during design review. + +## Animation sequence ("reject, then correct") + +One-shot on page load, matching the existing hero's play-once philosophy (no looping): + +1. **0.15s–1.05s** — "Hardened. Secure." types on together as one unit via the existing stepped clip-path reveal (reuse `cs-hh-reveal`/caret exactly as today, just re-targeted from "Verified. Secure." to "Hardened. Secure."). "Secure." types on already carrying the brand gradient; "Hardened." types on in the same gradient too at this stage — it only switches to muted gray once struck (step 2), so the reveal itself still reads as one unified gradient phrase. +2. **1.05s–1.4s** — Red strikethrough draws left-to-right across "Hardened." only (`scaleX` on a `::after` bar), immediately after typing finishes; "Hardened." simultaneously desaturates from the gradient to muted gray as the strike lands — reads as a decisive cut, not a paused beat. +3. **1.4s–1.95s** — "Verified" (line 1) rises into view: blur→sharp focus snap + fade + slight upward settle, using the same brand gradient as "Secure." Continuous gradient shine starts at 1.95s and loops across both "Verified" and "Secure." together (reuse `cs-hh-shine`), same as today's shine. +4. **~1.7s–2.3s** — "Built for the AI Era." (line 3) focus-settles in, overlapping the tail of step 3, same easing/style as today's `cs-hh-focus`. + +Total settle lands ~2.3s, close to the current hero's documented ~2.2s finish — the H1 was already the longest-running hero element (other hero elements — lead paragraph, CTAs, side panel — finish by ~1.15s per `HeroProductSlide.tsx`), so the budget is not being blown out further. + +## Accessibility + +- The struck-through "Hardened" is a visual/rhetorical device, not part of the coherent accessible name. The H1 gets `aria-label="Verified. Secure. Built for the AI Era."` and the visual markup (all three lines) is wrapped in a single `aria-hidden="true"` container, so screen readers hear the clean original phrase, not "Verified Hardened Secure Built for the AI Era." +- `prefers-reduced-motion: reduce` shows the final static state immediately: "Verified" visible, "Hardened." shown with a static (non-animated) strikethrough, "Secure." and "Built for the AI Era." both visible, no caret, no shine — extending the existing reduced-motion block in `globals.css`. +- Full text stays present in server-rendered HTML (no client JS): the H1 remains the LCP element, per the existing file's documented constraint. + +## Implementation notes + +- Server component stays a server component (no `"use client"`) — animation is pure CSS, same as today. +- New/renamed CSS classes live alongside the existing `.cs-hh-*` rules in `globals.css` (~line 1471+): keep the existing `.cs-hh-type`/`.cs-hh-reveal`/`.cs-hh-snap`/`.cs-hh-shine`/caret rules but retarget their content to "Hardened. Secure.", add a new strike-through rule/keyframe, and a new "Verified" rise-in rule/keyframe reusing the same blur→sharp philosophy as `.cs-hh-phrase`'s `cs-hh-focus`. +- Typography: same `--fs-display-home` / `--fs-display-ls` tokens, `line-height: 1.05` — no new tokens introduced, per `apps/web/docs/TYPOGRAPHY-SYSTEM.md` conventions. +- No JS/client-side animation library — everything is CSS keyframes, consistent with the file's existing "zero client JS for this effect" constraint. + +## Out of scope + +- No changes to `HeroAwardSlide.tsx` (a second, currently-unused carousel slide with its own plain heading) — only `HeroHeading.tsx`, used by the active product slide, is touched. +- No changes to hero timing/delays for sibling elements (lead paragraph, CTAs, side panel). diff --git a/docs/superpowers/specs/2026-09-01-saas-3d-process-deck-design.md b/docs/superpowers/specs/2026-09-01-saas-3d-process-deck-design.md new file mode 100644 index 000000000..d286c66d6 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-saas-3d-process-deck-design.md @@ -0,0 +1,50 @@ +# SaaS Verified Pipeline — 3D Process Deck Design + +## Purpose + +Replace the diagram’s outlined-card appearance with a professional three-dimensional process instrument while preserving its existing meaning and stage order. + +## Approved Direction + +The diagram becomes a machined process deck rather than a row of containers. `Verified Components` enters as a luminous source cartridge. Code, Build, Test, and Deploy sit on raised dark modules with bevelled top planes, recessed undersides, and directional shadows. A continuous cyan-to-mint trust rail passes through illuminated circular ports and exits through a solid Security Review arch. + +Depth is communicated through material shading, occlusion, top-edge specular light, bottom faces, and soft offset shadows. Visible perimeter strokes are removed from the outer surface, route deck, source cartridge, stage modules, icon ports, and release badge. + +## Composition + +- Preserve the existing Verified Components → Code → Build → Test → Deploy → Security Review sequence. +- Preserve the distinct stage icons, animated trust pulse, scanner sweep, approved release state, and reduced-motion fallback. +- Remove the desktop measurement corners and inner route outline. +- Render the outer desktop surface as one deep base with a shadowed lower face, not a bordered panel. +- Render the verified route as a raised deck with a softly lit top plane and recessed lower face. +- Render source and stage modules without chamfer outlines; each uses an opaque face, a visible dark underside, and a narrow specular highlight. +- Rebuild the scanner as a solid U-shaped arch using filled geometry rather than three border strokes. +- Apply the same material language to the vertical mobile composition without introducing horizontal overflow. + +## Visual Language + +- Materials: graphite-blue anodised surfaces with subtle cyan reflections. +- Depth: offset soft shadows and inset shading; no hard block shadows or glass-card effects. +- Source: slightly brighter and wider than process modules, with a mint verification lens. +- Stage modules: same family and elevation, with modest variation from the moving signal rather than separate colours. +- Icon ports: recessed circular lenses with opaque centres that mask the rail. +- Scanner: solid green structural arch with a dark scanning aperture. +- Background grid: retained only as a faint measurement texture on the engineered base, fading at the perimeter. + +## Component Scope + +- `SaasVerifiedCore.tsx`: remove obsolete decorative surface chrome markup and add no new runtime behaviour. +- `SaasVerifiedCore.module.css`: replace container outlines with the 3D material and depth system for desktop and mobile. +- `SaasShiftLeft.test.tsx`: lock the borderless-container and 3D-layer contracts while preserving existing content, sequencing, accessibility, and motion assertions. + +No assets, dependencies, shared styles, typography tokens, marketing copy, navigation, other sections, or CMS code change. + +## Acceptance Criteria + +- The source and four process stages do not read as outlined cards. +- The outer panel and route deck do not use visible perimeter borders or nested outline frames. +- Each source/stage module has a top face, a recessed lower face, and an offset soft shadow. +- The scanner reads as a solid dimensional gate rather than a bordered box. +- The trust rail remains clearly visible between modules and hidden behind each icon port and scanner body. +- Desktop and mobile retain readable labels, stage order, no overflow, and complete reduced-motion states. +- The result feels like one coherent 3D security instrument, not a generic flowchart. diff --git a/docs/superpowers/specs/2026-09-01-saas-remove-late-route-design.md b/docs/superpowers/specs/2026-09-01-saas-remove-late-route-design.md new file mode 100644 index 000000000..63d3e057a --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-saas-remove-late-route-design.md @@ -0,0 +1,33 @@ +# SaaS Verified Pipeline — Late Route Removal Design + +## Purpose + +Prepare the “Move Beyond Shift Left” diagram for a new concept by removing the complete upper late-review pipeline. The existing verified-components pipeline remains the sole route and is not redesigned in this step. + +## Approved Composition + +- Remove the upper Code, Build, Test, Deploy, and closed Security Review route. +- Remove its neutral rail, coral return curve, pulse, stage nodes, and all associated mobile equivalents. +- Retain the lower Verified Components → Code → Build → Test → Deploy → Security Review pipeline unchanged. +- Retain the open scanner, approved release exit, faded technical grid, panel chrome, motion, and reduced-motion behaviour used by the verified route. +- Collapse the desktop control surface to the remaining route so deletion does not leave an empty upper region. +- Expose only the verified-first sequence to assistive technology. + +This is an intentional intermediate state. The future left-side integration concept is out of scope until the user supplies it. + +## Component Scope + +- `SaasVerifiedCore.tsx`: delete late-route desktop/mobile render helpers and closed-scanner branches. +- `SaasVerifiedCore.module.css`: delete late-route styles and animations; reduce the desktop surface height without changing verified-route geometry. +- `SaasShiftLeft.tsx`: remove the obsolete accessible late-review sequence. +- `SaasShiftLeft.test.tsx`: assert that no late-review route or closed scanner is rendered at either breakpoint while the verified route remains complete. + +No shared styles, typography tokens, other sections, navigation, assets, dependencies, or CMS code change. + +## Acceptance Criteria + +- No upper pipeline is rendered on desktop or mobile. +- No late-review return path or closed scanner remains in markup or styles. +- The verified pipeline preserves its source, four ordered stages, distinct icons, open scanner, and approved release exit. +- The desktop panel no longer reserves the removed route’s vertical space. +- The diagram remains responsive, accessible, reduced-motion safe, and production-build clean. diff --git a/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md b/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md new file mode 100644 index 000000000..088cfcddf --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md @@ -0,0 +1,120 @@ +# SaaS “Move Beyond Shift Left” — Verified Core Design + +## Purpose + +Replace the current cleanroom-reactor illustration with a more immediate visual explanation of the supplied statement: security begins in the software components developers use and remains embedded throughout delivery, while security introduced only after deployment creates rejection and rework. + +The section retains the supplied content exactly: + +- Heading: `Move Beyond Shift Left` +- Supporting paragraph: `Modern applications require security to be built into the software components developers use, not added after applications are created.` +- Late-review sequence: `Code → Build → Test → Deploy → Security Review` +- Verified-first sequence: `Verified Components → Code → Build → Test → Deploy → Security Review` + +No new marketing claims, comparison headings, or explanatory captions are added. + +## Approved Direction: The Verified Core + +The visual becomes one asymmetric delivery surface containing a dominant verified-first route and a smaller late-review reference route. + +The verified-first route begins with an oversized `Verified Components` source capsule on the far left. A continuous cyan-to-mint provenance ribbon emerges from the capsule and travels through transparent Code, Build, Test, and Deploy stage housings. The same luminous core remains visible inside every housing, making built-in security a material property of the pipeline rather than a separate checkpoint. + +At the right edge, `Security Review` becomes a scanning arch instead of another process card. The verified core passes through the arch and resolves into an open release arrow with a check mark. + +The late-review route sits above the primary route at reduced visual weight. It begins directly at Code, progresses through Build, Test, and Deploy on a neutral rail, and meets a closed coral `Security Review` scanner. A permanent return curve folds back to Code. The blocked scanner and return geometry communicate late discovery and rework without additional text. + +## Composition and Hierarchy + +- Preserve the centered section heading and supporting paragraph above the visual. +- Place both routes inside one wide obsidian control surface with a restrained border, inner highlight, and subtle technical grid. +- Give the verified-first route approximately 70 percent of the visual emphasis through scale, contrast, and vertical space. +- Keep the late-review route compact and subdued so it provides context without competing with the recommended path. +- Align Code, Build, Test, Deploy, and Security Review across both routes wherever the geometry permits. +- Remove the reactor chamber, stacked floors, enclosing review perimeter, floating `Unverified Components` object, and intake/rejection hexagon comparison. +- Use no external imagery. The diagram is built from semantic HTML, CSS, and decorative SVG geometry. + +## Visual Language + +- Background: near-black navy with a controlled cobalt lift behind the primary route. Purple remains atmospheric and does not dominate the diagram. +- Verified state: cyan-to-mint provenance ribbon, illuminated source capsule, embedded stage cores, open scanner, and release check. +- Late-review state: cool slate rail and stage housings, with coral reserved for the closed scanner and return signal. +- Stage housings: transparent chamfered shells that reveal the route passing through their centers. They must not read as generic rounded cards. +- Stage iconography: each primary delivery stage uses a distinct monoline symbol inside its core window—code brackets for Code, an isometric package for Build, a check-in-ring for Test, and an upward release arrow for Deploy. The symbols share one cyan line treatment so they improve recognition without fragmenting the continuous verified route. +- Stage layering: the continuous provenance rail passes behind each circular icon plate. An opaque inner plate masks the rail within the circle, while the icon remains on the foreground layer; the rail stays visible only on either side of the plate. +- Scanner: two vertical posts with a scanning aperture. The verified state is open; the late-review state is visibly closed. +- Scanner layering: the provenance rail passes behind the scanner frame and is fully hidden within its body, then reappears only at the approved release exit. +- Texture: fine grid, coordinate ticks, and restrained bloom provide depth without obscuring labels or flow. Desktop grid lines remain low-opacity and fade toward every panel edge instead of covering the control surface uniformly. +- Typography: all visible text uses the canonical `--fs-*`, `--font-display`, and `--font-sans` role tokens. + +## Motion + +Meaning must be complete in the settled frame. Motion only reinforces direction: + +1. A single restrained pulse travels from Verified Components through Code, Build, Test, and Deploy. +2. The open Security Review scanner sweeps once as the pulse passes and the release check brightens. +3. A short coral pulse travels backward along the late-review return curve. + +Animations share one slow CSS timeline and avoid continuous high-frequency movement. Under `prefers-reduced-motion: reduce`, all translation and scanning stop while the embedded core, open release, closed scanner, and return curve remain fully visible. + +## Responsive Behaviour + +### Desktop and tablet + +- Render one shared comparison surface without horizontal scrolling. +- Preserve the primary left-to-right route and the smaller late-review route above it. +- Keep visible labels as HTML when SVG scaling would reduce them below the typography system’s readable floor. +- Preserve SVG geometry with `preserveAspectRatio="xMidYMid meet"`. + +### Mobile + +- Replace the desktop composition with a dedicated vertical chain-of-trust layout. +- Show the late-review route first as a compact subdued process strip ending in a closed scanner and short return curve. +- Show the verified-first route as the dominant vertical composition: source capsule, four stacked stage housings with one continuous core, then an open Security Review scanner and release check. +- Use canonical typography tokens and introduce no horizontal scrolling. + +## Accessibility + +- Keep visual SVG geometry and animation `aria-hidden`. +- Expose the two exact source sequences once through visually hidden ordered content. +- Do not rely on color alone: the late path is blocked and looped; the verified path is continuous and exits through an open scanner. +- Ensure visible labels meet contrast requirements against their settled backgrounds. + +## Component Architecture + +- `SaasShiftLeft.tsx`: section shell, exact supplied copy, accessible descriptions, and the diagram entry point. +- `SaasVerifiedCore.tsx`: desktop and mobile diagram structure plus focused presentational helpers. +- `SaasVerifiedCore.module.css`: stage surface, responsive layout, illumination, scanner, pulse, return, and reduced-motion styles. +- `SaasShiftLeft.test.tsx`: exact-copy, sequence, geometry-contract, responsive, accessibility, and reduced-motion assertions. +- Remove the superseded `SaasCleanroomReactor.tsx` and `SaasCleanroomReactor.module.css` after the replacement passes its tests. + +No shared tokens, global CSS, navigation, other pages, CMS code, dependencies, or configuration change. + +## Testing and Verification + +Automated tests must prove: + +- The heading and supporting paragraph remain exact. +- Both source-document sequences are exposed exactly once to assistive technology. +- The old cleanroom-reactor geometry no longer renders. +- Verified Components is the source of one continuous core through Code, Build, Test, and Deploy. +- The verified Security Review scanner is open and followed by an explicit release exit. +- The late-review scanner is closed and connected to a return path toward Code. +- Desktop and mobile visuals are decorative and structurally distinct. +- Reduced-motion styles preserve a complete static diagram. +- No SVG uses `preserveAspectRatio="none"`. + +Verification requires the focused SaaS test, the web package test suite, lint, typecheck, production build, and visual inspection at 1440 × 900. A representative mobile viewport is also checked because its composition is structurally different. + +## Acceptance Criteria + +- A viewer can extract the contrast in a static frame without reading new explanatory copy. +- Verified Components is the unmistakable leftmost source of the primary route. +- The same verified core is visibly embedded inside every delivery stage. +- Code, Build, Test, and Deploy are distinguishable by both label and unique stage icon. +- The horizontal provenance rail never cuts through a stage glyph or its circular plate. +- The provenance rail is not visible through the Security Review scanner body. +- Desktop background grid lines are subdued and feather to transparency at the panel perimeter. +- The late-review route terminates in a closed scanner and visibly returns toward Code. +- The verified route passes through an open scanner and exits as a successful release. +- The design feels like a distinctive chain-of-trust instrument rather than a generic flowchart or metaphorical reactor. +- The section remains responsive, accessible, reduced-motion safe, and production-build clean. diff --git a/docs/web/WEB-PAGES.md b/docs/web/WEB-PAGES.md index 814badf06..0a57d5865 100644 --- a/docs/web/WEB-PAGES.md +++ b/docs/web/WEB-PAGES.md @@ -70,9 +70,9 @@ page slugs, categories, types, and build status across the dev journey. | 5 | Vulnerability Remediation | `/vulnerability-remediation` | Static | ✅ | All 7 sections built | | 9 | For CISO | `/for-ciso` | Static | ✅ | All 8 sections built (farheen integration 2026-05-20) | | 10 | For Developers | `/for-developers` | Static | ✅ | Route at `src/app/for-developers/`. Linked from the homepage AudienceTabs and the nav (`nav-config.ts`). | -| 12 | ROI Calculator | `/roi-calculator` | Static | 🚧 | Interactive Operational Impact simulator (light theme). Client `RoiSimulator` + isolated `model.ts` engine (v2 continuous log-scaled scoring). Sections: Hero, Simulator, How-it's-calculated, Footer CTA. Nav leaf added `built:false` under Solutions › Capability — flip to live once verified. | +| 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-container-security` | 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. | -| 14 | SaaS | `/industries/saas-container-security` | Static | 🚧 | Title, description and H1 are the SEO team's, applied verbatim. **Renamed from `/saas` 2026-08-31**; that path returned 404 in production, so no redirect was needed (unlike its sibling, which resolved). `noindex,nofollow`, out of the sitemap and **not** nav-linked pending copy approval (the Solutions › By industry row is commented out in `nav-config.ts`, ready to restore). Breadcrumb, `JsonLdGraph` and `pageRegistry` row (id=43) in place, so it emits the same graph as its sibling — to launch, drop the two flags and add the path to `STATIC_ROUTES`. | +| 14 | Modern Applications (SaaS) | `/industries/modern-applications` | Static | ✅ | Title ("Modern Application Security \| CleanStart") and description are the SEO team's, applied verbatim; the H1 is the client's. Nav label "Modern Applications" (Solutions › By industry). 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/modern-applications` in prod or the WebPage node drops out. | | 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`). | --- @@ -84,7 +84,7 @@ page slugs, categories, types, and build status across the dev journey. | 6 | CleanSight | `/cleansight` | Static | ✅ | All 8 sections built | | 7 | CleanStart SBOM | `/software-bill-materials` | Static | ✅ | All 4 sections built | | 8 | CleanStart Images | `/cleanstart-images` | Static | ✅ | All 5 sections built (Hero, Browse, EasyStart, UVP, Environment) | -| 8b | CleanStart Platform | `/cleanstart-platform` | Static | ✅ | Route at `src/app/cleanstart-platform/`. Platform overview ("AI-native trust architecture, source to runtime"); title "Inside the CleanStart Platform". Linked from nav-config (`network` icon). In sitemap STATIC_ROUTES. (Entity distinct from `/cleanstart-images`, which titles itself "CleanStart Images" — the prior brand-title collision is resolved.) | +| 8b | CleanStart Platform | `/cleanstart-platform` | Static | ❌ removed | **Deleted 2026-09-02** — route, `cleanstart-platform` section components and image assets removed. The page was never finished: it shipped `noindex`, absent from `nav-config.ts` and de-listed from the sitemap, so nothing was de-ranked and no redirect was seeded. It did resolve publicly and was advertised in `public/llms.txt` (entry removed), so register a 301 in the CMS `redirects` collection if the bare URL is still being hit. `cta-cube-textured.webp` moved to `public/images/teams/` — the Teams CTA was the only other consumer. Recover the whole page from git history (last built state: commit before this deletion) when it is rebuilt. | | 8c | Clean Libraries | `/clean-libraries` | Static | ✅ | Built 2026-06-17 from Figma 1512:988. 4 sections (Hero, Dependency-Risk cards, Invisible-Pipeline diagram, Built-Into-Workflow cards) + Govern-Every-Dependency CTA. Linked from Products nav (`folder` icon) and from the Pricing "Clean Libraries" offering. | --- @@ -96,7 +96,7 @@ the segment is reserved for this purpose and has no listing route of its own. | # | Page Name | URL Slug | Type | Status | Notes | |---|-----------|----------|------|--------|-------| -| C1 | Docker Hardened Images vs CleanStart | `/compare/cleanstart-vs-docker-hardened-images` | Static | ✅ | Built 2026-07-31 from the "Docker Hardened Images vs CleanStart" copy doc. Sections in `src/components/sections/compare/`, all copy + the 15-row capability matrix + FAQ centralised in `compare-data.ts` (matrix counts and FAQPage JSON-LD both derive from it). Emits BreadcrumbList + FAQPage. In sitemap STATIC_ROUTES. **Deliberately orphaned — not in `nav-config.ts` and no inbound internal links** (decision 2026-08-04); revisit before expecting it to rank. 2026-08-04: hero rebuilt around an inline isometric artifact (`CompareHeroArtifact`); the whole untitled intro run (lead + four questions + three closing paragraphs) reunited in `CompareIntro` on its own tinted band, matching the document, which puts no heading between the title and "At a Glance"; capability table restored to flat (header + 15 rows — the source groups nothing); the "Key takeaway:" label restored from the document. Removed as not-in-document: category bands, takeaway-card footer strings, 01–04 numerals (the source list is `numFmt="bullet"`), and the Docker trademark disclaimer. | +| C1 | Docker Hardened Images vs CleanStart | `/compare/cleanstart-vs-docker-hardened-images` | Static | ✅ | **Rebuilt from scratch 2026-09-02** against the new SEO doc "Docker Hardened Images vs CleanStart - Final" (supersedes the 2026-07-31 build, which was never signed off). Six bands, one per document heading: `CompareHero` (with the `CompareFoundationStacks` inheritance diagram) → `CompareFoundations` → `CompareMatrix` → `CompareBuildFlow` → `CompareDifferentiators` → `CompareFAQ`, plus `CompareCTA` in the footer slot. All copy, the 20-row / 4-group capability matrix and the 8 FAQs live in `compare-data.ts`; the matrix counts and the FAQPage JSON-LD both derive from it. Emits BreadcrumbList + FAQPage. Outline: 1 H1, 6 H2 (5 document sections + the CTA), 3 document H3 + 8 FAQ H3. Meta title/description/URL are the document's. The matrix is one `` that reflows to labelled cards below `lg` (no second mobile copy) with a sticky column head. **Still `noindex,nofollow` and de-listed from `app/sitemap.ts` pending sign-off** — drop both directives and re-add the sitemap entry together. **Deliberately orphaned — not in `nav-config.ts` and no inbound internal links** (decision 2026-08-04); revisit before expecting it to rank. | ---