From a0268b6030d7dedf8acd337851b5fca3537534fa Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 10:56:00 +0530 Subject: [PATCH 01/85] docs(web): specify SaaS verified core redesign --- .../2026-09-01-saas-verified-core-design.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-01-saas-verified-core-design.md 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..3438a17ef --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md @@ -0,0 +1,113 @@ +# 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. +- Scanner: two vertical posts with a scanning aperture. The verified state is open; the late-review state is visibly closed. +- Texture: fine grid, coordinate ticks, and restrained bloom provide depth without obscuring labels or flow. +- 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. +- 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. From fe4f4414d5f5efc3661552a158d755881617944e Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 10:59:16 +0530 Subject: [PATCH 02/85] docs(web): plan SaaS verified core implementation --- .../plans/2026-09-01-saas-verified-core.md | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-01-saas-verified-core.md 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..ed081b3e8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-saas-verified-core.md @@ -0,0 +1,283 @@ +# 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. From 4ea68aab13d3942724438aa818569272828d5e76 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 11:46:32 +0530 Subject: [PATCH 03/85] feat(web): redesign SaaS verified core pipeline --- .../saas/SaasCleanroomReactor.module.css | 485 ----------- .../sections/saas/SaasCleanroomReactor.tsx | 580 ------------- .../sections/saas/SaasShiftLeft.test.tsx | 96 +- .../sections/saas/SaasShiftLeft.tsx | 17 +- .../sections/saas/SaasVerifiedCore.module.css | 821 ++++++++++++++++++ .../sections/saas/SaasVerifiedCore.tsx | 287 ++++++ 6 files changed, 1174 insertions(+), 1112 deletions(-) delete mode 100644 apps/web/src/components/sections/saas/SaasCleanroomReactor.module.css delete mode 100644 apps/web/src/components/sections/saas/SaasCleanroomReactor.tsx create mode 100644 apps/web/src/components/sections/saas/SaasVerifiedCore.module.css create mode 100644 apps/web/src/components/sections/saas/SaasVerifiedCore.tsx 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/SaasShiftLeft.test.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx index cd1317fd2..edd8d4471 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx @@ -26,72 +26,82 @@ describe('SaasShiftLeft', () => { ); }); - it('replaces the two-lane release gate with desktop and mobile cleanroom reactors', () => { + it('renders structurally distinct desktop and mobile Verified Core diagrams', () => { 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 mobile = html.slice(html.indexOf('data-verified-core="mobile"')); + + 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='); + expect(html).not.toContain('Unverified Components'); + expect(mobile).toContain('data-late-review-path="return"'); + expect(mobile).toContain('data-verified-source="verified-components"'); + expect(mobile).toMatch( + /data-core-stage="code"[\s\S]*data-core-stage="build"[\s\S]*data-core-stage="test"[\s\S]*data-core-stage="deploy"/, + ); + expect(mobile).toContain('data-security-review="closed"'); + expect(mobile).toContain('data-security-review="open"'); + expect(mobile).toContain('data-release-exit="approved"'); }); - it('places every application stage inside one reactor chamber', () => { + it('carries one verified core through every delivery stage', () => { const html = renderSection(); - expect(html).toContain('data-reactor-chamber="application"'); + expect(html).toContain('data-verified-source="verified-components"'); + expect(html).toContain('data-trust-ribbon="continuous"'); 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"/, + /data-core-stage="code"[\s\S]*data-core-stage="build"[\s\S]*data-core-stage="test"[\s\S]*data-core-stage="deploy"/, ); }); - it('makes verified components the source, security review the perimeter, and late review external', () => { + it('contrasts an open release with a closed late-review return', () => { 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); - } + expect(html).toContain('data-security-review="open"'); + expect(html).toContain('data-release-exit="approved"'); + expect(html).toContain('data-release-arrow="forward"'); + expect(html).toContain('data-security-review="closed"'); + expect(html).toContain('data-late-review-path="return"'); }); - it('keeps both visuals decorative and exposes one accessible process description', () => { + it('exposes both exact source sequences once and hides duplicate visuals', () => { 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="Code, Build, Test, Deploy, Security Review"/g)).toHaveLength( + 1, + ); expect( - html.match(/aria-label="Verified Components, Code, Build, Test, Deploy, Security Review"/g), + 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./); }); - it('provides a complete reduced-motion state for the reactor', () => { - const stylesheetPath = new URL('./SaasCleanroomReactor.module.css', import.meta.url); + 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(/\.sourcePulse[\s\S]*animation: none !important/); - expect(stylesheet).toMatch(/\.layerPlate[\s\S]*opacity: 1 !important/); - expect(stylesheet).toMatch(/\.scanBeam[\s\S]*opacity: 0 !important/); + expect(stylesheet).toMatch( + /\.verifiedPulse,\s*\.returnPulse,\s*\.scannerBeam,\s*\.releaseCheck\s*{\s*animation: none !important;/, + ); + }); + + it('keeps the trust ribbon cyan-to-mint and animates the approved release state', () => { + const stylesheet = readFileSync( + new URL('./SaasVerifiedCore.module.css', import.meta.url), + 'utf8', + ); + const trustRibbonRule = stylesheet.match(/\.trustRibbon\s*{([\s\S]*?)\n {2}}/)?.[1] ?? ''; + + expect(trustRibbonRule).toContain('#7fe3ff 0%'); + expect(trustRibbonRule).not.toContain('#9a51ff'); + expect(stylesheet).toMatch(/\.releaseCheck\s*{[\s\S]*animation: releaseApproval/); + expect(stylesheet).toContain('@keyframes releaseApproval'); }); }); diff --git a/apps/web/src/components/sections/saas/SaasShiftLeft.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.tsx index d42e745e6..4565733a7 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.tsx @@ -1,9 +1,10 @@ import type React from 'react'; import { Container, Section } from '@/components/layout'; import { Reveal } from '@/components/ui/Reveal'; -import { SaasCleanroomReactor } from './SaasCleanroomReactor'; +import { SaasVerifiedCore } from './SaasVerifiedCore'; -const PROCESS_DESCRIPTION = +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; export function SaasShiftLeft(): React.ReactElement { @@ -61,7 +62,15 @@ export function SaasShiftLeft(): React.ReactElement { -
    +
      +
    1. Code
    2. +
    3. Build
    4. +
    5. Test
    6. +
    7. Deploy
    8. +
    9. Security Review
    10. +
    + +
    1. Verified Components
    2. Code
    3. Build
    4. @@ -72,7 +81,7 @@ export function SaasShiftLeft(): React.ReactElement {
      - +
      diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css new file mode 100644 index 000000000..bdc25a9f6 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css @@ -0,0 +1,821 @@ +.stage { + width: 100%; + max-width: 1120px; + margin-inline: auto; +} + +.desktopSurface { + display: none; +} + +.mobileSurface { + display: grid; + gap: 18px; +} + +.mobileLateCard, +.mobileVerifiedCard { + position: relative; + overflow: hidden; + border: 1px solid rgba(151, 182, 235, 0.2); + border-radius: 22px; + background: + linear-gradient(rgba(118, 159, 218, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(118, 159, 218, 0.055) 1px, transparent 1px), + linear-gradient(155deg, rgba(18, 29, 57, 0.96) 0%, rgba(6, 12, 29, 0.98) 100%); + background-size: 34px 34px, 34px 34px, auto; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 24px 50px rgba(3, 7, 21, 0.3); +} + +.mobileLateCard { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 14px 12px; + padding: 20px 18px 12px; + border-color: rgba(255, 135, 149, 0.22); + background: + radial-gradient(circle at 92% 42%, rgba(255, 107, 107, 0.11), transparent 28%), + linear-gradient(rgba(118, 159, 218, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(118, 159, 218, 0.045) 1px, transparent 1px), + linear-gradient(155deg, rgba(24, 25, 50, 0.96) 0%, rgba(10, 11, 27, 0.98) 100%); + background-size: auto, 34px 34px, 34px 34px, auto; +} + +.mobileLateStages { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.mobileLateStage { + min-width: 0; + padding: 8px 9px; + border: 1px solid rgba(196, 207, 236, 0.13); + border-radius: 8px; + background: rgba(160, 177, 217, 0.055); + color: rgba(226, 232, 250, 0.63); + font-family: var(--font-display); + font-size: var(--fs-caption); + font-weight: 600; + line-height: 1.2; + text-align: center; +} + +.mobileLateConnector { + position: absolute; + top: 50%; + right: 92px; + width: 28px; + height: 1px; + background: linear-gradient(90deg, rgba(183, 196, 226, 0.16), rgba(255, 135, 149, 0.56)); +} + +.mobileReturnPath { + grid-column: 1 / -1; + width: 100%; + height: 38px; + margin-top: -4px; +} + +.mobileVerifiedCard { + padding: 24px 18px 28px; + border-color: rgba(103, 227, 213, 0.3); + background: + radial-gradient(circle at 50% 12%, rgba(44, 193, 235, 0.17), transparent 32%), + linear-gradient(rgba(118, 159, 218, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(118, 159, 218, 0.055) 1px, transparent 1px), + linear-gradient(155deg, rgba(12, 31, 55, 0.97) 0%, rgba(4, 15, 29, 0.99) 100%); + background-size: auto, 34px 34px, 34px 34px, auto; +} + +.mobileCoreTrack { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 13px; +} + +.mobileTrustRibbon { + position: absolute; + z-index: 0; + top: 72px; + bottom: 74px; + left: 50%; + width: 5px; + transform: translateX(-50%); + border-radius: 999px; + background: linear-gradient(180deg, #7fe3ff 0%, #57e4d0 74%, #71efb8 100%); + box-shadow: 0 0 18px rgba(95, 227, 192, 0.42); +} + +.sourceShell, +.stageShell { + position: relative; + z-index: 2; + width: 100%; + padding: 1px; + clip-path: polygon(12px 0, calc(100% - 12px) 0, 100% 12px, 100% calc(100% - 12px), calc(100% - 12px) 100%, 12px 100%, 0 calc(100% - 12px), 0 12px); +} + +.sourceShell { + max-width: 280px; + min-height: 86px; + background: linear-gradient(135deg, rgba(163, 112, 255, 0.9), rgba(93, 237, 204, 0.95)); + filter: drop-shadow(0 14px 28px rgba(27, 184, 211, 0.2)); +} + +.sourceInner, +.stageInner { + width: 100%; + height: 100%; + clip-path: inherit; +} + +.sourceInner { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 84px; + padding: 14px; + background: + linear-gradient(135deg, rgba(62, 42, 111, 0.88) 0%, rgba(12, 68, 83, 0.94) 100%), + #0b1f35; +} + +.sourceSeal { + display: grid; + flex: 0 0 auto; + width: 40px; + height: 40px; + place-items: center; + border: 1px solid rgba(210, 251, 255, 0.72); + border-radius: 50%; + background: linear-gradient(145deg, #c9f8ff 0%, #66e4d1 100%); + color: #0b2734; + box-shadow: 0 0 0 7px rgba(104, 228, 218, 0.08), 0 0 24px rgba(104, 228, 218, 0.34); +} + +.sourceSeal svg, +.scannerStateIcon svg, +.releaseCheck svg { + width: 20px; + height: 20px; +} + +.sourceLabel, +.stageLabel, +.scannerLabel { + font-family: var(--font-display); + font-weight: 600; + letter-spacing: -0.02em; +} + +.sourceLabel { + color: #f5fdff; + font-size: var(--fs-body-sm); + line-height: 1.25; + text-align: left; +} + +.stageShell { + max-width: 260px; + min-height: 72px; + background: linear-gradient(135deg, rgba(131, 215, 255, 0.56), rgba(96, 225, 193, 0.33)); + filter: drop-shadow(0 10px 20px rgba(9, 25, 46, 0.24)); +} + +.stageInner { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 16px; + min-height: 70px; + padding: 13px 16px; + background: + linear-gradient(115deg, rgba(20, 43, 72, 0.95), rgba(7, 28, 45, 0.97)), + #081d31; +} + +.stageLabel { + color: rgba(241, 250, 255, 0.94); + font-size: var(--fs-body-sm); + line-height: 1.2; +} + +.coreWindow { + position: relative; + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 1px solid rgba(127, 227, 255, 0.45); + border-radius: 50%; + background: rgba(6, 31, 46, 0.86); + box-shadow: 0 0 0 6px rgba(95, 227, 192, 0.045); +} + +.coreLine { + position: absolute; + top: 50%; + right: -17px; + left: -17px; + height: 3px; + transform: translateY(-50%); + border-radius: 999px; + background: linear-gradient(90deg, #7fe3ff, #5fe3c0); +} + +.coreNode { + position: relative; + z-index: 1; + width: 10px; + height: 10px; + border: 2px solid #d8fbff; + border-radius: 50%; + background: #5fe3c0; + box-shadow: 0 0 15px rgba(95, 227, 192, 0.9); +} + +.scanner { + position: relative; + z-index: 3; + display: grid; + min-width: 86px; + min-height: 104px; + place-items: center; + align-content: center; + gap: 8px; +} + +.scannerFrame { + position: relative; + width: 68px; + height: 66px; + overflow: hidden; + border-top: 3px solid currentColor; + border-right: 3px solid currentColor; + border-left: 3px solid currentColor; + border-radius: 10px 10px 3px 3px; + background: rgba(7, 22, 39, 0.88); + box-shadow: 0 0 18px color-mix(in srgb, currentColor 24%, transparent); +} + +.scannerOpen { + color: #70edbd; +} + +.scannerClosed { + color: #ff8795; +} + +.scannerBeam { + position: absolute; + inset: 4px 7px auto; + height: 2px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 12px currentColor; + animation: scannerSweep 8.8s cubic-bezier(0.65, 0, 0.35, 1) infinite; +} + +.scannerBarrier { + position: absolute; + right: 8px; + bottom: 11px; + left: 8px; + height: 7px; + border-radius: 999px; + background: currentColor; + transform-origin: right center; +} + +.scannerOpen .scannerBarrier { + transform: translateY(-31px) rotate(-90deg); + opacity: 0.72; +} + +.scannerClosed .scannerBarrier { + box-shadow: 0 0 14px rgba(255, 135, 149, 0.58); +} + +.scannerStateIcon { + position: absolute; + top: 50%; + left: 50%; + display: grid; + width: 26px; + height: 26px; + place-items: center; + transform: translate(-50%, -44%); +} + +.scannerLabel { + color: currentColor; + font-size: var(--fs-caption); + line-height: 1.2; + text-align: center; +} + +.scannerCompact { + min-width: 78px; + min-height: 82px; + gap: 5px; +} + +.scannerCompact .scannerFrame { + width: 56px; + height: 50px; +} + +.scannerCompact.scannerOpen .scannerBarrier { + transform: translateY(-21px) rotate(-90deg); +} + +.scannerCompact .scannerLabel { + max-width: 86px; +} + +.releaseExit { + position: absolute; + top: 26px; + left: calc(50% + 31px); + display: flex; + align-items: center; +} + +.releaseLine { + width: 28px; + height: 2px; + background: linear-gradient(90deg, #70edbd, rgba(112, 237, 189, 0.18)); +} + +.releaseArrow { + width: 0; + height: 0; + margin-left: -1px; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 8px solid #70edbd; + filter: drop-shadow(0 0 5px rgba(112, 237, 189, 0.48)); +} + +.releaseCheck { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border: 1px solid rgba(160, 255, 216, 0.74); + border-radius: 50%; + background: rgba(15, 75, 62, 0.92); + color: #a0ffd8; + box-shadow: 0 0 18px rgba(112, 237, 189, 0.3); + animation: releaseApproval 8.8s ease-in-out infinite; +} + +.scannerVertical { + margin-top: 4px; + padding-bottom: 50px; +} + +.scannerVertical .releaseExit { + top: auto; + bottom: 2px; + left: 50%; + flex-direction: column; + transform: translateX(-50%); +} + +.scannerVertical .releaseLine { + width: 2px; + height: 18px; + background: linear-gradient(180deg, #70edbd, rgba(112, 237, 189, 0.18)); +} + +.scannerVertical .releaseArrow { + margin-top: -1px; + margin-left: 0; + transform: rotate(90deg); +} + +.returnGuide { + stroke: rgba(255, 135, 149, 0.55); + stroke-width: 2; + stroke-dasharray: 6 8; + stroke-linecap: round; +} + +.returnPulse { + stroke: #ff8795; + stroke-width: 3; + stroke-dasharray: 80 1200; + stroke-linecap: round; + filter: drop-shadow(0 0 6px rgba(255, 135, 149, 0.7)); + animation: returnSignal 8.8s linear infinite; +} + +@keyframes scannerSweep { + 0%, + 14% { + transform: translateY(0); + opacity: 0.32; + } + 42%, + 62% { + transform: translateY(50px); + opacity: 0.95; + } + 76%, + 100% { + transform: translateY(0); + opacity: 0.32; + } +} + +@keyframes returnSignal { + 0%, + 58% { + stroke-dashoffset: 0; + opacity: 0; + } + 63% { + opacity: 1; + } + 88% { + stroke-dashoffset: -760; + opacity: 1; + } + 94%, + 100% { + stroke-dashoffset: -840; + opacity: 0; + } +} + +@keyframes releaseApproval { + 0%, + 56% { + transform: scale(0.92); + opacity: 0.72; + } + 68%, + 84% { + transform: scale(1.08); + opacity: 1; + box-shadow: 0 0 24px rgba(112, 237, 189, 0.58); + } + 94%, + 100% { + transform: scale(0.92); + opacity: 0.72; + } +} + +@media (min-width: 640px) { + .mobileLateStages { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .desktopSurface { + position: relative; + isolation: isolate; + display: block; + min-height: 500px; + overflow: hidden; + padding: 30px 34px 34px; + border: 1px solid rgba(137, 181, 244, 0.28); + border-radius: 28px; + background: + radial-gradient(circle at 35% 78%, rgba(44, 193, 235, 0.16), transparent 35%), + radial-gradient(circle at 82% 22%, rgba(105, 65, 203, 0.14), transparent 31%), + linear-gradient(rgba(138, 174, 228, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(138, 174, 228, 0.055) 1px, transparent 1px), + linear-gradient(145deg, rgba(15, 25, 51, 0.98) 0%, rgba(5, 12, 27, 0.99) 52%, rgba(5, 24, 34, 0.98) 100%); + background-size: auto, auto, 42px 42px, 42px 42px, auto; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + inset 0 -1px 0 rgba(87, 218, 208, 0.08), + 0 36px 78px rgba(3, 7, 21, 0.46); + } + + .desktopSurface::before { + position: absolute; + z-index: -1; + inset: 0; + content: ''; + background: linear-gradient(112deg, transparent 0%, rgba(126, 226, 255, 0.035) 44%, transparent 62%); + pointer-events: none; + } + + .mobileSurface { + display: none; + } + + .surfaceChrome { + position: absolute; + z-index: 0; + inset: 20px; + pointer-events: none; + } + + .cornerTopLeft, + .cornerTopRight, + .cornerBottomLeft, + .cornerBottomRight { + position: absolute; + width: 24px; + height: 24px; + border-color: rgba(136, 200, 247, 0.34); + } + + .cornerTopLeft { + top: 0; + left: 0; + border-top: 1px solid; + border-left: 1px solid; + } + + .cornerTopRight { + top: 0; + right: 0; + border-top: 1px solid; + border-right: 1px solid; + } + + .cornerBottomLeft { + bottom: 0; + left: 0; + border-bottom: 1px solid; + border-left: 1px solid; + } + + .cornerBottomRight { + right: 0; + bottom: 0; + border-right: 1px solid; + border-bottom: 1px solid; + } + + .measureTop, + .measureBottom { + position: absolute; + left: 50%; + width: 116px; + height: 1px; + transform: translateX(-50%); + background: repeating-linear-gradient(90deg, rgba(127, 227, 255, 0.28) 0 2px, transparent 2px 10px); + } + + .measureTop { + top: 0; + } + + .measureBottom { + bottom: 0; + } + + .routeStack { + position: relative; + z-index: 1; + display: grid; + gap: 16px; + } + + .lateRoute { + position: relative; + height: 172px; + opacity: 0.82; + } + + .returnPath { + position: absolute; + z-index: 0; + top: -5px; + left: 0; + width: 100%; + height: 150px; + overflow: visible; + } + + .lateRail { + position: absolute; + z-index: 0; + top: 111px; + right: 7.2%; + left: 20.2%; + height: 1px; + background: linear-gradient(90deg, rgba(193, 205, 234, 0.24), rgba(255, 135, 149, 0.5)); + } + + .lateGrid, + .verifiedGrid { + position: relative; + z-index: 2; + display: grid; + grid-template-columns: 1.38fr repeat(4, minmax(0, 1fr)) 1.2fr; + gap: 12px; + align-items: center; + } + + .lateGrid { + height: 100%; + padding-top: 62px; + } + + .lateStage { + display: grid; + min-width: 0; + place-items: center; + gap: 8px; + } + + .lateNode { + position: relative; + display: grid; + width: 52px; + height: 44px; + place-items: center; + clip-path: polygon(9px 0, calc(100% - 9px) 0, 100% 9px, 100% calc(100% - 9px), calc(100% - 9px) 100%, 9px 100%, 0 calc(100% - 9px), 0 9px); + background: linear-gradient(145deg, rgba(130, 150, 195, 0.24), rgba(46, 58, 91, 0.52)); + box-shadow: inset 0 0 0 1px rgba(196, 207, 236, 0.15); + } + + .lateNodeCore { + width: 9px; + height: 9px; + border: 1px solid rgba(228, 233, 247, 0.32); + border-radius: 50%; + background: rgba(194, 204, 230, 0.18); + } + + .lateLabel { + color: rgba(224, 230, 248, 0.58); + font-family: var(--font-display); + font-size: var(--fs-caption); + font-weight: 600; + line-height: 1.2; + text-align: center; + } + + .lateRoute .scanner { + align-self: end; + margin-bottom: 4px; + } + + .verifiedRoute { + position: relative; + min-height: 220px; + padding: 30px 24px; + border: 1px solid rgba(95, 227, 192, 0.24); + border-radius: 18px; + background: + radial-gradient(circle at 11% 50%, rgba(136, 83, 255, 0.18), transparent 21%), + linear-gradient(100deg, rgba(29, 54, 91, 0.58) 0%, rgba(6, 33, 48, 0.62) 58%, rgba(8, 46, 45, 0.46) 100%); + box-shadow: inset 0 1px 0 rgba(198, 251, 255, 0.07), 0 0 46px rgba(44, 193, 235, 0.07); + } + + .verifiedRoute::after { + position: absolute; + inset: 10px; + border: 1px solid rgba(127, 227, 255, 0.07); + border-radius: 12px; + content: ''; + pointer-events: none; + } + + .verifiedGrid { + min-height: 158px; + } + + .trustRibbon { + position: absolute; + z-index: 1; + top: 50%; + right: 5.5%; + left: 6.8%; + height: 6px; + transform: translateY(-50%); + border-radius: 999px; + background: linear-gradient(90deg, #7fe3ff 0%, #69e4dd 42%, #5fe3c0 78%, #70edbd 100%); + box-shadow: 0 0 18px rgba(95, 227, 192, 0.38), 0 0 42px rgba(44, 193, 235, 0.12); + } + + .verifiedPulse { + position: absolute; + z-index: 1; + top: 50%; + left: 8%; + width: 13px; + height: 13px; + transform: translate(-50%, -50%); + border: 2px solid #e7ffff; + border-radius: 50%; + background: #70edbd; + box-shadow: 0 0 18px #70edbd; + animation: verifiedSignal 8.8s cubic-bezier(0.55, 0, 0.35, 1) infinite; + } + + .sourceShell, + .stageShell { + max-width: none; + } + + .sourceShell { + min-height: 112px; + } + + .sourceInner { + min-height: 110px; + padding: 14px 13px; + } + + .sourceSeal { + width: 42px; + height: 42px; + } + + .stageShell { + min-height: 100px; + } + + .stageInner { + grid-template-columns: 1fr; + justify-items: center; + gap: 10px; + min-height: 98px; + padding: 14px 10px; + text-align: center; + } + + .stageLabel { + font-size: var(--fs-body-sm); + } + + .coreWindow { + width: 36px; + height: 36px; + } + + .coreLine { + right: -26px; + left: -26px; + } + + .verifiedRoute .scanner { + min-width: 0; + } +} + +@keyframes verifiedSignal { + 0%, + 8% { + left: 8%; + opacity: 0; + } + 13% { + opacity: 1; + } + 78% { + left: 89%; + opacity: 1; + } + 86%, + 100% { + left: 94%; + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .verifiedPulse, + .returnPulse, + .scannerBeam, + .releaseCheck { + animation: none !important; + } + + .verifiedPulse { + left: 89%; + opacity: 1; + } + + .returnPulse { + opacity: 0; + } + + .scannerBeam { + transform: translateY(28px); + opacity: 0.74; + } + + .releaseCheck { + transform: none; + opacity: 1; + } +} diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx new file mode 100644 index 000000000..d964df386 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx @@ -0,0 +1,287 @@ +import type React from 'react'; +import styles from './SaasVerifiedCore.module.css'; + +type DeliveryStageId = 'code' | 'build' | 'test' | 'deploy'; + +interface DeliveryStage { + readonly id: DeliveryStageId; + readonly label: 'Code' | 'Build' | 'Test' | 'Deploy'; +} + +interface SecurityScannerProps { + readonly state: 'open' | 'closed'; + readonly compact?: boolean; + readonly vertical?: boolean; +} + +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 ( +
      + + +
      + ); +} + +function DesktopVerifiedCore(): React.ReactElement { + return ( + + ); +} + +function SurfaceChrome(): React.ReactElement { + return ( +
      + + + + + + +
      + ); +} + +function LateReviewRoute(): React.ReactElement { + return ( +
      + + + + + + + + + + + +
      + + {DELIVERY_STAGES.map((stage) => ( + + ))} + +
      +
      + ); +} + +function LateStage({ stage }: { readonly stage: DeliveryStage }): React.ReactElement { + return ( +
      + + + + {stage.label} +
      + ); +} + +function VerifiedRoute(): React.ReactElement { + return ( +
      + + +
      + + {DELIVERY_STAGES.map((stage) => ( + + ))} + +
      +
      + ); +} + +function VerifiedSource(): React.ReactElement { + return ( +
      +
      + + + + Verified Components +
      +
      + ); +} + +function StageHousing({ stage }: { readonly stage: DeliveryStage }): React.ReactElement { + return ( +
      +
      + {stage.label} + + + + +
      +
      + ); +} + +function SecurityScanner({ + state, + compact = false, + vertical = false, +}: SecurityScannerProps): React.ReactElement { + const isOpen = state === 'open'; + const className = [ + styles.scanner, + isOpen ? styles.scannerOpen : styles.scannerClosed, + compact ? styles.scannerCompact : '', + vertical ? styles.scannerVertical : '', + ] + .filter(Boolean) + .join(' '); + + return ( +
      +
      + + + + {isOpen ? : } + +
      + Security Review + {isOpen ? ( + + + + + + + + ) : null} +
      + ); +} + +function MobileVerifiedCore(): React.ReactElement { + return ( + + ); +} + +function MobileLateRoute(): React.ReactElement { + return ( +
      +
      + {DELIVERY_STAGES.map((stage) => ( + + {stage.label} + + ))} +
      + + + + + + + + + + +
      + ); +} + +function MobileVerifiedRoute(): React.ReactElement { + return ( +
      +
      + + + {DELIVERY_STAGES.map((stage) => ( + + ))} + +
      +
      + ); +} + +function CheckIcon(): React.ReactElement { + return ( + + ); +} + +function CrossIcon(): React.ReactElement { + return ( + + ); +} From f3bd0b0006d9d5ab7f41482fecb9d4100b2b1008 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 13:51:48 +0530 Subject: [PATCH 04/85] feat(web): rebuild the SaaS hero artifact from real catalogue data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client rejected hero-app-platform.webp. Its core fault was meaning, not craft: a rising line chart promises revenue growth on a page that sells container security, and nothing in the render was about security at all. Four regenerations could not shake the stock-illustration read, because an illustration OF security is generic by nature. Competitor research settled the direction. Chainguard's homepage hero is a row of real image cards carrying FIPS VALIDATED / STIG HARDENED badges and live CVE-reduction counts; Docker Hardened Images does the same with OS / Architecture / Compliance rows. Neither market leader uses an illustration. Product truth is the convention here. So the artifact is now a column of real CleanStart images: nginx at the edge, redis for cache, postgres for data, node for the runtime, which is a SaaS service stack rather than a list of popular images. The finance page could not run this artifact. Every claim 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). "Security Hardened", "FIPS Available", SBOM, Signature and Provenance are badges and tabs that catalogue publishes. Per-image CVE counts are deliberately absent: the catalogue does not publish them, so any number would be invented. Built as a column rather than the overlapping fan tried first — the fan's negative margins made height depend on card content, and it stood only 185px tall in a 627px hero. It now measures 440x411, with a 163px gap to the headline at 1280 and no horizontal overflow. Hidden below xl as before, so mobile loses nothing. lint, typecheck and build pass. --- apps/web/src/app/globals.css | 19 ++ .../src/components/sections/saas/SaasHero.tsx | 74 ++---- .../sections/saas/SaasHeroStack.tsx | 238 ++++++++++++++++++ 3 files changed, 281 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/components/sections/saas/SaasHeroStack.tsx diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index b60915c9a..5dad11abc 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1316,6 +1316,25 @@ 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 stack cards (SaasHeroStack.tsx). The cluster settles in once and + then holds — a hero artifact that loops competes with the headline. + + Written settled-first: the resting state IS the finished card, and the + entrance is layered on only under `no-preference`, so reduced motion gets the + composed cluster rather than three invisible cards. */ +@media (prefers-reduced-motion: no-preference) { + @keyframes cs-stack-card { + from { + opacity: 0; + translate: 0 18px; + } + } + + .cs-stack-card { + animation: cs-stack-card 620ms cubic-bezier(0.16, 1, 0.3, 1) both; + } +} + @keyframes cs-hero-reveal { from { opacity: 0; diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index 64e8687dc..32f40bcf4 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -1,32 +1,20 @@ import type React from 'react'; -import Image from 'next/image'; import Link from 'next/link'; +import { SaasHeroStack } from './SaasHeroStack'; 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 built in code from real catalogue data + * (SaasHeroStack.tsx), because the render it replaced was rejected and four + * regenerations could not shake the stock-illustration read. Both category + * leaders reached the same conclusion: neither Chainguard nor Docker Hardened + * Images uses an illustration in its hero. */ export function SaasHero(): React.ReactElement { return ( @@ -46,18 +34,15 @@ 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. */} + width that holds both the artifact and the headline without one of them + being squeezed. Same "hide it when there is no room" call the sibling + hero makes. + + Built, not rendered: this was hero-app-platform.webp until the client + rejected it. See SaasHeroStack.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 +
      @@ -117,7 +91,7 @@ export function SaasHero(): React.ReactElement { }} > {/* 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 + measure. At xl+ the budget is fitted to the artifact's measured left edge at each width, with a margin. */}
      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} + +
      + +
      + + +
      + +
      + +
      + + + +
      +
      +
      + ); +} From 5c3d9ba28e1e61a8d2a96cb38ebcb90127d24cd1 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 14:30:31 +0530 Subject: [PATCH 05/85] docs(web): specify SaaS pipeline stage icons --- .../plans/2026-09-01-saas-verified-core.md | 49 +++++++++++++++++++ .../2026-09-01-saas-verified-core-design.md | 2 + 2 files changed, 51 insertions(+) diff --git a/docs/superpowers/plans/2026-09-01-saas-verified-core.md b/docs/superpowers/plans/2026-09-01-saas-verified-core.md index ed081b3e8..6bc1f4222 100644 --- a/docs/superpowers/plans/2026-09-01-saas-verified-core.md +++ b/docs/superpowers/plans/2026-09-01-saas-verified-core.md @@ -281,3 +281,52 @@ git diff -- apps/web/src/components/sections/saas docs/superpowers/plans/2026-09 ``` 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. 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 index 3438a17ef..3d8f9e989 100644 --- a/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md +++ b/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md @@ -39,6 +39,7 @@ The late-review route sits above the primary route at reduced visual weight. It - 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. - Scanner: two vertical posts with a scanning aperture. The verified state is open; the late-review state is visibly closed. - Texture: fine grid, coordinate ticks, and restrained bloom provide depth without obscuring labels or flow. - Typography: all visible text uses the canonical `--fs-*`, `--font-display`, and `--font-sans` role tokens. @@ -107,6 +108,7 @@ Verification requires the focused SaaS test, the web package test suite, lint, t - 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 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. From 513ffefa6e3c41f4c7fb76bdba0d6c47b2e1af94 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 14:37:58 +0530 Subject: [PATCH 06/85] feat(web): make the SaaS hero artifact a CleanStart product panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the stack-card column with the dashboard the page actually wanted: a light product surface floating on the dark hero, which is the familiar SaaS hero move. The difference from the render the client rejected is the content — that one led with a rising line chart, which promises revenue growth on a page selling container security. There is no chart here. The panel shows a catalogue: three figures, then four images with their hardening state. Brand and depth, per feedback. The panel sits in perspective (rotateY -13deg, near edge on the left so it turns back toward the copy), seated on a blue-to-violet bloom so a white surface does not read as a cut-out on the mesh. Status chips moved off a conventional green — the one colour on the panel that could belong to any dashboard anywhere — onto CleanStart blue for hardened and violet for FIPS, with a blue keyline over each figure. A dark provenance card is pushed forward on Z over the lower-left corner: it breaks the rectangle, gives the composition a second tone, and carries SBOM / Signature / Provenance. The artifact wrapper now sets z-index 1. The hero's bottom fade is a later sibling and was painting white over the panel's lower third; the old 3D render wanted that wash, but a UI panel with dissolving edges reads as a rendering bug. Verified the nav mega menus still open above it (menu positioner is z-50 in the root stacking context). Figures are the ones images.cleanstart.com publishes (947 images, 34%+ average CVE reduction, 4M+ packages from verified source) and the badges are the ones its detail pages carry. Per-image CVE counts are absent on purpose: the catalogue does not publish them, so any number would be invented. Measured at 1440: panel 520x341 spanning y 113..464, no horizontal overflow, and none at 1280 either. Hidden below xl as before. --- apps/web/src/app/globals.css | 39 +- .../src/components/sections/saas/SaasHero.tsx | 34 +- .../sections/saas/SaasHeroDashboard.tsx | 383 ++++++++++++++++++ 3 files changed, 434 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/sections/saas/SaasHeroDashboard.tsx diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 5dad11abc..f68a0a641 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1316,22 +1316,45 @@ 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 stack cards (SaasHeroStack.tsx). The cluster settles in once and - then holds — a hero artifact that loops competes with the headline. +/* SaaS hero product panel (SaasHeroDashboard.tsx). Settles in once and holds — + a hero artifact that loops competes with the headline for attention. - Written settled-first: the resting state IS the finished card, and the + Written settled-first: the resting state IS the finished panel, and the entrance is layered on only under `no-preference`, so reduced motion gets the - composed cluster rather than three invisible cards. */ + composed dashboard rather than an invisible one. */ @media (prefers-reduced-motion: no-preference) { - @keyframes cs-stack-card { + @keyframes cs-hero-panel { from { opacity: 0; - translate: 0 18px; + translate: 0 22px; } } - .cs-stack-card { - animation: cs-stack-card 620ms cubic-bezier(0.16, 1, 0.3, 1) both; + @keyframes cs-hero-panel-row { + from { + opacity: 0; + } + } + + .cs-hero-panel { + animation: cs-hero-panel 700ms cubic-bezier(0.16, 1, 0.3, 1) both; + } + + .cs-hero-panel-row { + animation: cs-hero-panel-row 460ms ease-out both; + } + + /* The floating provenance card lands after the panel it overlaps, so the + depth reads as a card arriving in front rather than as one flat unit. */ + @keyframes cs-hero-panel-float { + from { + opacity: 0; + translate: -10px 16px; + } + } + + .cs-hero-panel-float { + animation: cs-hero-panel-float 620ms cubic-bezier(0.16, 1, 0.3, 1) 520ms both; } } diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index 32f40bcf4..75fc2223b 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -1,6 +1,6 @@ import type React from 'react'; import Link from 'next/link'; -import { SaasHeroStack } from './SaasHeroStack'; +import { SaasHeroDashboard } from './SaasHeroDashboard'; import { HeroReveal } from '@/components/ui/Reveal'; /* @@ -10,11 +10,9 @@ import { HeroReveal } from '@/components/ui/Reveal'; * is the proposal's, verbatim. * * The artifact is the one departure from that shell. Every other hero on the - * site carries a 3D render; this one is built in code from real catalogue data - * (SaasHeroStack.tsx), because the render it replaced was rejected and four - * regenerations could not shake the stock-illustration read. Both category - * leaders reached the same conclusion: neither Chainguard nor Docker Hardened - * Images uses an illustration in its hero. + * site carries a 3D render; this one is a product panel built in code + * (SaasHeroDashboard.tsx), because the render it replaced was rejected and + * regenerating it kept landing on stock illustration. */ export function SaasHero(): React.ReactElement { return ( @@ -40,13 +38,21 @@ export function SaasHero(): React.ReactElement { hero makes. Built, not rendered: this was hero-app-platform.webp until the client - rejected it. See SaasHeroStack.tsx for the reasoning. There is no + rejected it. See SaasHeroDashboard.tsx for the reasoning. There is no `priority` preload left to get wrong — the old render was preloading on phones that never painted it. */}
      - +
      diff --git a/apps/web/src/components/sections/saas/SaasHeroDashboard.tsx b/apps/web/src/components/sections/saas/SaasHeroDashboard.tsx new file mode 100644 index 000000000..3f57ebe63 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasHeroDashboard.tsx @@ -0,0 +1,383 @@ +import Image from 'next/image'; +import type React from 'react'; + +/* + * SaaS hero artifact — a CleanStart Images product panel. + * + * Built in code rather than rendered. The 3D dashboard render this replaces + * (hero-app-platform.webp) was rejected, and the fault was its content, not its + * craft: a rising line chart promises revenue growth on a page that sells + * container security. + * + * So this is still a dashboard — the familiar SaaS hero move of a light product + * surface floating on a dark gradient — but every panel in it is about the + * software supply chain. Stats on the left of a catalogue, images with their + * hardening state on the right. No charts. + * + * Figures are the ones images.cleanstart.com publishes on its own landing page + * (947 images, 34%+ average CVE reduction, 4M+ packages from verified source), + * and "Security Hardened" / "FIPS Available" are the badges its image detail + * pages carry. Per-image CVE counts are deliberately absent: the catalogue does + * not publish them per image, so any number here would be invented. + * + * The four images are a SaaS service stack (edge, cache, data, runtime), not a + * list of popular tags — that is what makes the artifact belong to this page + * rather than to the finance one. + */ + +interface ImageRow { + readonly name: string; + readonly role: string; + readonly logoUrl: string; + readonly fips: boolean; + /** devicon wordmarks with heavy internal whitespace sit 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`; +} + +const ROWS: readonly ImageRow[] = [ + { + name: 'nginx', + role: 'Edge', + logoUrl: deviconLogo('nginx', 'original'), + fips: true, + logoScale: 1.2, + }, + { name: 'node', role: 'Runtime', logoUrl: deviconLogo('nodejs', 'original'), fips: true }, + { name: 'postgres', role: 'Data', logoUrl: deviconLogo('postgresql', 'original'), fips: true }, + { name: 'redis', role: 'Cache', logoUrl: deviconLogo('redis', 'original'), fips: false }, +]; + +const STATS: readonly { value: string; label: string }[] = [ + { value: '947', label: 'Hardened images' }, + { value: '34%+', label: 'Avg CVE reduction' }, + { value: '4M+', label: 'Verified packages' }, +]; + +const PANEL_TEXT = '#101322'; +const PANEL_MUTED = 'rgba(16,19,34,0.52)'; +const HAIRLINE = 'rgba(16,19,34,0.08)'; + +function RailGlyph({ active, bars }: { active: boolean; bars: number }): React.ReactElement { + return ( +
      + {Array.from({ length: bars }, (_, i) => ( + + ))} +
      + ); +} + +/* + * Blue for hardened, violet for FIPS — the CleanStart pair. The first pass used + * a conventional status green, which is the one colour on the panel that could + * belong to any dashboard on the internet. + */ +function StatusChip({ label, tone }: { label: string; tone: 'ok' | 'fips' }): React.ReactElement { + return ( + + {label} + + ); +} + +export function SaasHeroDashboard(): React.ReactElement { + return ( +
      + {/* Brand bloom behind the panel. A white surface dropped straight onto the + hero mesh reads as a cut-out; this seats it in CleanStart's blue and + violet so it belongs to the gradient it sits on. */} +
      + + {/* One rotation for the whole scene, with the floating card pushed forward + on Z inside it, so panel and card share a single vanishing point. The + left edge is the near edge: the artifact sits right of the copy, so it + turns back toward the headline rather than away off the page. + + No `will-change` and no `backdrop-filter` here — both have caused + stacking-context and GPU-promotion artifacts elsewhere on this site. */} +
      +
      + {/* Window chrome. Keeps the artifact reading as a product surface rather + than as a content card dropped on the gradient. */} +
      + + + + +
      + +
      + {/* Left rail */} +
      + + + + +
      + +
      +
      + {STATS.map((stat) => ( +
      + {/* Brand keyline above each figure. Carries CleanStart's blue + into the panel body, which is otherwise all neutrals. */} + +
      + {stat.value} +
      +
      + {stat.label} +
      +
      + ))} +
      + +
      + Image + Status +
      + + {ROWS.map((row, i) => ( +
      + + + + + + {row.name} + + + + {row.role} + + + + {row.fips && } + + +
      + ))} +
      +
      +
      +
      + + {/* Provenance card, pushed forward on Z and overlapping the panel's lower + left corner. It does the work a flat panel cannot: it breaks the + rectangle, it puts a dark brand-coloured element against the white so + the composition has two tones, and it carries the three artifacts the + page keeps promising. Sibling of the panel, not a child — the panel + clips its own overflow. */} +
      + {['SBOM', 'Signature', 'Provenance'].map((label, i) => ( + + {i > 0 && ( + + )} + + {label} + + + ))} +
      +
      + ); +} From 7c753dee7bd09f4077abb63cc4af95b9bb187ee8 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 14:55:41 +0530 Subject: [PATCH 07/85] feat(web): distinguish SaaS pipeline stages with icons --- .../sections/saas/SaasShiftLeft.test.tsx | 19 +++++++++ .../sections/saas/SaasVerifiedCore.module.css | 25 ++++++----- .../sections/saas/SaasVerifiedCore.tsx | 41 ++++++++++++++++++- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx index edd8d4471..0346bd0d7 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx @@ -55,6 +55,25 @@ describe('SaasShiftLeft', () => { ); }); + it('renders a distinct stage icon in both responsive diagrams', () => { + const html = renderSection(); + + for (const stage of ['code', 'build', 'test', 'deploy']) { + expect(html.match(new RegExp(`data-stage-icon="${stage}"`, 'g'))).toHaveLength(2); + } + }); + + it('keeps stage icons visually prominent inside their core windows', () => { + const stylesheet = readFileSync( + new URL('./SaasVerifiedCore.module.css', import.meta.url), + 'utf8', + ); + const stageIconRule = stylesheet.match(/\.stageIcon\s*{([^}]*)}/)?.[1] ?? ''; + + expect(stageIconRule).toContain('width: 27px'); + expect(stageIconRule).toContain('height: 27px'); + }); + it('contrasts an open release with a closed late-review return', () => { const html = renderSection(); diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css index bdc25a9f6..8310f107d 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css @@ -210,8 +210,8 @@ .coreWindow { position: relative; display: grid; - width: 34px; - height: 34px; + width: 46px; + height: 46px; place-items: center; border: 1px solid rgba(127, 227, 255, 0.45); border-radius: 50%; @@ -230,15 +230,18 @@ background: linear-gradient(90deg, #7fe3ff, #5fe3c0); } -.coreNode { +.stageIcon { position: relative; z-index: 1; - width: 10px; - height: 10px; - border: 2px solid #d8fbff; - border-radius: 50%; - background: #5fe3c0; - box-shadow: 0 0 15px rgba(95, 227, 192, 0.9); + width: 27px; + height: 27px; + overflow: visible; + color: #c9faff; + filter: drop-shadow(0 0 5px rgba(95, 227, 192, 0.76)); + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; } .scanner { @@ -758,8 +761,8 @@ } .coreWindow { - width: 36px; - height: 36px; + width: 48px; + height: 48px; } .coreLine { diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx index d964df386..f82b06290 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx @@ -148,13 +148,52 @@ function StageHousing({ stage }: { readonly stage: DeliveryStage }): React.React {stage.label} - +
      ); } +function StageIcon({ stage }: { readonly stage: DeliveryStageId }): React.ReactElement { + const commonProps = { + 'aria-hidden': true, + className: styles.stageIcon, + 'data-stage-icon': stage, + fill: 'none', + viewBox: '0 0 24 24', + } as const; + + switch (stage) { + case 'code': + return ( + + + + ); + case 'build': + return ( + + + + + ); + case 'test': + return ( + + + + + ); + case 'deploy': + return ( + + + + ); + } +} + function SecurityScanner({ state, compact = false, From c26fd8670df078ca2de994e8faaf1cf7193080d9 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 15:05:15 +0530 Subject: [PATCH 08/85] feat(web): make the SaaS hero the attack surface, shown as size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same container image twice on one baseline at one scale: the public base image on the left, the CleanStart rebuild on the right. The right stack is a fraction of the height, and the packages stripped out of it drift away above, so the picture reads as subtraction rather than as two unrelated objects. This replaces the product panel, which the page did not want. That one put a SaaS dashboard forward as the subject, and this page sells container security TO SaaS companies: the container is the subject and SaaS is the audience. SaaS now stays a light touch, the way the finance sibling is only "finance" through a small bank glyph in its backdrop. Chosen over the alternatives on legibility. It is one idea, readable in about a second, where the hexagon lattice, the component constellation, the catalogue panel and a cutaway concept carrying eight simultaneous ideas all needed decoding, which a hero beside a large headline does not get. Contrast also comes free: a bright minimal object on a dark hero is the natural composition, where a dark cross-section would fight the mesh. Two signals, not one. Size carries the headline idea, and order carries it again up close: the public image gets jittered package heights and baselines so it reads as unruly, the rebuild gets three identical blocks per band so it reads as governed. Size alone was not doing enough work in the first pass. Package layout is deterministic (Math.imul mixing, not Math.random) so server and client agree and hydration does not mismatch. CVE marks are three, muted and round: the finance risk-chain section already signals vulnerabilities with red and amber triangles and the two industry pages should not trade the same mark. No figures are drawn on it — a number beside two stacks invites reading the height difference as that ratio, and this is an illustration, not a chart. Width is a deliberately narrow clamp. Fixed at 560 left only 43px between artifact and headline at 1280; clamp(520px, 40vw, 560px) gives 83px there and the full size from 1440 up, while the 10px labels only move to 10.8px across that range. Hidden below xl as before. lint, typecheck and build pass. --- apps/web/src/app/globals.css | 45 +- .../src/components/sections/saas/SaasHero.tsx | 21 +- .../sections/saas/SaasHeroAttackSurface.tsx | 301 ++++++++++++++ .../sections/saas/SaasHeroDashboard.tsx | 383 ------------------ 4 files changed, 326 insertions(+), 424 deletions(-) create mode 100644 apps/web/src/components/sections/saas/SaasHeroAttackSurface.tsx delete mode 100644 apps/web/src/components/sections/saas/SaasHeroDashboard.tsx diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index f68a0a641..156c56b34 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1316,45 +1316,26 @@ 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 product panel (SaasHeroDashboard.tsx). Settles in once and holds — - a hero artifact that loops competes with the headline for attention. +/* SaaS hero artifact (SaasHeroAttackSurface.tsx). The stacks build once from + the baseline up, then hold. A hero artifact that loops competes with the + headline for attention. - Written settled-first: the resting state IS the finished panel, and the - entrance is layered on only under `no-preference`, so reduced motion gets the - composed dashboard rather than an invisible one. */ -@media (prefers-reduced-motion: no-preference) { - @keyframes cs-hero-panel { - from { - opacity: 0; - translate: 0 22px; - } - } + 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. - @keyframes cs-hero-panel-row { - from { - opacity: 0; - } - } - - .cs-hero-panel { - animation: cs-hero-panel 700ms cubic-bezier(0.16, 1, 0.3, 1) both; - } - - .cs-hero-panel-row { - animation: cs-hero-panel-row 460ms ease-out both; - } - - /* The floating provenance card lands after the panel it overlaps, so the - depth reads as a card arriving in front rather than as one flat unit. */ - @keyframes cs-hero-panel-float { + 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; - translate: -10px 16px; } } - .cs-hero-panel-float { - animation: cs-hero-panel-float 620ms cubic-bezier(0.16, 1, 0.3, 1) 520ms both; + .cs-hero-band, + .cs-hero-fade { + animation: cs-hero-band 560ms ease-out both; } } diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index 75fc2223b..f59e650d1 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -1,6 +1,6 @@ import type React from 'react'; import Link from 'next/link'; -import { SaasHeroDashboard } from './SaasHeroDashboard'; +import { SaasHeroAttackSurface } from './SaasHeroAttackSurface'; import { HeroReveal } from '@/components/ui/Reveal'; /* @@ -10,8 +10,8 @@ import { HeroReveal } from '@/components/ui/Reveal'; * is the proposal's, verbatim. * * The artifact is the one departure from that shell. Every other hero on the - * site carries a 3D render; this one is a product panel built in code - * (SaasHeroDashboard.tsx), because the render it replaced was rejected and + * site carries a 3D render; this one is drawn in code + * (SaasHeroAttackSurface.tsx), because the render it replaced was rejected and * regenerating it kept landing on stock illustration. */ export function SaasHero(): React.ReactElement { @@ -38,7 +38,7 @@ export function SaasHero(): React.ReactElement { hero makes. Built, not rendered: this was hero-app-platform.webp until the client - rejected it. See SaasHeroDashboard.tsx for the reasoning. There is no + rejected it. See SaasHeroAttackSurface.tsx for the reasoning. There is no `priority` preload left to get wrong — the old render was preloading on phones that never painted it. */}
      - +
      diff --git a/apps/web/src/components/sections/saas/SaasHeroAttackSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAttackSurface.tsx new file mode 100644 index 000000000..b12074435 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasHeroAttackSurface.tsx @@ -0,0 +1,301 @@ +import type React from 'react'; + +/* + * SaaS hero artifact — attack surface, shown as size. + * + * The same container image twice, on one baseline at one scale: the public base + * image on the left, the CleanStart rebuild of it on the right. The right stack + * is a fraction of the height, and the packages that were stripped out drift + * away above the left one, so the picture reads as SUBTRACTION rather than as + * two unrelated objects. + * + * Why this and not the alternatives that were tried: + * + * - It is ONE idea, legible in about a second. Big versus small. Every earlier + * attempt (a hexagon lattice, a component constellation, a catalogue panel, a + * cutaway spec with eight simultaneous ideas) needed decoding, and a hero + * sitting beside a large headline does not get decoded. + * - Contrast comes for free. The bright minimal object against the dark hero is + * the natural composition, where a dark cross-section on the navy mesh would + * have to fight the background. + * - It is the actual product claim and it is provable: hardened, minimal, fewer + * packages, smaller attack surface. images.cleanstart.com publishes 34%+ + * average CVE reduction across 33,357+ remediated CVEs. + * - It collides with nothing on the site. There is no sequence, so it cannot be + * confused with the Code/Build/Test/Deploy section below; there is no + * inspection, magnifier or shield, so it does not echo the financial services + * hero, which is about scrutiny where this is about removal. + * + * SaaS stays a light touch, deliberately. The finance sibling is only "finance" + * through a small bank glyph in its backdrop; the container is the subject + * there too. Trying to make the artifact itself say "SaaS" is what produced the + * rejected dashboards. The H1 and the copy carry that. + * + * No numbers are drawn on it. A figure next to two stacks invites the reader to + * read the height difference as that ratio, and it is an illustration, not a + * chart. + */ + +const VIEW_W = 520; +const BASELINE = 322; +const STACK_W = 176; +const LEFT_X = 56; +const RIGHT_X = 288; +const BAND_GAP = 5; + +const LEFT_BANDS = [34, 28, 32, 26, 30, 34, 28] as const; +const RIGHT_BANDS = [28, 22, 26] as const; + +/* + * Deterministic, not Math.random: the package layout must be identical on the + * server and the client or React throws a hydration mismatch. + */ +function rnd(i: number): number { + let x = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); + x = (x ^ (x >>> 13)) >>> 0; + return x / 4294967296; +} + +interface Rect { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; +} + +/* + * Two package treatments, because size alone was not doing enough work. The + * public image gets jittered heights and baselines so it reads as unruly; the + * rebuild gets three identical blocks on one line so it reads as governed. Order + * versus disorder carries the idea even before the height difference registers. + */ +function densePackages(stackX: number, y: number, height: number, seed: number): readonly Rect[] { + const maxX = stackX + STACK_W - 9; + const out: Rect[] = []; + let cx = stackX + 9; + let i = 0; + + while (cx < maxX && i < 24) { + const w = Math.min(8 + Math.floor(rnd(seed + i) * 16), maxX - cx); + if (w < 6) break; + const shrink = Math.floor(rnd(seed + i + 500) * 5); + const drop = Math.floor(rnd(seed + i + 900) * 3); + out.push({ x: cx, y: y + drop, w, h: Math.max(4, height - shrink) }); + cx += w + 4; + i += 1; + } + return out; +} + +function sparsePackages(stackX: number, y: number, height: number): readonly Rect[] { + const count = 3; + const gap = 14; + const usable = STACK_W - 18 - gap * (count - 1); + const w = usable / count; + return Array.from({ length: count }, (_, i) => ({ + x: stackX + 9 + i * (w + gap), + y, + w, + h: height, + })); +} + +interface Band { + readonly y: number; + readonly h: number; + readonly packages: readonly Rect[]; +} + +function buildStack(stackX: number, heights: readonly number[], dense: boolean): readonly Band[] { + const bands: Band[] = []; + let bottom = BASELINE; + + heights.forEach((h, i) => { + const y = bottom - h; + bands.push({ + y, + h, + packages: dense + ? densePackages(stackX, y + 6, h - 12, i * 97 + 11) + : sparsePackages(stackX, y + 7, h - 14), + }); + bottom = y - BAND_GAP; + }); + + return bands; +} + +const LEFT_STACK = buildStack(LEFT_X, LEFT_BANDS, true); +const RIGHT_STACK = buildStack(RIGHT_X, RIGHT_BANDS, false); + +/* + * Three, muted, and round. The financial services page's risk-chain section + * already signals vulnerabilities with red and amber triangles; the two industry + * pages should not trade the same mark. + */ +const CVE_MARKS: readonly { x: number; y: number; fill: string }[] = [ + { x: 104, y: 303, fill: 'rgba(255,107,90,0.75)' }, + { x: 188, y: 241, fill: 'rgba(255,176,32,0.66)' }, + { x: 130, y: 148, fill: 'rgba(255,107,90,0.58)' }, +]; + +/** Stripped packages leaving the public image: removal made visible. */ +const FRAGMENTS: readonly (Rect & { o: number; r: number })[] = [ + { x: 241, y: 168, w: 22, h: 8, o: 0.32, r: -12 }, + { x: 268, y: 140, w: 17, h: 8, o: 0.27, r: 9 }, + { x: 247, y: 112, w: 24, h: 7, o: 0.21, r: -6 }, + { x: 281, y: 88, w: 14, h: 7, o: 0.16, r: 14 }, + { x: 258, y: 62, w: 19, h: 6, o: 0.11, r: -9 }, + { x: 292, y: 42, w: 11, h: 6, o: 0.07, r: 6 }, +]; + +function Label({ x, text, bright }: { x: number; text: string; bright: boolean }) { + return ( + + {text} + + ); +} + +export function SaasHeroAttackSurface(): React.ReactElement { + return ( + + + + + + + + + + + + + + {/* Shared baseline. Without it the two stacks float and the height + comparison stops being a comparison. */} + + + + {FRAGMENTS.map((f) => ( + + ))} + + + {/* Public base image: dimmed, dense, carrying the CVEs. */} + {LEFT_STACK.map((band, i) => ( + + + {band.packages.map((p) => ( + + ))} + + ))} + + + {CVE_MARKS.map((c) => ( + + ))} + + + {/* CleanStart rebuild: fewer layers, sparse packages, full saturation. It + is smaller than the image beside it and still the brightest thing in + the frame, which is the whole argument. */} + + + {RIGHT_STACK.map((band, i) => ( + + + {band.packages.map((p) => ( + + ))} + + ))} + + + + + + + ); +} diff --git a/apps/web/src/components/sections/saas/SaasHeroDashboard.tsx b/apps/web/src/components/sections/saas/SaasHeroDashboard.tsx deleted file mode 100644 index 3f57ebe63..000000000 --- a/apps/web/src/components/sections/saas/SaasHeroDashboard.tsx +++ /dev/null @@ -1,383 +0,0 @@ -import Image from 'next/image'; -import type React from 'react'; - -/* - * SaaS hero artifact — a CleanStart Images product panel. - * - * Built in code rather than rendered. The 3D dashboard render this replaces - * (hero-app-platform.webp) was rejected, and the fault was its content, not its - * craft: a rising line chart promises revenue growth on a page that sells - * container security. - * - * So this is still a dashboard — the familiar SaaS hero move of a light product - * surface floating on a dark gradient — but every panel in it is about the - * software supply chain. Stats on the left of a catalogue, images with their - * hardening state on the right. No charts. - * - * Figures are the ones images.cleanstart.com publishes on its own landing page - * (947 images, 34%+ average CVE reduction, 4M+ packages from verified source), - * and "Security Hardened" / "FIPS Available" are the badges its image detail - * pages carry. Per-image CVE counts are deliberately absent: the catalogue does - * not publish them per image, so any number here would be invented. - * - * The four images are a SaaS service stack (edge, cache, data, runtime), not a - * list of popular tags — that is what makes the artifact belong to this page - * rather than to the finance one. - */ - -interface ImageRow { - readonly name: string; - readonly role: string; - readonly logoUrl: string; - readonly fips: boolean; - /** devicon wordmarks with heavy internal whitespace sit 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`; -} - -const ROWS: readonly ImageRow[] = [ - { - name: 'nginx', - role: 'Edge', - logoUrl: deviconLogo('nginx', 'original'), - fips: true, - logoScale: 1.2, - }, - { name: 'node', role: 'Runtime', logoUrl: deviconLogo('nodejs', 'original'), fips: true }, - { name: 'postgres', role: 'Data', logoUrl: deviconLogo('postgresql', 'original'), fips: true }, - { name: 'redis', role: 'Cache', logoUrl: deviconLogo('redis', 'original'), fips: false }, -]; - -const STATS: readonly { value: string; label: string }[] = [ - { value: '947', label: 'Hardened images' }, - { value: '34%+', label: 'Avg CVE reduction' }, - { value: '4M+', label: 'Verified packages' }, -]; - -const PANEL_TEXT = '#101322'; -const PANEL_MUTED = 'rgba(16,19,34,0.52)'; -const HAIRLINE = 'rgba(16,19,34,0.08)'; - -function RailGlyph({ active, bars }: { active: boolean; bars: number }): React.ReactElement { - return ( -
      - {Array.from({ length: bars }, (_, i) => ( - - ))} -
      - ); -} - -/* - * Blue for hardened, violet for FIPS — the CleanStart pair. The first pass used - * a conventional status green, which is the one colour on the panel that could - * belong to any dashboard on the internet. - */ -function StatusChip({ label, tone }: { label: string; tone: 'ok' | 'fips' }): React.ReactElement { - return ( - - {label} - - ); -} - -export function SaasHeroDashboard(): React.ReactElement { - return ( -
      - {/* Brand bloom behind the panel. A white surface dropped straight onto the - hero mesh reads as a cut-out; this seats it in CleanStart's blue and - violet so it belongs to the gradient it sits on. */} -
      - - {/* One rotation for the whole scene, with the floating card pushed forward - on Z inside it, so panel and card share a single vanishing point. The - left edge is the near edge: the artifact sits right of the copy, so it - turns back toward the headline rather than away off the page. - - No `will-change` and no `backdrop-filter` here — both have caused - stacking-context and GPU-promotion artifacts elsewhere on this site. */} -
      -
      - {/* Window chrome. Keeps the artifact reading as a product surface rather - than as a content card dropped on the gradient. */} -
      - - - - -
      - -
      - {/* Left rail */} -
      - - - - -
      - -
      -
      - {STATS.map((stat) => ( -
      - {/* Brand keyline above each figure. Carries CleanStart's blue - into the panel body, which is otherwise all neutrals. */} - -
      - {stat.value} -
      -
      - {stat.label} -
      -
      - ))} -
      - -
      - Image - Status -
      - - {ROWS.map((row, i) => ( -
      - - - - - - {row.name} - - - - {row.role} - - - - {row.fips && } - - -
      - ))} -
      -
      -
      -
      - - {/* Provenance card, pushed forward on Z and overlapping the panel's lower - left corner. It does the work a flat panel cannot: it breaks the - rectangle, it puts a dark brand-coloured element against the white so - the composition has two tones, and it carries the three artifacts the - page keeps promising. Sibling of the panel, not a child — the panel - clips its own overflow. */} -
      - {['SBOM', 'Signature', 'Provenance'].map((label, i) => ( - - {i > 0 && ( - - )} - - {label} - - - ))} -
      -
      - ); -} From 332aa8b3b6ab60b4e0f401bb37f9567316088880 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 15:57:59 +0530 Subject: [PATCH 09/85] feat(web): seat the SaaS hero app on a verified container image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both halves of the H1 now appear at once. Every earlier attempt showed only one of them: the catalogue panel and the product dashboard were SaaS with no container, and the attack-surface comparison was containers with no SaaS — and its flat bands did not read as container images either, they read as generic block grids. A SaaS application window sits just above an isometric container image, with a soft contact shadow so it is plainly running ON it. The container is the subject: largest object, holds the verified seal. The app is the context, and says SaaS through tenant rows, avatars and status pills rather than charts, since a rising line on this page promises revenue growth and that is what got the original render rejected. Same formula as the approved financial services hero (industry surface as context, container as subject), which is right for a page family. What differs is the industry signal and the composition: that one magnifies a container inside a finance console, this one seats an app on top of an image. Three fixes after seeing the first pass on the page: - The container was one solid box and read as a plinth, which is the "app on a platform" idea already rejected once. It is now three stacked layers with a 6px reveal between them and bright leading edges, so it reads as an IMAGE rather than a slab. - The contact shadow was a flat dark ellipse and looked like a hole punched in the top face. It is now a soft-edged radial. - A "VERIFIED CONTAINER IMAGE" caption landed 60px inside the hero's white bottom fade, where 55%-white type is invisible. The artifact is taller than the dark band above that fade, so it could not just move up; the caption is gone and the object carries itself. Measured: 560x422 at 1440 with 130px to the headline, 520px wide with 83px at 1280, no horizontal overflow at either, hidden below xl. lint, typecheck and build pass. --- apps/web/src/app/globals.css | 3 +- .../src/components/sections/saas/SaasHero.tsx | 8 +- .../sections/saas/SaasHeroAttackSurface.tsx | 301 ------------------ .../sections/saas/SaasHeroVerifiedRuntime.tsx | 267 ++++++++++++++++ 4 files changed, 272 insertions(+), 307 deletions(-) delete mode 100644 apps/web/src/components/sections/saas/SaasHeroAttackSurface.tsx create mode 100644 apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 156c56b34..2df4e009a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1316,8 +1316,7 @@ 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 (SaasHeroAttackSurface.tsx). The stacks build once from - the baseline up, then hold. A hero artifact that loops competes with the +/* SaaS hero artifact (SaasHeroVerifiedRuntime.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 diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index f59e650d1..7ecbd06eb 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -1,6 +1,6 @@ import type React from 'react'; import Link from 'next/link'; -import { SaasHeroAttackSurface } from './SaasHeroAttackSurface'; +import { SaasHeroVerifiedRuntime } from './SaasHeroVerifiedRuntime'; import { HeroReveal } from '@/components/ui/Reveal'; /* @@ -11,7 +11,7 @@ import { HeroReveal } from '@/components/ui/Reveal'; * * 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 - * (SaasHeroAttackSurface.tsx), because the render it replaced was rejected and + * (SaasHeroVerifiedRuntime.tsx), because the render it replaced was rejected and * regenerating it kept landing on stock illustration. */ export function SaasHero(): React.ReactElement { @@ -38,7 +38,7 @@ export function SaasHero(): React.ReactElement { hero makes. Built, not rendered: this was hero-app-platform.webp until the client - rejected it. See SaasHeroAttackSurface.tsx for the reasoning. There is no + rejected it. See SaasHeroVerifiedRuntime.tsx for the reasoning. There is no `priority` preload left to get wrong — the old render was preloading on phones that never painted it. */}
      - +
      diff --git a/apps/web/src/components/sections/saas/SaasHeroAttackSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAttackSurface.tsx deleted file mode 100644 index b12074435..000000000 --- a/apps/web/src/components/sections/saas/SaasHeroAttackSurface.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import type React from 'react'; - -/* - * SaaS hero artifact — attack surface, shown as size. - * - * The same container image twice, on one baseline at one scale: the public base - * image on the left, the CleanStart rebuild of it on the right. The right stack - * is a fraction of the height, and the packages that were stripped out drift - * away above the left one, so the picture reads as SUBTRACTION rather than as - * two unrelated objects. - * - * Why this and not the alternatives that were tried: - * - * - It is ONE idea, legible in about a second. Big versus small. Every earlier - * attempt (a hexagon lattice, a component constellation, a catalogue panel, a - * cutaway spec with eight simultaneous ideas) needed decoding, and a hero - * sitting beside a large headline does not get decoded. - * - Contrast comes for free. The bright minimal object against the dark hero is - * the natural composition, where a dark cross-section on the navy mesh would - * have to fight the background. - * - It is the actual product claim and it is provable: hardened, minimal, fewer - * packages, smaller attack surface. images.cleanstart.com publishes 34%+ - * average CVE reduction across 33,357+ remediated CVEs. - * - It collides with nothing on the site. There is no sequence, so it cannot be - * confused with the Code/Build/Test/Deploy section below; there is no - * inspection, magnifier or shield, so it does not echo the financial services - * hero, which is about scrutiny where this is about removal. - * - * SaaS stays a light touch, deliberately. The finance sibling is only "finance" - * through a small bank glyph in its backdrop; the container is the subject - * there too. Trying to make the artifact itself say "SaaS" is what produced the - * rejected dashboards. The H1 and the copy carry that. - * - * No numbers are drawn on it. A figure next to two stacks invites the reader to - * read the height difference as that ratio, and it is an illustration, not a - * chart. - */ - -const VIEW_W = 520; -const BASELINE = 322; -const STACK_W = 176; -const LEFT_X = 56; -const RIGHT_X = 288; -const BAND_GAP = 5; - -const LEFT_BANDS = [34, 28, 32, 26, 30, 34, 28] as const; -const RIGHT_BANDS = [28, 22, 26] as const; - -/* - * Deterministic, not Math.random: the package layout must be identical on the - * server and the client or React throws a hydration mismatch. - */ -function rnd(i: number): number { - let x = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); - x = (x ^ (x >>> 13)) >>> 0; - return x / 4294967296; -} - -interface Rect { - readonly x: number; - readonly y: number; - readonly w: number; - readonly h: number; -} - -/* - * Two package treatments, because size alone was not doing enough work. The - * public image gets jittered heights and baselines so it reads as unruly; the - * rebuild gets three identical blocks on one line so it reads as governed. Order - * versus disorder carries the idea even before the height difference registers. - */ -function densePackages(stackX: number, y: number, height: number, seed: number): readonly Rect[] { - const maxX = stackX + STACK_W - 9; - const out: Rect[] = []; - let cx = stackX + 9; - let i = 0; - - while (cx < maxX && i < 24) { - const w = Math.min(8 + Math.floor(rnd(seed + i) * 16), maxX - cx); - if (w < 6) break; - const shrink = Math.floor(rnd(seed + i + 500) * 5); - const drop = Math.floor(rnd(seed + i + 900) * 3); - out.push({ x: cx, y: y + drop, w, h: Math.max(4, height - shrink) }); - cx += w + 4; - i += 1; - } - return out; -} - -function sparsePackages(stackX: number, y: number, height: number): readonly Rect[] { - const count = 3; - const gap = 14; - const usable = STACK_W - 18 - gap * (count - 1); - const w = usable / count; - return Array.from({ length: count }, (_, i) => ({ - x: stackX + 9 + i * (w + gap), - y, - w, - h: height, - })); -} - -interface Band { - readonly y: number; - readonly h: number; - readonly packages: readonly Rect[]; -} - -function buildStack(stackX: number, heights: readonly number[], dense: boolean): readonly Band[] { - const bands: Band[] = []; - let bottom = BASELINE; - - heights.forEach((h, i) => { - const y = bottom - h; - bands.push({ - y, - h, - packages: dense - ? densePackages(stackX, y + 6, h - 12, i * 97 + 11) - : sparsePackages(stackX, y + 7, h - 14), - }); - bottom = y - BAND_GAP; - }); - - return bands; -} - -const LEFT_STACK = buildStack(LEFT_X, LEFT_BANDS, true); -const RIGHT_STACK = buildStack(RIGHT_X, RIGHT_BANDS, false); - -/* - * Three, muted, and round. The financial services page's risk-chain section - * already signals vulnerabilities with red and amber triangles; the two industry - * pages should not trade the same mark. - */ -const CVE_MARKS: readonly { x: number; y: number; fill: string }[] = [ - { x: 104, y: 303, fill: 'rgba(255,107,90,0.75)' }, - { x: 188, y: 241, fill: 'rgba(255,176,32,0.66)' }, - { x: 130, y: 148, fill: 'rgba(255,107,90,0.58)' }, -]; - -/** Stripped packages leaving the public image: removal made visible. */ -const FRAGMENTS: readonly (Rect & { o: number; r: number })[] = [ - { x: 241, y: 168, w: 22, h: 8, o: 0.32, r: -12 }, - { x: 268, y: 140, w: 17, h: 8, o: 0.27, r: 9 }, - { x: 247, y: 112, w: 24, h: 7, o: 0.21, r: -6 }, - { x: 281, y: 88, w: 14, h: 7, o: 0.16, r: 14 }, - { x: 258, y: 62, w: 19, h: 6, o: 0.11, r: -9 }, - { x: 292, y: 42, w: 11, h: 6, o: 0.07, r: 6 }, -]; - -function Label({ x, text, bright }: { x: number; text: string; bright: boolean }) { - return ( - - {text} - - ); -} - -export function SaasHeroAttackSurface(): React.ReactElement { - return ( - - - - - - - - - - - - - - {/* Shared baseline. Without it the two stacks float and the height - comparison stops being a comparison. */} - - - - {FRAGMENTS.map((f) => ( - - ))} - - - {/* Public base image: dimmed, dense, carrying the CVEs. */} - {LEFT_STACK.map((band, i) => ( - - - {band.packages.map((p) => ( - - ))} - - ))} - - - {CVE_MARKS.map((c) => ( - - ))} - - - {/* CleanStart rebuild: fewer layers, sparse packages, full saturation. It - is smaller than the image beside it and still the brightest thing in - the frame, which is the whole argument. */} - - - {RIGHT_STACK.map((band, i) => ( - - - {band.packages.map((p) => ( - - ))} - - ))} - - - - - - - ); -} diff --git a/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx b/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx new file mode 100644 index 000000000..26e2debbc --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx @@ -0,0 +1,267 @@ +import type React from 'react'; + +/* + * SaaS hero artifact — a SaaS application, and the verified container image it + * runs on. + * + * Both halves of the H1 have to be visible at once. Earlier attempts each showed + * only one: the catalogue panel and the product dashboard were SaaS with no + * container, and the two-stack attack-surface comparison was containers with no + * SaaS (and its flat bands did not read as container images either — they read + * as generic block grids). + * + * Composition, and the order matters: + * + * - The CONTAINER IMAGE is the subject: the largest object, isometric so it + * reads as a physical thing rather than a diagram, and built as a STACK OF + * LAYERS rather than one solid box. A single box read as a plinth, which is + * the "app on a platform" idea that was already rejected once; discrete + * layers are what make it an image instead of a slab. + * - The SaaS APPLICATION is the context, seated just above the top layer so it + * is plainly running ON the image. + * + * The app window says SaaS through tenant rows, avatars and status pills. No + * charts: a rising line on this page promises revenue growth, which is what got + * the original render rejected. + * + * No caption. One read "VERIFIED CONTAINER IMAGE" and landed 60px inside the + * hero's white bottom fade, where 55%-white type is invisible — and the artifact + * is taller than the dark band above that fade, so it could not simply be moved + * up. The object carries itself: layered isometric stack, verified seal, under a + * headline that already says Container Security. + * + * Same formula as the approved financial services hero (industry surface as + * context, container as subject), which is right for a page family. What differs + * is the industry signal and the composition: that one magnifies a container + * inside a finance console, this one seats an app on top of an image. + */ + +const VIEW_W = 520; +const VIEW_H = 392; + +/* Points, not tuples: `noUncheckedIndexedAccess` makes every array index + `number | undefined`, which is noise for fixed geometry like this. */ +interface Point { + readonly x: number; + readonly y: number; +} + +const CX = 262; +const HALF_W = 132; +const HALF_D = 35; +const THICK = 26; + +/** Top to bottom. Centres 32 apart against a 26 thickness leaves a 6px reveal + between layers, which is what stops the stack reading as one solid box. */ +const LAYER_Y = [246, 278, 310] as const; + +const APP_X = 152; +const APP_Y = 58; +const APP_W = 232; +const APP_H = 132; + +interface TenantRow { + readonly y: number; + readonly nameW: number; + readonly pillW: number; +} + +const TENANTS: readonly TenantRow[] = [ + { y: 100, nameW: 62, pillW: 34 }, + { y: 124, nameW: 48, pillW: 28 }, + { y: 148, nameW: 70, pillW: 34 }, + { y: 172, nameW: 54, pillW: 26 }, +]; + +function poly(...points: readonly Point[]): string { + return points.map((p) => `${p.x},${p.y}`).join(' '); +} + +function Layer({ cy, top }: { cy: number; top: boolean }): React.ReactElement { + const t: Point = { x: CX, y: cy - HALF_D }; + const r: Point = { x: CX + HALF_W, y: cy }; + const b: Point = { x: CX, y: cy + HALF_D }; + const l: Point = { x: CX - HALF_W, y: cy }; + const drop = (p: Point): Point => ({ x: p.x, y: p.y + THICK }); + + return ( + + + + + {/* Bright leading edges catch the light and separate one layer from the + next; without them the stack flattens into a single mass. */} + + + + ); +} + +export function SaasHeroVerifiedRuntime(): React.ReactElement { + const topLayerY = LAYER_Y[0]; + const bottomLayerY = LAYER_Y[LAYER_Y.length - 1] ?? 310; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + {/* Soft-edged, not a flat ellipse. A hard dark ellipse on the top face + read as a hole punched in it rather than as a shadow. */} + + + + + + + + + + {/* Drawn bottom layer first so each upper layer occludes the one beneath, + which is what gives the stack its depth order. */} + + {[...LAYER_Y].reverse().map((cy) => ( + + ))} + + {/* Verified seal, straddling the stack's front corner so it reads as + applied to the whole image rather than to one layer. */} + + + + + + + + + {/* SaaS application — the context. Tenants, not charts. */} + + + + {[0, 1, 2].map((i) => ( + + ))} + + + {TENANTS.map((row) => ( + + + + + + ))} + + + ); +} From 28511e548dcb7c15c2720ce99f72b51fa17de71b Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 15:59:07 +0530 Subject: [PATCH 10/85] docs(web): specify SaaS stage paint order --- .../plans/2026-09-01-saas-verified-core.md | 37 +++++++++++++++++++ .../2026-09-01-saas-verified-core-design.md | 2 + 2 files changed, 39 insertions(+) diff --git a/docs/superpowers/plans/2026-09-01-saas-verified-core.md b/docs/superpowers/plans/2026-09-01-saas-verified-core.md index 6bc1f4222..6a22f58d9 100644 --- a/docs/superpowers/plans/2026-09-01-saas-verified-core.md +++ b/docs/superpowers/plans/2026-09-01-saas-verified-core.md @@ -330,3 +330,40 @@ 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. 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 index 3d8f9e989..ea940631d 100644 --- a/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md +++ b/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md @@ -40,6 +40,7 @@ The late-review route sits above the primary route at reduced visual weight. It - 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. - Texture: fine grid, coordinate ticks, and restrained bloom provide depth without obscuring labels or flow. - Typography: all visible text uses the canonical `--fs-*`, `--font-display`, and `--font-sans` role tokens. @@ -109,6 +110,7 @@ Verification requires the focused SaaS test, the web package test suite, lint, t - 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 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. From 8bc399111cb5b6216f695c7aefb1a4e046f8bf0a Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 16:02:36 +0530 Subject: [PATCH 11/85] fix(web): show the SaaS hero artifact from tablet up, not just xl The artifact was gated at xl, so anyone on a 768-1279px window saw a headline and a CTA against an empty right half. It now renders from md and is hidden on phones only, where there genuinely is no room beside the headline and a decorative artifact should drop rather than stack. Making it work below 1280 means scaling both sides, since the artifact and the copy compete for one row. The artifact goes to clamp(280px, 38vw, 560px): 286px at 768, 386px at 1030, 542px at 1440. It is pure geometry with no type in it, so it survives scaling in a way the earlier caption-bearing versions would not have. The copy column now yields room via calc(100% - clamp(280px,38vw,560px) - 56px), so its budget tracks the artifact instead of being re-guessed per breakpoint. 100% of the padded container, NOT 100vw: vw includes the scrollbar, which handed the copy 15px it did not have and left only a 24px gap to the artifact at 1030px. With 100% the gap is a steady 56px through md and lg, widening to 148px at 1440 where the tighter xl cap takes over. Measured: 768 gives a 3-line headline, 286px artifact, 56px gap; 1030 gives 386px and 56px; 1440 gives 542px and 148px. No horizontal overflow at any width, still hidden at 375. lint, typecheck and build pass. --- .../src/components/sections/saas/SaasHero.tsx | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index 7ecbd06eb..a20bf7966 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -32,10 +32,14 @@ export function SaasHero(): React.ReactElement { decoding="async" /> - {/* Hero artifact, pinned right and only at xl+. Below 1280px there is no - width that holds both the artifact and the headline without one of them - being squeezed. 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 SaasHeroVerifiedRuntime.tsx for the reasoning. There is no @@ -43,7 +47,7 @@ export function SaasHero(): React.ReactElement { phones that never painted it. */}
      @@ -99,10 +102,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 artifact'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. */} +

      SaaS Companies + Container Security for SaaS Companies

      From 132887befe86cc47a42bc73fc5f983026c9d56ac Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 16:09:41 +0530 Subject: [PATCH 12/85] feat(web): fill out the SaaS hero artifact and close the middle gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, one cause. The headline is short, so its text ended at 533px while the artifact began at 844px — a 310px hole in the middle of the hero against a 40px margin on the right. And the drawing itself was mostly padding: the subject occupied 264px of a 520px viewBox, so much of what sat on the right was empty space rather than illustration. Filled the frame: - Two dim satellite stacks flank the subject. They carry a real idea as well as ballast: this is one image out of a catalogue, not a single artifact. Dropped well back in opacity so they read as depth rather than as three competing objects. - A masked dot field gives the frame texture and fades out at the edges, so it stops rather than being sliced by the viewBox. - The container stack and the app window are both larger, and the layer geometry widened with them. Then moved the whole artifact off the right edge. `right` was a flat 40px mirroring the copy's left padding, which was correct on paper and wrong on screen; it is now clamp(24px, 4.5vw, 76px), and the width went from 38vw/560 to 42vw/600 now that the extra box holds content instead of air. Measured at 1440: the visual gap drops 310px to 229px and the right margin rises 40px to 64px, which is a far better balance for a right-weighted composition. 1030 gives a 426px artifact with a 42px box gap, 768 gives 316px with 54px and a 3-line headline. No horizontal overflow at any width, still hidden at 375. lint, typecheck and build pass. --- .../src/components/sections/saas/SaasHero.tsx | 23 +- .../sections/saas/SaasHeroVerifiedRuntime.tsx | 205 +++++++++++------- .../sections/saas/SaasShiftLeft.test.tsx | 15 ++ .../sections/saas/SaasVerifiedCore.module.css | 14 +- 4 files changed, 171 insertions(+), 86 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index a20bf7966..0bcc0a762 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -65,19 +65,26 @@ export function SaasHero(): React.ReactElement { // `right-0`. The wrapper is capped at --container-default while the // copy sits inside px-10, so right-0 hung the render 40px past the // text's mirror line and it read as escaping the grid. - right: '40px', + // Was a flat 40px, mirroring the copy's left padding. That looked + // right on paper but not on screen: the headline is short, so its + // text ended at 533 while the artifact began at 844 — a 310px hole + // in the middle against a 40px margin on the right. Pulling the + // artifact off the edge trades some of that hole for a right margin + // that is still smaller than the gap it closes. + right: 'clamp(24px, 4.5vw, 76px)', // Retuned for the two-line headline. The previous 44% / 540px pair // was fitted to a three-line one; against the shorter headline it // left a 352px hole in the middle of the composition and the // artifact had no weight to answer the copy with. top: '46%', transform: 'translateY(-50%)', - // Scales with the viewport now that it renders from md up: about - // 292px at 768, 389px at 1024, 486px at 1280 and 547px at 1440, - // capped at 560. The artifact is pure geometry with no type in it, - // so it survives being scaled down in a way the earlier - // caption-bearing versions would not have. - width: 'clamp(280px, 38vw, 560px)', + // Scales with the viewport now that it renders from md up. Widened + // from 38vw/560 because the drawing itself was mostly padding; with + // the frame filled, a bigger box puts real content closer to the + // copy instead of more empty space. Pure geometry with no type in + // it, so it survives scaling in a way the earlier caption-bearing + // versions would not have. + width: 'clamp(280px, 42vw, 600px)', }} > @@ -111,7 +118,7 @@ export function SaasHero(): React.ReactElement { 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. */} -
      +

      `${p.x},${p.y}`).join(' '); } -function Layer({ cy, top }: { cy: number; top: boolean }): React.ReactElement { - const t: Point = { x: CX, y: cy - HALF_D }; - const r: Point = { x: CX + HALF_W, y: cy }; - const b: Point = { x: CX, y: cy + HALF_D }; - const l: Point = { x: CX - HALF_W, y: cy }; - const drop = (p: Point): Point => ({ x: p.x, y: p.y + THICK }); +interface SlabProps { + readonly cx: number; + readonly cy: number; + readonly halfW: number; + readonly halfD: number; + readonly thick: number; + readonly edge: string; +} + +function Slab({ cx, cy, halfW, halfD, thick, edge }: SlabProps): React.ReactElement { + const t: Point = { x: cx, y: cy - halfD }; + const r: Point = { x: cx + halfW, y: cy }; + const b: Point = { x: cx, y: cy + halfD }; + const l: Point = { x: cx - halfW, y: cy }; + const drop = (p: Point): Point => ({ x: p.x, y: p.y + thick }); return ( @@ -104,30 +111,49 @@ function Layer({ cy, top }: { cy: number; top: boolean }): React.ReactElement { stroke="rgba(255,255,255,0.18)" strokeWidth={1.2} /> - {/* Bright leading edges catch the light and separate one layer from the - next; without them the stack flattens into a single mass. */} - - + {/* Leading edges catch the light and separate one layer from the next; + without them the stack flattens into a single mass. */} + + ); } +/** A dim two-layer stack: one more image in the catalogue, and frame ballast. */ +function Satellite({ cx, cy, scale }: { cx: number; cy: number; scale: number }) { + const halfW = 46 * scale; + const halfD = 13 * scale; + const thick = 11 * scale; + return ( + <> + {[cy + 16 * scale, cy].map((y) => ( + + ))} + + ); +} + +/** Deterministic is not needed here — the grid is a fixed lattice. */ +function DotField(): React.ReactElement { + const dots: React.ReactElement[] = []; + for (let x = 24; x <= VIEW_W - 16; x += 30) { + for (let y = 28; y <= VIEW_H - 24; y += 30) { + dots.push(); + } + } + return {dots}; +} + export function SaasHeroVerifiedRuntime(): React.ReactElement { - const topLayerY = LAYER_Y[0]; + const topLayerY = LAYER_Y[0] ?? 242; const bottomLayerY = LAYER_Y[LAYER_Y.length - 1] ?? 310; return ( @@ -155,8 +181,8 @@ export function SaasHeroVerifiedRuntime(): React.ReactElement { - - + + {/* Soft-edged, not a flat ellipse. A hard dark ellipse on the top face @@ -166,39 +192,64 @@ export function SaasHeroVerifiedRuntime(): React.ReactElement { + {/* Fades the dot field out at the frame edges so it stops rather than + being sliced off by the viewBox. */} + + + + + + + + - + + + + + - {/* Drawn bottom layer first so each upper layer occludes the one beneath, + {/* Satellites sit behind and outside the subject, dimmed well back so they + read as depth rather than as three competing objects. */} + + + + + + + + {/* Bottom layer drawn first so each upper layer occludes the one beneath, which is what gives the stack its depth order. */} {[...LAYER_Y].reverse().map((cy) => ( - + ))} {/* Verified seal, straddling the stack's front corner so it reads as applied to the whole image rather than to one layer. */} - - + + - + {/* SaaS application — the context. Tenants, not charts. */} @@ -230,9 +281,9 @@ export function SaasHeroVerifiedRuntime(): React.ReactElement { /> ))} { expect(stageIconRule).toContain('height: 27px'); }); + it('layers the provenance rail behind an opaque icon plate', () => { + const stylesheet = readFileSync( + new URL('./SaasVerifiedCore.module.css', import.meta.url), + 'utf8', + ); + const coreLineRule = stylesheet.match(/\.coreLine\s*{([^}]*)}/)?.[1] ?? ''; + const iconPlateRule = stylesheet.match(/\.coreWindow::after\s*{([^}]*)}/)?.[1] ?? ''; + const stageIconRule = stylesheet.match(/\.stageIcon\s*{([^}]*)}/)?.[1] ?? ''; + + expect(coreLineRule).toContain('z-index: 0'); + expect(iconPlateRule).toContain('z-index: 1'); + expect(iconPlateRule).toContain('background: radial-gradient'); + expect(stageIconRule).toContain('z-index: 2'); + }); + it('contrasts an open release with a closed late-review return', () => { const html = renderSection(); diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css index 8310f107d..2149a3b8f 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css @@ -221,6 +221,7 @@ .coreLine { position: absolute; + z-index: 0; top: 50%; right: -17px; left: -17px; @@ -230,9 +231,20 @@ background: linear-gradient(90deg, #7fe3ff, #5fe3c0); } +.coreWindow::after { + position: absolute; + z-index: 1; + inset: 2px; + border-radius: inherit; + background: radial-gradient(circle at 50% 42%, #0c3548 0%, #061f31 68%, #051725 100%); + box-shadow: inset 0 0 0 1px rgba(127, 227, 255, 0.08); + content: ''; + pointer-events: none; +} + .stageIcon { position: relative; - z-index: 1; + z-index: 2; width: 27px; height: 27px; overflow: visible; From 308d371c804b535e36f958a4f6a0537a6a84bb7c Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 16:12:01 +0530 Subject: [PATCH 13/85] test(web): lock SaaS stage icon paint order --- .../web/src/components/sections/saas/SaasShiftLeft.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx index aacb21469..dc1622cc4 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx @@ -83,9 +83,13 @@ describe('SaasShiftLeft', () => { const iconPlateRule = stylesheet.match(/\.coreWindow::after\s*{([^}]*)}/)?.[1] ?? ''; const stageIconRule = stylesheet.match(/\.stageIcon\s*{([^}]*)}/)?.[1] ?? ''; + expect(coreLineRule).toContain('position: absolute'); expect(coreLineRule).toContain('z-index: 0'); + expect(iconPlateRule).toContain('position: absolute'); expect(iconPlateRule).toContain('z-index: 1'); - expect(iconPlateRule).toContain('background: radial-gradient'); + expect(iconPlateRule).toContain('#0c3548 0%'); + expect(iconPlateRule).toContain('#051725 100%'); + expect(stageIconRule).toContain('position: relative'); expect(stageIconRule).toContain('z-index: 2'); }); From adfd08a94c38a64608ad97191c667943e8078251 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 16:14:32 +0530 Subject: [PATCH 14/85] docs(web): specify SaaS scanner mask and grid fade --- .../plans/2026-09-01-saas-verified-core.md | 30 +++++++++++++++++++ .../2026-09-01-saas-verified-core-design.md | 5 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-01-saas-verified-core.md b/docs/superpowers/plans/2026-09-01-saas-verified-core.md index 6a22f58d9..e2cf41929 100644 --- a/docs/superpowers/plans/2026-09-01-saas-verified-core.md +++ b/docs/superpowers/plans/2026-09-01-saas-verified-core.md @@ -367,3 +367,33 @@ 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-saas-verified-core-design.md b/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md index ea940631d..088cfcddf 100644 --- a/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md +++ b/docs/superpowers/specs/2026-09-01-saas-verified-core-design.md @@ -42,7 +42,8 @@ The late-review route sits above the primary route at reduced visual weight. It - 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. -- Texture: fine grid, coordinate ticks, and restrained bloom provide depth without obscuring labels or flow. +- 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 @@ -111,6 +112,8 @@ Verification requires the focused SaaS test, the web package test suite, lint, t - 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. From 498a32d8ef87a16366d066aeb6ba13cc9dee4412 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 16:16:07 +0530 Subject: [PATCH 15/85] feat(web): give the SaaS hero container a real surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base was a bare gradient plane, which read as unfinished rather than restrained: a material with no surface. Two additions fix that, and both are structural rather than decorative. Corrugation ribs on every visible side face. This is the single strongest cue that the object is a CONTAINER rather than a stack of slabs, and it is the same ribbing the Clean Images artifact already uses elsewhere on the site. Vertical on screen, because the isometric projection maps height straight down. Satellites take ribs={0}: at their rendered size the lines would only turn to mush. Surface treatment on the one visible top face: an isometric grid echoing the hero's own gridline overlay, an inset edge line for craft, and a soft sheen so it reads as a lit material. Deliberately no logo, which was the alternative considered. This is the top of a container IMAGE, and a mark stamped on it turns the object into branded packaging — a cheaper read, and the 3D-render-with-logo look the client rejected in the first place. The app window also covers the middle of that face, so a centred graphic would be half-occluded and look like a defect rather than a decision. lint, typecheck and build pass. --- .../sections/saas/SaasHeroVerifiedRuntime.tsx | 120 +++++++++++++++++- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx b/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx index 80618b748..23926a425 100644 --- a/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx @@ -53,6 +53,8 @@ const THICK = 28; between layers, which is what stops the stack reading as one solid box. */ const LAYER_Y = [242, 276, 310] as const; +const TOP_LAYER_Y = LAYER_Y[0]; + const APP_X = 146; const APP_Y = 44; const APP_W = 250; @@ -82,9 +84,12 @@ interface SlabProps { readonly halfD: number; readonly thick: number; readonly edge: string; + /** Corrugation ribs per visible side face. 0 for the dim satellites, where + they would only turn to mush at the rendered size. */ + readonly ribs?: number; } -function Slab({ cx, cy, halfW, halfD, thick, edge }: SlabProps): React.ReactElement { +function Slab({ cx, cy, halfW, halfD, thick, edge, ribs = 0 }: SlabProps): React.ReactElement { const t: Point = { x: cx, y: cy - halfD }; const r: Point = { x: cx + halfW, y: cy }; const b: Point = { x: cx, y: cy + halfD }; @@ -111,6 +116,38 @@ function Slab({ cx, cy, halfW, halfD, thick, edge }: SlabProps): React.ReactElem stroke="rgba(255,255,255,0.18)" strokeWidth={1.2} /> + {/* Corrugation. This is the single strongest cue that the object is a + CONTAINER rather than a stack of slabs — the same ribbing the Clean + Images artifact uses elsewhere on the site. Vertical on screen because + the isometric projection maps height straight down. */} + {Array.from({ length: Math.max(0, ribs - 1) }, (_, i) => { + const f = (i + 1) / ribs; + const lx = l.x + (b.x - l.x) * f; + const ly = l.y + (b.y - l.y) * f; + const rx = b.x + (r.x - b.x) * f; + const ry = b.y + (r.y - b.y) * f; + return ( + + + + + ); + })} + {/* Leading edges catch the light and separate one layer from the next; without them the stack flattens into a single mass. */} @@ -141,6 +178,64 @@ function Satellite({ cx, cy, scale }: { cx: number; cy: number; scale: number }) ); } +/* + * Surface treatment for the one visible top face. + * + * Deliberately NOT a logo. This is the top of a container IMAGE, and a mark + * stamped on it turns the object into branded packaging, which is both a + * cheaper read and the 3D-render-with-logo look the client rejected. The app + * window also covers the middle of this face, so a centred graphic would be + * half-occluded and look like a bug. + * + * Left flat, though, the face read as unfinished rather than restrained — a + * material with no surface. An isometric grid (echoing the hero's own gridline + * overlay, so it stays on-system), an inset edge line for craft, and a soft + * sheen give it substance without adding a message. + */ +function TopSurface({ cy }: { cy: number }): React.ReactElement { + const t: Point = { x: CX, y: cy - HALF_D }; + const r: Point = { x: CX + HALF_W, y: cy }; + const b: Point = { x: CX, y: cy + HALF_D }; + const l: Point = { x: CX - HALF_W, y: cy }; + + // Barycentric-ish walk across the rhombus: P = T + a·(T→R) + k·(T→L). + const at = (a: number, k: number): Point => ({ + x: t.x + a * HALF_W - k * HALF_W, + y: t.y + a * HALF_D + k * HALF_D, + }); + const inset = (p: Point): Point => ({ + x: CX + (p.x - CX) * 0.9, + y: cy + (p.y - cy) * 0.9, + }); + + const steps = [1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6]; + const line = (from: Point, to: Point, key: string) => ( + + ); + + return ( + + {steps.map((v) => line(at(v, 0), at(v, 1), `a${v}`))} + {steps.map((v) => line(at(0, v), at(1, v), `k${v}`))} + + + + ); +} + /** Deterministic is not needed here — the grid is a fixed lattice. */ function DotField(): React.ReactElement { const dots: React.ReactElement[] = []; @@ -153,7 +248,6 @@ function DotField(): React.ReactElement { } export function SaasHeroVerifiedRuntime(): React.ReactElement { - const topLayerY = LAYER_Y[0] ?? 242; const bottomLayerY = LAYER_Y[LAYER_Y.length - 1] ?? 310; return ( @@ -199,6 +293,21 @@ export function SaasHeroVerifiedRuntime(): React.ReactElement { + + + + + + + + @@ -230,10 +339,13 @@ export function SaasHeroVerifiedRuntime(): React.ReactElement { halfW={HALF_W} halfD={HALF_D} thick={THICK} - edge={cy === topLayerY ? 'rgba(255,255,255,0.5)' : 'rgba(255,255,255,0.32)'} + ribs={8} + edge={cy === TOP_LAYER_Y ? 'rgba(255,255,255,0.5)' : 'rgba(255,255,255,0.32)'} /> ))} + + {/* Verified seal, straddling the stack's front corner so it reads as applied to the whole image rather than to one layer. */} @@ -249,7 +361,7 @@ export function SaasHeroVerifiedRuntime(): React.ReactElement { - + {/* SaaS application — the context. Tenants, not charts. */} From 2412e212b7b76a9e2aa0da2176c85f2b7905a715 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 16:20:13 +0530 Subject: [PATCH 16/85] fix(web): make the SaaS hero base read as an image, not as freight The previous commit put shipping-container corner castings at the base. Wrong register: this is a container IMAGE, and literal freight hardware turns it into a box of cargo. Removed. Replaced with signals from the software vocabulary, so the bottom layer reads as the hardened base image the rest is built on: - A digest strip along its front face: a short run of uneven marks standing in for a content hash, which is what actually identifies and pins an image. - A violet underglow beneath the base, in the same violet as the verified seal in front of it, so the foundation and the seal read as one statement rather than two unrelated marks. lint and build pass. --- .../sections/saas/SaasHeroVerifiedRuntime.tsx | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx b/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx index 23926a425..1af6406e7 100644 --- a/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx @@ -156,6 +156,42 @@ function Slab({ cx, cy, halfW, halfD, thick, edge, ribs = 0 }: SlabProps): React ); } +/* + * Base-layer treatment. The bottom layer is the hardened BASE IMAGE, and it + * should look like the sealed foundation the rest is built on. + * + * Software vocabulary only. An earlier pass put shipping-container corner + * castings here, which was the wrong register entirely: this is a container + * image, not cargo, and literal freight hardware makes it a box of goods. So + * the signals are a digest strip — the short run of uneven marks that reads as + * a content hash, which is what actually identifies and pins an image — and a + * violet underglow tying the base to the verified seal in front of it. + */ +function BaseTreatment({ cy }: { cy: number }): React.ReactElement { + const l: Point = { x: CX - HALF_W, y: cy }; + const b: Point = { x: CX, y: cy + HALF_D }; + const widths = [7, 4, 9, 5, 6, 3, 8, 5, 4, 7]; + + return ( + + {widths.map((w, i) => { + const f = 0.12 + (i / widths.length) * 0.76; + return ( + + ); + })} + + ); +} + /** A dim two-layer stack: one more image in the catalogue, and frame ballast. */ function Satellite({ cx, cy, scale }: { cx: number; cy: number; scale: number }) { const halfW = 46 * scale; @@ -288,6 +324,11 @@ export function SaasHeroVerifiedRuntime(): React.ReactElement { {/* Fades the dot field out at the frame edges so it stops rather than being sliced off by the viewBox. */} + + + + + @@ -328,6 +369,16 @@ export function SaasHeroVerifiedRuntime(): React.ReactElement { + {/* Seals the foundation visually: the base sits in the same violet the + verified mark uses, so the two read as one statement. */} + + {/* Bottom layer drawn first so each upper layer occludes the one beneath, which is what gives the stack its depth order. */} @@ -345,6 +396,7 @@ export function SaasHeroVerifiedRuntime(): React.ReactElement { ))} + {/* Verified seal, straddling the stack's front corner so it reads as applied to the whole image rather than to one layer. */} From 1e2f58d6540c9747fe643fcbe7433602a4ce9634 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 16:27:30 +0530 Subject: [PATCH 17/85] feat(web): make the SaaS hero seal a shield with a checkmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verified mark at the stack's front corner was a disc with a tick; it is now a shield carrying the same tick. Same position, same violet, one clearer read. It is deliberately the ONLY security mark on the artifact. A second shield laid flat on the top plane was built and pulled: two marks say the same thing twice, and a symbol foreshortened into the isometric plane reads as a sticker dropped on the surface, where one facing the viewer reads as a seal applied to the whole image. The lit occupancy cells briefly added to the top face came out with it. One idea per surface — the plane keeps its isometric grid, sheen and inset edge, which give it material without competing with the seal. Worth noting for later: the FIPS page owns the shield as its signature object (shield-glow.webp, shield-only.webp). At this size, inside a container stack and rendered flat rather than as a 3D hero object, the two do not collide — but if FIPS and SaaS ever sit side by side in the same view, this is the element to re-check. lint and build pass. --- .../sections/saas/SaasHeroVerifiedRuntime.tsx | 28 +++++++++++++++---- .../sections/saas/SaasShiftLeft.test.tsx | 22 +++++++++++++++ .../sections/saas/SaasVerifiedCore.module.css | 19 ++++++++++--- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx b/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx index 1af6406e7..76c3769cb 100644 --- a/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx @@ -227,6 +227,11 @@ function Satellite({ cx, cy, scale }: { cx: number; cy: number; scale: number }) * material with no surface. An isometric grid (echoing the hero's own gridline * overlay, so it stays on-system), an inset edge line for craft, and a soft * sheen give it substance without adding a message. + * + * A few grid cells are then lit, which turns the surface from texture into + * content: components seated on the verified base. Using the grid that is + * already there beats dropping a foreign shape onto the plane — the marks + * inherit the isometric projection for free and cannot read as a sticker. */ function TopSurface({ cy }: { cy: number }): React.ReactElement { const t: Point = { x: CX, y: cy - HALF_D }; @@ -245,6 +250,7 @@ function TopSurface({ cy }: { cy: number }): React.ReactElement { }); const steps = [1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6]; + const line = (from: Point, to: Point, key: string) => ( {/* Verified seal, straddling the stack's front corner so it reads as - applied to the whole image rather than to one layer. */} - - + applied to the whole image rather than to one layer. + + A shield rather than a disc, and the ONLY security mark on the + artifact. A second one laid flat on the top plane was tried and + pulled: two marks say the same thing twice, and a symbol + foreshortened into the isometric plane reads as a sticker, where one + facing the viewer reads as a seal. */} + + diff --git a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx index dc1622cc4..ee08c74e2 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx @@ -93,6 +93,28 @@ describe('SaasShiftLeft', () => { expect(stageIconRule).toContain('z-index: 2'); }); + it('masks the rail inside the scanner and fades the desktop grid', () => { + const stylesheet = readFileSync( + new URL('./SaasVerifiedCore.module.css', import.meta.url), + 'utf8', + ); + const scannerFrameRule = stylesheet.match(/\.scannerFrame\s*{([^}]*)}/)?.[1] ?? ''; + const desktopSurfaceRules = [ + ...stylesheet.matchAll(/\.desktopSurface\s*{([^}]*)}/g), + ]; + const desktopSurfaceRule = desktopSurfaceRules[desktopSurfaceRules.length - 1]?.[1] ?? ''; + const gridOverlayRule = stylesheet.match(/\.desktopSurface::after\s*{([^}]*)}/)?.[1] ?? ''; + + expect(scannerFrameRule).toContain( + 'background: linear-gradient(180deg, #081b2d 0%, #061421 100%)', + ); + expect(desktopSurfaceRule).not.toContain('rgba(138, 174, 228, 0.055)'); + expect(gridOverlayRule).toContain('rgba(138, 174, 228, 0.028)'); + expect(gridOverlayRule).toContain('-webkit-mask-image: radial-gradient'); + expect(gridOverlayRule).toContain('mask-image: radial-gradient'); + expect(gridOverlayRule).toContain('pointer-events: none'); + }); + it('contrasts an open release with a closed late-review return', () => { const html = renderSection(); diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css index 2149a3b8f..521eecaeb 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css @@ -276,7 +276,7 @@ border-right: 3px solid currentColor; border-left: 3px solid currentColor; border-radius: 10px 10px 3px 3px; - background: rgba(7, 22, 39, 0.88); + background: linear-gradient(180deg, #081b2d 0%, #061421 100%); box-shadow: 0 0 18px color-mix(in srgb, currentColor 24%, transparent); } @@ -509,10 +509,7 @@ background: radial-gradient(circle at 35% 78%, rgba(44, 193, 235, 0.16), transparent 35%), radial-gradient(circle at 82% 22%, rgba(105, 65, 203, 0.14), transparent 31%), - linear-gradient(rgba(138, 174, 228, 0.055) 1px, transparent 1px), - linear-gradient(90deg, rgba(138, 174, 228, 0.055) 1px, transparent 1px), linear-gradient(145deg, rgba(15, 25, 51, 0.98) 0%, rgba(5, 12, 27, 0.99) 52%, rgba(5, 24, 34, 0.98) 100%); - background-size: auto, auto, 42px 42px, 42px 42px, auto; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), inset 0 -1px 0 rgba(87, 218, 208, 0.08), @@ -528,6 +525,20 @@ pointer-events: none; } + .desktopSurface::after { + position: absolute; + z-index: 0; + inset: 0; + content: ''; + background: + linear-gradient(rgba(138, 174, 228, 0.028) 1px, transparent 1px), + linear-gradient(90deg, rgba(138, 174, 228, 0.028) 1px, transparent 1px); + background-size: 42px 42px; + -webkit-mask-image: radial-gradient(ellipse 78% 70% at 50% 50%, #000 0%, rgba(0, 0, 0, 0.82) 52%, transparent 100%); + mask-image: radial-gradient(ellipse 78% 70% at 50% 50%, #000 0%, rgba(0, 0, 0, 0.82) 52%, transparent 100%); + pointer-events: none; + } + .mobileSurface { display: none; } From 705bbe5cb5bc00b06eba91c7c9feeb6d5df36951 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 16:33:01 +0530 Subject: [PATCH 18/85] test(web): lock SaaS scanner and grid layering --- .../components/sections/saas/SaasShiftLeft.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx index ee08c74e2..d4a368e85 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx @@ -104,15 +104,26 @@ describe('SaasShiftLeft', () => { ]; const desktopSurfaceRule = desktopSurfaceRules[desktopSurfaceRules.length - 1]?.[1] ?? ''; const gridOverlayRule = stylesheet.match(/\.desktopSurface::after\s*{([^}]*)}/)?.[1] ?? ''; + const routeStackRule = stylesheet.match(/\.routeStack\s*{([^}]*)}/)?.[1] ?? ''; + const gridOverlayMatches = [...stylesheet.matchAll(/\.desktopSurface::after\s*{/g)]; + const desktopMediaStart = stylesheet.indexOf('@media (min-width: 1024px)'); + const desktopMediaEnd = stylesheet.indexOf('@keyframes verifiedSignal'); + const desktopMediaRule = stylesheet.slice(desktopMediaStart, desktopMediaEnd); expect(scannerFrameRule).toContain( 'background: linear-gradient(180deg, #081b2d 0%, #061421 100%)', ); expect(desktopSurfaceRule).not.toContain('rgba(138, 174, 228, 0.055)'); + expect(gridOverlayMatches).toHaveLength(1); + expect(desktopMediaRule).toContain('.desktopSurface::after'); + expect(gridOverlayRule).toContain('position: absolute'); + expect(gridOverlayRule).toContain('z-index: 0'); expect(gridOverlayRule).toContain('rgba(138, 174, 228, 0.028)'); expect(gridOverlayRule).toContain('-webkit-mask-image: radial-gradient'); expect(gridOverlayRule).toContain('mask-image: radial-gradient'); expect(gridOverlayRule).toContain('pointer-events: none'); + expect(routeStackRule).toContain('position: relative'); + expect(routeStackRule).toContain('z-index: 1'); }); it('contrasts an open release with a closed late-review return', () => { From af78d096ffcbaee3e4ef0bc8f70ad7b3f65b33c2 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 16:34:08 +0530 Subject: [PATCH 19/85] copy(web): change the SaaS H1 to the client's headline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Modern Applications Move Faster. Security Must Keep Up." replaces 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 span. Left to wrap naturally the break landed mid-clause — "Move Faster. Security" sharing a line — which destroys the parallel the headline is built on. Same treatment FinanceStack's heading uses, and not a
      . Verified in the built HTML: one h1, reading "Modern Applications Move Faster.Security Must Keep Up.", with the title tag unchanged. lint, typecheck and build pass. --- .../src/components/sections/saas/SaasHero.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index 0bcc0a762..3b13f642c 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -131,10 +131,18 @@ export function SaasHero(): React.ReactElement { marginBottom: 'clamp(24px, 2.5vw, 36px)', }} > - {/* 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
      . */} + Modern Applications Move Faster. + Security Must Keep Up.

      From 4617bcf59707b086a901be1791394e5fba29c33a Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 17:11:58 +0530 Subject: [PATCH 20/85] docs(web): specify SaaS late route removal --- .../2026-09-01-saas-remove-late-route.md | 41 +++++++++++++++++++ ...026-09-01-saas-remove-late-route-design.md | 33 +++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-01-saas-remove-late-route.md create mode 100644 docs/superpowers/specs/2026-09-01-saas-remove-late-route-design.md 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/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. From 8ac85dc9468e13266a0d78b11ad5267ab2fa69ac Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 17:20:53 +0530 Subject: [PATCH 21/85] refactor(web): remove SaaS late review route --- .../sections/saas/SaasShiftLeft.test.tsx | 22 +- .../sections/saas/SaasShiftLeft.tsx | 9 - .../sections/saas/SaasVerifiedCore.module.css | 203 +----------------- .../sections/saas/SaasVerifiedCore.tsx | 148 ++----------- 4 files changed, 24 insertions(+), 358 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx index d4a368e85..ce8832778 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx @@ -35,12 +35,12 @@ describe('SaasShiftLeft', () => { expect(html).not.toContain('data-cleanroom-reactor='); expect(html).not.toContain('data-reactor-chamber='); expect(html).not.toContain('Unverified Components'); - expect(mobile).toContain('data-late-review-path="return"'); + expect(mobile).not.toContain('data-late-review-path="return"'); expect(mobile).toContain('data-verified-source="verified-components"'); expect(mobile).toMatch( /data-core-stage="code"[\s\S]*data-core-stage="build"[\s\S]*data-core-stage="test"[\s\S]*data-core-stage="deploy"/, ); - expect(mobile).toContain('data-security-review="closed"'); + expect(mobile).not.toContain('data-security-review="closed"'); expect(mobile).toContain('data-security-review="open"'); expect(mobile).toContain('data-release-exit="approved"'); }); @@ -126,22 +126,20 @@ describe('SaasShiftLeft', () => { expect(routeStackRule).toContain('z-index: 1'); }); - it('contrasts an open release with a closed late-review return', () => { + it('renders only the verified route and its approved release', () => { const html = renderSection(); expect(html).toContain('data-security-review="open"'); expect(html).toContain('data-release-exit="approved"'); expect(html).toContain('data-release-arrow="forward"'); - expect(html).toContain('data-security-review="closed"'); - expect(html).toContain('data-late-review-path="return"'); + expect(html).not.toContain('data-security-review="closed"'); + expect(html).not.toContain('data-late-review-path="return"'); }); - it('exposes both exact source sequences once and hides duplicate visuals', () => { + it('exposes the verified sequence once and hides duplicate visuals', () => { const html = renderSection(); - expect(html.match(/aria-label="Code, Build, Test, Deploy, Security Review"/g)).toHaveLength( - 1, - ); + expect(html).not.toContain('aria-label="Code, Build, Test, Deploy, Security Review"'); expect( html.match( /aria-label="Verified Components, Code, Build, Test, Deploy, Security Review"/g, @@ -149,7 +147,6 @@ describe('SaasShiftLeft', () => { ).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./); }); @@ -159,8 +156,11 @@ describe('SaasShiftLeft', () => { expect(stylesheet).toContain('@media (prefers-reduced-motion: reduce)'); expect(stylesheet).toMatch( - /\.verifiedPulse,\s*\.returnPulse,\s*\.scannerBeam,\s*\.releaseCheck\s*{\s*animation: none !important;/, + /\.verifiedPulse,\s*\.scannerBeam,\s*\.releaseCheck\s*{\s*animation: none !important;/, ); + expect(stylesheet).not.toContain('.returnPulse'); + expect(stylesheet).not.toContain('.lateRoute'); + expect(stylesheet).not.toContain('.mobileLateCard'); }); it('keeps the trust ribbon cyan-to-mint and animates the approved release state', () => { diff --git a/apps/web/src/components/sections/saas/SaasShiftLeft.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.tsx index 4565733a7..abdc3fa40 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.tsx @@ -3,7 +3,6 @@ import { Container, Section } from '@/components/layout'; import { Reveal } from '@/components/ui/Reveal'; import { SaasVerifiedCore } from './SaasVerifiedCore'; -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; @@ -62,14 +61,6 @@ export function SaasShiftLeft(): React.ReactElement {
      -
        -
      1. Code
      2. -
      3. Build
      4. -
      5. Test
      6. -
      7. Deploy
      8. -
      9. Security Review
      10. -
      -
      1. Verified Components
      2. Code
      3. diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css index 521eecaeb..f0cda566e 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css @@ -10,10 +10,8 @@ .mobileSurface { display: grid; - gap: 18px; } -.mobileLateCard, .mobileVerifiedCard { position: relative; overflow: hidden; @@ -27,59 +25,6 @@ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 24px 50px rgba(3, 7, 21, 0.3); } -.mobileLateCard { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 14px 12px; - padding: 20px 18px 12px; - border-color: rgba(255, 135, 149, 0.22); - background: - radial-gradient(circle at 92% 42%, rgba(255, 107, 107, 0.11), transparent 28%), - linear-gradient(rgba(118, 159, 218, 0.045) 1px, transparent 1px), - linear-gradient(90deg, rgba(118, 159, 218, 0.045) 1px, transparent 1px), - linear-gradient(155deg, rgba(24, 25, 50, 0.96) 0%, rgba(10, 11, 27, 0.98) 100%); - background-size: auto, 34px 34px, 34px 34px, auto; -} - -.mobileLateStages { - position: relative; - z-index: 1; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 7px; -} - -.mobileLateStage { - min-width: 0; - padding: 8px 9px; - border: 1px solid rgba(196, 207, 236, 0.13); - border-radius: 8px; - background: rgba(160, 177, 217, 0.055); - color: rgba(226, 232, 250, 0.63); - font-family: var(--font-display); - font-size: var(--fs-caption); - font-weight: 600; - line-height: 1.2; - text-align: center; -} - -.mobileLateConnector { - position: absolute; - top: 50%; - right: 92px; - width: 28px; - height: 1px; - background: linear-gradient(90deg, rgba(183, 196, 226, 0.16), rgba(255, 135, 149, 0.56)); -} - -.mobileReturnPath { - grid-column: 1 / -1; - width: 100%; - height: 38px; - margin-top: -4px; -} - .mobileVerifiedCard { padding: 24px 18px 28px; border-color: rgba(103, 227, 213, 0.3); @@ -284,10 +229,6 @@ color: #70edbd; } -.scannerClosed { - color: #ff8795; -} - .scannerBeam { position: absolute; inset: 4px 7px auto; @@ -314,10 +255,6 @@ opacity: 0.72; } -.scannerClosed .scannerBarrier { - box-shadow: 0 0 14px rgba(255, 135, 149, 0.58); -} - .scannerStateIcon { position: absolute; top: 50%; @@ -336,25 +273,6 @@ text-align: center; } -.scannerCompact { - min-width: 78px; - min-height: 82px; - gap: 5px; -} - -.scannerCompact .scannerFrame { - width: 56px; - height: 50px; -} - -.scannerCompact.scannerOpen .scannerBarrier { - transform: translateY(-21px) rotate(-90deg); -} - -.scannerCompact .scannerLabel { - max-width: 86px; -} - .releaseExit { position: absolute; top: 26px; @@ -417,22 +335,6 @@ transform: rotate(90deg); } -.returnGuide { - stroke: rgba(255, 135, 149, 0.55); - stroke-width: 2; - stroke-dasharray: 6 8; - stroke-linecap: round; -} - -.returnPulse { - stroke: #ff8795; - stroke-width: 3; - stroke-dasharray: 80 1200; - stroke-linecap: round; - filter: drop-shadow(0 0 6px rgba(255, 135, 149, 0.7)); - animation: returnSignal 8.8s linear infinite; -} - @keyframes scannerSweep { 0%, 14% { @@ -451,26 +353,6 @@ } } -@keyframes returnSignal { - 0%, - 58% { - stroke-dashoffset: 0; - opacity: 0; - } - 63% { - opacity: 1; - } - 88% { - stroke-dashoffset: -760; - opacity: 1; - } - 94%, - 100% { - stroke-dashoffset: -840; - opacity: 0; - } -} - @keyframes releaseApproval { 0%, 56% { @@ -490,18 +372,12 @@ } } -@media (min-width: 640px) { - .mobileLateStages { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } -} - @media (min-width: 1024px) { .desktopSurface { position: relative; isolation: isolate; display: block; - min-height: 500px; + min-height: 284px; overflow: hidden; padding: 30px 34px 34px; border: 1px solid rgba(137, 181, 244, 0.28); @@ -613,33 +489,6 @@ gap: 16px; } - .lateRoute { - position: relative; - height: 172px; - opacity: 0.82; - } - - .returnPath { - position: absolute; - z-index: 0; - top: -5px; - left: 0; - width: 100%; - height: 150px; - overflow: visible; - } - - .lateRail { - position: absolute; - z-index: 0; - top: 111px; - right: 7.2%; - left: 20.2%; - height: 1px; - background: linear-gradient(90deg, rgba(193, 205, 234, 0.24), rgba(255, 135, 149, 0.5)); - } - - .lateGrid, .verifiedGrid { position: relative; z-index: 2; @@ -649,51 +498,6 @@ align-items: center; } - .lateGrid { - height: 100%; - padding-top: 62px; - } - - .lateStage { - display: grid; - min-width: 0; - place-items: center; - gap: 8px; - } - - .lateNode { - position: relative; - display: grid; - width: 52px; - height: 44px; - place-items: center; - clip-path: polygon(9px 0, calc(100% - 9px) 0, 100% 9px, 100% calc(100% - 9px), calc(100% - 9px) 100%, 9px 100%, 0 calc(100% - 9px), 0 9px); - background: linear-gradient(145deg, rgba(130, 150, 195, 0.24), rgba(46, 58, 91, 0.52)); - box-shadow: inset 0 0 0 1px rgba(196, 207, 236, 0.15); - } - - .lateNodeCore { - width: 9px; - height: 9px; - border: 1px solid rgba(228, 233, 247, 0.32); - border-radius: 50%; - background: rgba(194, 204, 230, 0.18); - } - - .lateLabel { - color: rgba(224, 230, 248, 0.58); - font-family: var(--font-display); - font-size: var(--fs-caption); - font-weight: 600; - line-height: 1.2; - text-align: center; - } - - .lateRoute .scanner { - align-self: end; - margin-bottom: 4px; - } - .verifiedRoute { position: relative; min-height: 220px; @@ -820,7 +624,6 @@ @media (prefers-reduced-motion: reduce) { .verifiedPulse, - .returnPulse, .scannerBeam, .releaseCheck { animation: none !important; @@ -831,10 +634,6 @@ opacity: 1; } - .returnPulse { - opacity: 0; - } - .scannerBeam { transform: translateY(28px); opacity: 0.74; diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx index f82b06290..9bee539ca 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx @@ -9,8 +9,6 @@ interface DeliveryStage { } interface SecurityScannerProps { - readonly state: 'open' | 'closed'; - readonly compact?: boolean; readonly vertical?: boolean; } @@ -35,7 +33,6 @@ function DesktopVerifiedCore(): React.ReactElement { @@ -55,63 +52,6 @@ function SurfaceChrome(): React.ReactElement { ); } -function LateReviewRoute(): React.ReactElement { - return ( -
        - - - - - - - - - - - -
        - - {DELIVERY_STAGES.map((stage) => ( - - ))} - -
        -
        - ); -} - -function LateStage({ stage }: { readonly stage: DeliveryStage }): React.ReactElement { - return ( -
        - - - - {stage.label} -
        - ); -} - function VerifiedRoute(): React.ReactElement { return (
        @@ -122,7 +62,7 @@ function VerifiedRoute(): React.ReactElement { {DELIVERY_STAGES.map((stage) => ( ))} - +
      ); @@ -194,40 +134,32 @@ function StageIcon({ stage }: { readonly stage: DeliveryStageId }): React.ReactE } } -function SecurityScanner({ - state, - compact = false, - vertical = false, -}: SecurityScannerProps): React.ReactElement { - const isOpen = state === 'open'; +function SecurityScanner({ vertical = false }: SecurityScannerProps): React.ReactElement { const className = [ styles.scanner, - isOpen ? styles.scannerOpen : styles.scannerClosed, - compact ? styles.scannerCompact : '', + styles.scannerOpen, vertical ? styles.scannerVertical : '', ] .filter(Boolean) .join(' '); return ( -
      +
      - {isOpen ? : } +
      Security Review - {isOpen ? ( - - - - - - + + + + + - ) : null} +
      ); } @@ -235,54 +167,11 @@ function SecurityScanner({ function MobileVerifiedCore(): React.ReactElement { return ( ); } -function MobileLateRoute(): React.ReactElement { - return ( -
      -
      - {DELIVERY_STAGES.map((stage) => ( - - {stage.label} - - ))} -
      - - - - - - - - - - -
      - ); -} - function MobileVerifiedRoute(): React.ReactElement { return (
      @@ -292,7 +181,7 @@ function MobileVerifiedRoute(): React.ReactElement { {DELIVERY_STAGES.map((stage) => ( ))} - +
      ); @@ -311,16 +200,3 @@ function CheckIcon(): React.ReactElement { ); } - -function CrossIcon(): React.ReactElement { - return ( - - ); -} From 66c5e6ba7c67a32862ada57702087c4ec444f2f3 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 17:29:47 +0530 Subject: [PATCH 22/85] feat(web): rebuild the SaaS hero as an application and its components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New direction from the client, with a reference: no container and no base object. A central application window carrying the complexity of a real product, with the pieces it is built from floating around it at varying depth and angle. Built in code rather than as an image. The style is rounded rectangles, flat fills, small glyphs and slight rotations, which is native to SVG — an easier build than the isometric container it replaces, where every vertex was projected by hand. Code also holds exact brand colour, stays crisp at every DPR, costs a few KB rather than a raster, and sidesteps the reference asset itself: it is a free stock vector, so it is attribution-bound and already on thousands of sites. The security story moved into WHAT floats. The six cards are not generic UI chrome; they are what a SaaS application is assembled from — source, base images, packages, libraries, a signature digest — and three carry a verified tick. That is what keeps this container security rather than generic app development, and it is why the composition belongs on this page rather than on any other landing page using the same reference. Dashed tethers run from each card back to the window. Without them the cards read as unrelated confetti; with them every piece plainly belongs to the application in the middle. The reference's flourishes (light rays, bubbles, scattered gears) are deliberately left out. That decorative layer is the stock-vector look the client rejected in the original render. Measured: 599x470 at 1440 with a 67px gap to the headline, 531px at 1280 with 55px, 316px at 768 with 54px, hidden at 375. No horizontal overflow at any width. lint, typecheck and build pass. --- apps/web/src/app/globals.css | 2 +- .../src/components/sections/saas/SaasHero.tsx | 8 +- .../sections/saas/SaasHeroAppSurface.tsx | 374 +++++++++++++ .../sections/saas/SaasHeroVerifiedRuntime.tsx | 500 ------------------ 4 files changed, 379 insertions(+), 505 deletions(-) create mode 100644 apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx delete mode 100644 apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 2df4e009a..675875223 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1316,7 +1316,7 @@ 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 (SaasHeroVerifiedRuntime.tsx). Builds once, then holds. A hero artifact that loops competes with the +/* 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 diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index 3b13f642c..7cb811e3c 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -1,6 +1,6 @@ import type React from 'react'; import Link from 'next/link'; -import { SaasHeroVerifiedRuntime } from './SaasHeroVerifiedRuntime'; +import { SaasHeroAppSurface } from './SaasHeroAppSurface'; import { HeroReveal } from '@/components/ui/Reveal'; /* @@ -11,7 +11,7 @@ import { HeroReveal } from '@/components/ui/Reveal'; * * 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 - * (SaasHeroVerifiedRuntime.tsx), because the render it replaced was rejected and + * (SaasHeroAppSurface.tsx), because the render it replaced was rejected and * regenerating it kept landing on stock illustration. */ export function SaasHero(): React.ReactElement { @@ -42,7 +42,7 @@ export function SaasHero(): React.ReactElement { competing for the same row. Built, not rendered: this was hero-app-platform.webp until the client - rejected it. See SaasHeroVerifiedRuntime.tsx for the reasoning. There is no + 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. */}
      - +
      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..771e62800 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx @@ -0,0 +1,374 @@ +import type React from 'react'; + +/* + * SaaS hero artifact — the application, and everything it is assembled from. + * + * Direction from the client, with a reference: no container and no base object. + * A central application window carrying the complexity of a real product, with + * the pieces it is built from floating around it at varying depth and angle. + * + * Built in code rather than as an image. The style is rounded rectangles, flat + * fills, small glyphs and slight rotations, which is native to SVG — an easier + * build than the isometric container this replaces, where every vertex had to be + * projected by hand. Code also keeps exact brand colour, stays crisp at every + * DPR, costs a few KB instead of a raster, and sidesteps the reference asset + * itself: it is a free stock vector, so it is attribution-bound and already on + * thousands of sites. + * + * The security story lives in WHAT floats. These are not generic UI chrome + * cards: they are the things a SaaS application is actually assembled from — + * base images, packages, libraries, source. Three carry a verified tick, so the + * page reads as container security rather than as app development. That is the + * whole reason this composition belongs on this page and not on any other + * landing page using the same reference. + * + * The flourishes in the reference (light rays, bubbles, scattered gears) are + * deliberately restrained. That decorative layer is exactly the stock-vector + * look the client rejected in the original render. + */ + +const VIEW_W = 560; +const VIEW_H = 440; + +const APP_X = 132; +const APP_Y = 98; +const APP_W = 296; +const APP_H = 238; + +type CardKind = 'code' | 'rows' | 'package' | 'grid' | 'chip'; + +interface FloatCard { + readonly id: string; + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; + readonly rot: number; + readonly kind: CardKind; + /** Verified components. Not every card: a tick on all of them is wallpaper. */ + readonly tick: boolean; + /** Anchor point on the app window that this card tethers back to. */ + readonly tether: readonly [number, number]; +} + +const CARDS: readonly FloatCard[] = [ + { id: 'code', x: 22, y: 40, w: 108, h: 72, rot: -8, kind: 'code', tick: false, tether: [150, 120] }, + { id: 'img', x: 402, y: 34, w: 132, h: 86, rot: 7, kind: 'grid', tick: true, tether: [412, 130] }, + { id: 'pkg', x: 432, y: 182, w: 112, h: 76, rot: -5, kind: 'package', tick: true, tether: [424, 210] }, + { id: 'sig', x: 396, y: 316, w: 124, h: 54, rot: 8, kind: 'chip', tick: true, tether: [408, 306] }, + { id: 'dep', x: 18, y: 288, w: 116, h: 76, rot: 6, kind: 'rows', tick: false, tether: [140, 300] }, + { id: 'lib', x: 6, y: 176, w: 92, h: 60, rot: -6, kind: 'rows', tick: false, tether: [136, 200] }, +]; + +function CardBody({ kind, w, h }: { kind: CardKind; w: number; h: number }): React.ReactElement { + const pad = 11; + const bar = (i: number, width: number) => ( + + ); + + if (kind === 'code') { + return ( + + + + + {[0, 1].map((i) => bar(i, w - pad * 2 - (i === 0 ? 26 : 44)))} + + ); + } + + if (kind === 'grid') { + return ( + + {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} + {bar(0, w - pad * 2 - 20)} + + ); + } + + if (kind === 'package') { + return ( + + + + {[0, 1].map((i) => bar(i, w - pad * 2 - i * 22))} + + ); + } + + if (kind === 'chip') { + return ( + + {[0, 1, 2, 3, 4, 5, 6].map((i) => ( + + ))} + + ); + } + + return {[0, 1, 2].map((i) => bar(i, w - pad * 2 - i * 18))}; +} + +function Card({ card }: { card: FloatCard }): React.ReactElement { + const { x, y, w, h, rot, kind, tick } = card; + return ( + + + + {tick && ( + + + + + )} + + ); +} + +export function SaasHeroAppSurface(): React.ReactElement { + return ( + + + + + + + + + + + + + + + + + + + + {/* Tethers, drawn first so they pass behind everything. They are what + stops the cards reading as unrelated confetti: each one belongs to the + application in the middle. */} + + {CARDS.map((c) => ( + + ))} + + + {CARDS.map((c, i) => ( + + + + ))} + + {/* The application. Light against the dark hero so it is unmistakably the + subject, and dense enough to read as a real product rather than a + wireframe: rail, header, two summary tiles, then rows. */} + + + + + {[0, 1, 2].map((i) => ( + + ))} + + + + {[0, 1, 2, 3, 4].map((i) => ( + + ))} + + {[0, 1].map((i) => ( + + + + + + ))} + + {[0, 1, 2, 3].map((i) => ( + + + + + + ))} + + + ); +} diff --git a/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx b/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx deleted file mode 100644 index 76c3769cb..000000000 --- a/apps/web/src/components/sections/saas/SaasHeroVerifiedRuntime.tsx +++ /dev/null @@ -1,500 +0,0 @@ -import type React from 'react'; - -/* - * SaaS hero artifact — a SaaS application, and the verified container image it - * runs on. - * - * Both halves of the H1 have to be visible at once. Earlier attempts each showed - * only one: the catalogue panel and the product dashboard were SaaS with no - * container, and the attack-surface comparison was containers with no SaaS (and - * its flat bands read as generic block grids rather than as images). - * - * Composition, and the order matters: - * - * - The CONTAINER IMAGE is the subject: largest object, isometric so it reads - * as a physical thing rather than a diagram, and built as a STACK OF LAYERS - * rather than one solid box. A single box read as a plinth, which is the - * "app on a platform" idea already rejected once; discrete layers are what - * make it an image instead of a slab. - * - The SaaS APPLICATION is the context, seated just above the top layer so it - * is plainly running ON the image. It says SaaS through tenant rows, - * avatars and status pills — never charts, since a rising line on this page - * promises revenue growth, which is what got the original render rejected. - * - Two dim SATELLITE stacks and a dot field fill the frame. The main subject - * previously occupied 264px of a 520px viewBox, so the artifact was mostly - * padding, which read as a hole between the headline and the illustration. - * The satellites also carry a real idea: this is one image out of a - * catalogue, not a single artifact. - * - * No caption. One read "VERIFIED CONTAINER IMAGE" and landed inside the hero's - * white bottom fade, where 55%-white type is invisible. The object carries - * itself under a headline that already says Container Security. - * - * Same formula as the approved financial services hero (industry surface as - * context, container as subject), which is right for a page family. What differs - * is the industry signal and the composition: that one magnifies a container - * inside a finance console, this one seats an app on top of an image. - */ - -const VIEW_W = 520; -const VIEW_H = 392; - -interface Point { - readonly x: number; - readonly y: number; -} - -const CX = 268; -const HALF_W = 166; -const HALF_D = 44; -const THICK = 28; - -/** Top to bottom. Centres 34 apart against a 28 thickness leaves a 6px reveal - between layers, which is what stops the stack reading as one solid box. */ -const LAYER_Y = [242, 276, 310] as const; - -const TOP_LAYER_Y = LAYER_Y[0]; - -const APP_X = 146; -const APP_Y = 44; -const APP_W = 250; -const APP_H = 140; - -interface TenantRow { - readonly y: number; - readonly nameW: number; - readonly pillW: number; -} - -const TENANTS: readonly TenantRow[] = [ - { y: 90, nameW: 68, pillW: 36 }, - { y: 114, nameW: 52, pillW: 28 }, - { y: 138, nameW: 76, pillW: 36 }, - { y: 162, nameW: 58, pillW: 26 }, -]; - -function poly(...points: readonly Point[]): string { - return points.map((p) => `${p.x},${p.y}`).join(' '); -} - -interface SlabProps { - readonly cx: number; - readonly cy: number; - readonly halfW: number; - readonly halfD: number; - readonly thick: number; - readonly edge: string; - /** Corrugation ribs per visible side face. 0 for the dim satellites, where - they would only turn to mush at the rendered size. */ - readonly ribs?: number; -} - -function Slab({ cx, cy, halfW, halfD, thick, edge, ribs = 0 }: SlabProps): React.ReactElement { - const t: Point = { x: cx, y: cy - halfD }; - const r: Point = { x: cx + halfW, y: cy }; - const b: Point = { x: cx, y: cy + halfD }; - const l: Point = { x: cx - halfW, y: cy }; - const drop = (p: Point): Point => ({ x: p.x, y: p.y + thick }); - - return ( - - - - - {/* Corrugation. This is the single strongest cue that the object is a - CONTAINER rather than a stack of slabs — the same ribbing the Clean - Images artifact uses elsewhere on the site. Vertical on screen because - the isometric projection maps height straight down. */} - {Array.from({ length: Math.max(0, ribs - 1) }, (_, i) => { - const f = (i + 1) / ribs; - const lx = l.x + (b.x - l.x) * f; - const ly = l.y + (b.y - l.y) * f; - const rx = b.x + (r.x - b.x) * f; - const ry = b.y + (r.y - b.y) * f; - return ( - - - - - ); - })} - - {/* Leading edges catch the light and separate one layer from the next; - without them the stack flattens into a single mass. */} - - - - ); -} - -/* - * Base-layer treatment. The bottom layer is the hardened BASE IMAGE, and it - * should look like the sealed foundation the rest is built on. - * - * Software vocabulary only. An earlier pass put shipping-container corner - * castings here, which was the wrong register entirely: this is a container - * image, not cargo, and literal freight hardware makes it a box of goods. So - * the signals are a digest strip — the short run of uneven marks that reads as - * a content hash, which is what actually identifies and pins an image — and a - * violet underglow tying the base to the verified seal in front of it. - */ -function BaseTreatment({ cy }: { cy: number }): React.ReactElement { - const l: Point = { x: CX - HALF_W, y: cy }; - const b: Point = { x: CX, y: cy + HALF_D }; - const widths = [7, 4, 9, 5, 6, 3, 8, 5, 4, 7]; - - return ( - - {widths.map((w, i) => { - const f = 0.12 + (i / widths.length) * 0.76; - return ( - - ); - })} - - ); -} - -/** A dim two-layer stack: one more image in the catalogue, and frame ballast. */ -function Satellite({ cx, cy, scale }: { cx: number; cy: number; scale: number }) { - const halfW = 46 * scale; - const halfD = 13 * scale; - const thick = 11 * scale; - return ( - <> - {[cy + 16 * scale, cy].map((y) => ( - - ))} - - ); -} - -/* - * Surface treatment for the one visible top face. - * - * Deliberately NOT a logo. This is the top of a container IMAGE, and a mark - * stamped on it turns the object into branded packaging, which is both a - * cheaper read and the 3D-render-with-logo look the client rejected. The app - * window also covers the middle of this face, so a centred graphic would be - * half-occluded and look like a bug. - * - * Left flat, though, the face read as unfinished rather than restrained — a - * material with no surface. An isometric grid (echoing the hero's own gridline - * overlay, so it stays on-system), an inset edge line for craft, and a soft - * sheen give it substance without adding a message. - * - * A few grid cells are then lit, which turns the surface from texture into - * content: components seated on the verified base. Using the grid that is - * already there beats dropping a foreign shape onto the plane — the marks - * inherit the isometric projection for free and cannot read as a sticker. - */ -function TopSurface({ cy }: { cy: number }): React.ReactElement { - const t: Point = { x: CX, y: cy - HALF_D }; - const r: Point = { x: CX + HALF_W, y: cy }; - const b: Point = { x: CX, y: cy + HALF_D }; - const l: Point = { x: CX - HALF_W, y: cy }; - - // Barycentric-ish walk across the rhombus: P = T + a·(T→R) + k·(T→L). - const at = (a: number, k: number): Point => ({ - x: t.x + a * HALF_W - k * HALF_W, - y: t.y + a * HALF_D + k * HALF_D, - }); - const inset = (p: Point): Point => ({ - x: CX + (p.x - CX) * 0.9, - y: cy + (p.y - cy) * 0.9, - }); - - const steps = [1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6]; - - const line = (from: Point, to: Point, key: string) => ( - - ); - - return ( - - {steps.map((v) => line(at(v, 0), at(v, 1), `a${v}`))} - {steps.map((v) => line(at(0, v), at(1, v), `k${v}`))} - - - - ); -} - -/** Deterministic is not needed here — the grid is a fixed lattice. */ -function DotField(): React.ReactElement { - const dots: React.ReactElement[] = []; - for (let x = 24; x <= VIEW_W - 16; x += 30) { - for (let y = 28; y <= VIEW_H - 24; y += 30) { - dots.push(); - } - } - return {dots}; -} - -export function SaasHeroVerifiedRuntime(): React.ReactElement { - const bottomLayerY = LAYER_Y[LAYER_Y.length - 1] ?? 310; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - {/* Soft-edged, not a flat ellipse. A hard dark ellipse on the top face - read as a hole punched in it rather than as a shadow. */} - - - - - - {/* Fades the dot field out at the frame edges so it stops rather than - being sliced off by the viewBox. */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* Satellites sit behind and outside the subject, dimmed well back so they - read as depth rather than as three competing objects. */} - - - - - - - - {/* Seals the foundation visually: the base sits in the same violet the - verified mark uses, so the two read as one statement. */} - - - {/* Bottom layer drawn first so each upper layer occludes the one beneath, - which is what gives the stack its depth order. */} - - {[...LAYER_Y].reverse().map((cy) => ( - - ))} - - - - - {/* Verified seal, straddling the stack's front corner so it reads as - applied to the whole image rather than to one layer. - - A shield rather than a disc, and the ONLY security mark on the - artifact. A second one laid flat on the top plane was tried and - pulled: two marks say the same thing twice, and a symbol - foreshortened into the isometric plane reads as a sticker, where one - facing the viewer reads as a seal. */} - - - - - - - - - {/* SaaS application — the context. Tenants, not charts. */} - - - - {[0, 1, 2].map((i) => ( - - ))} - - - {TENANTS.map((row) => ( - - - - - - ))} - - - ); -} From c74db81e858417bea5ec0024d23ff59c451f1836 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 17:42:26 +0530 Subject: [PATCH 23/85] feat(web): push the SaaS hero artifact closer to the client reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass sat six tidy, well-separated, identically-styled cards around the window and read as far too simple beside the reference. Four things were missing, and all four are what create the density: - Cards now OVERLAP the surface and each other, some in front and some behind. Separation was the main reason it looked sparse. - Cards are TRANSLUCENT, so the surface reads through them. That is what makes depth rather than a flat collage. - Real object VARIETY instead of one card repeated: a code badge, a cog panel, a package, an avatar row, a dense manifest, an image grid, and a selection frame with corner handles. - Fine internal detail — thin line runs and small bars — so each piece looks like it contains something. Plus rays behind the subject. Fixed while building: the ray gradient was inverted. Its stops lit the far tips and faded toward the centre, which turned the fan into a dark crown ringing the surface instead of light coming from behind it. The polygon runs tip-to-base up the bounding box, so the stops had to be swapped. The reference's pink and teal palette and its scattered bubbles are not copied — brand blue, cyan and violet, and no confetti. That decorative layer is the stock-vector look the client rejected in the original render. Measured at 1440: 599x475, clear of the header at 51 vs 73, 67px to the headline, no horizontal overflow. lint, typecheck and build pass. --- .../sections/saas/SaasHeroAppSurface.tsx | 534 ++++++++++-------- 1 file changed, 299 insertions(+), 235 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx index 771e62800..2f5945ef9 100644 --- a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx @@ -3,202 +3,171 @@ import type React from 'react'; /* * SaaS hero artifact — the application, and everything it is assembled from. * - * Direction from the client, with a reference: no container and no base object. - * A central application window carrying the complexity of a real product, with - * the pieces it is built from floating around it at varying depth and angle. + * 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. * - * Built in code rather than as an image. The style is rounded rectangles, flat - * fills, small glyphs and slight rotations, which is native to SVG — an easier - * build than the isometric container this replaces, where every vertex had to be - * projected by hand. Code also keeps exact brand colour, stays crisp at every - * DPR, costs a few KB instead of a raster, and sidesteps the reference asset - * itself: it is a free stock vector, so it is attribution-bound and already on - * thousands of sites. + * A first pass sat six tidy, well-separated, identically-styled cards around the + * window and read as far too simple next to the reference. What the reference + * actually does, and what this now does: * - * The security story lives in WHAT floats. These are not generic UI chrome - * cards: they are the things a SaaS application is actually assembled from — - * base images, packages, libraries, source. Three carry a verified tick, so the - * page reads as container security rather than as app development. That is the - * whole reason this composition belongs on this page and not on any other - * landing page using the same reference. + * - Cards OVERLAP the window and each other, some in front and some behind. + * Separation is what made the first pass look sparse. + * - Cards are TRANSLUCENT, so the surface reads through them. That is what + * creates depth rather than a flat collage. + * - Real object VARIETY, not one card repeated: a code badge, a cog panel, a + * package, an avatar row, a dense manifest, an image grid, a selection frame + * with corner handles. + * - Fine internal DETAIL — thin line runs, small bars — so each piece looks + * like it contains something. + * - Rays behind the subject. * - * The flourishes in the reference (light rays, bubbles, scattered gears) are - * deliberately restrained. That decorative layer is exactly the stock-vector - * look the client rejected in the original render. + * 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. These are the things a SaaS + * application is assembled from — source, base images, packages, libraries, a + * signed manifest — and several carry a verified tick. That is what keeps this + * container security rather than generic app development, and it is the reason + * the composition belongs on this page and not on any other landing page using + * the same reference. + * + * The reference's pink/teal palette and its scattered bubbles are not copied. + * Brand blue, cyan and violet only, and no confetti: that decorative layer is + * the stock-vector look the client rejected in the original render. */ -const VIEW_W = 560; -const VIEW_H = 440; +const VIEW_W = 580; +const VIEW_H = 460; -const APP_X = 132; -const APP_Y = 98; -const APP_W = 296; -const APP_H = 238; +const APP_X = 150; +const APP_Y = 92; +const APP_W = 268; +const APP_H = 268; -type CardKind = 'code' | 'rows' | 'package' | 'grid' | 'chip'; - -interface FloatCard { - readonly id: string; - readonly x: number; - readonly y: number; - readonly w: number; - readonly h: number; - readonly rot: number; - readonly kind: CardKind; - /** Verified components. Not every card: a tick on all of them is wallpaper. */ - readonly tick: boolean; - /** Anchor point on the app window that this card tethers back to. */ - readonly tether: readonly [number, number]; -} +const BLUE = '#239cff'; +const CYAN = '#4fe3ff'; +const VIOLET = '#a76bff'; -const CARDS: readonly FloatCard[] = [ - { id: 'code', x: 22, y: 40, w: 108, h: 72, rot: -8, kind: 'code', tick: false, tether: [150, 120] }, - { id: 'img', x: 402, y: 34, w: 132, h: 86, rot: 7, kind: 'grid', tick: true, tether: [412, 130] }, - { id: 'pkg', x: 432, y: 182, w: 112, h: 76, rot: -5, kind: 'package', tick: true, tether: [424, 210] }, - { id: 'sig', x: 396, y: 316, w: 124, h: 54, rot: 8, kind: 'chip', tick: true, tether: [408, 306] }, - { id: 'dep', x: 18, y: 288, w: 116, h: 76, rot: 6, kind: 'rows', tick: false, tether: [140, 300] }, - { id: 'lib', x: 6, y: 176, w: 92, h: 60, rot: -6, kind: 'rows', tick: false, tether: [136, 200] }, -]; - -function CardBody({ kind, w, h }: { kind: CardKind; w: number; h: number }): React.ReactElement { - const pad = 11; - const bar = (i: number, width: number) => ( - +function Tick({ x, y, r = 9 }: { x: number; y: number; r?: number }): React.ReactElement { + return ( + + + + ); +} - if (kind === 'code') { - return ( - - - - + {widths.map((w, i) => ( + - {[0, 1].map((i) => bar(i, w - pad * 2 - (i === 0 ? 26 : 44)))} - - ); - } - - if (kind === 'grid') { - return ( - - {[0, 1, 2, 3, 4, 5].map((i) => ( - - ))} - {bar(0, w - pad * 2 - 20)} - - ); - } + ))} + + ); +} - if (kind === 'package') { - return ( - +function Cog({ x, y, r, fill }: { x: number; y: number; r: number; fill: string }) { + return ( + + {Array.from({ length: 8 }, (_, i) => ( - - {[0, 1].map((i) => bar(i, w - pad * 2 - i * 22))} - - ); - } - - if (kind === 'chip') { - return ( - - {[0, 1, 2, 3, 4, 5, 6].map((i) => ( - - ))} - - ); - } + ))} + + + + ); +} - return {[0, 1, 2].map((i) => bar(i, w - pad * 2 - i * 18))}; +/** Shared card shell: translucent, so whatever it overlaps shows through. */ +function Shell({ + w, + h, + accent, + radius = 10, +}: { + w: number; + h: number; + accent?: string; + radius?: number; +}): React.ReactElement { + return ( + + ); } -function Card({ card }: { card: FloatCard }): React.ReactElement { - const { x, y, w, h, rot, kind, tick } = card; +/** Design-tool selection frame. Straight from the reference, and it earns its + place here: it reads as a component being worked on, not just decoration. */ +function Handles({ w, h }: { w: number; h: number }): React.ReactElement { + const pts: readonly (readonly [number, number])[] = [ + [0, 0], + [w, 0], + [0, h], + [w, h], + ]; return ( - - - - {tick && ( - - - - - )} + + + {pts.map(([px, py]) => ( + + ))} ); } @@ -213,13 +182,21 @@ export function SaasHeroAppSurface(): React.ReactElement { > - - + + + {/* Bright at the BASE, fading to the tip. The stops were the other way + round at first, which lit the far ends and turned the fan into a dark + crown ringing the surface instead of light coming from behind it. + The polygon runs from y=-210 (tip, bbox top) to y=0 (base, bottom). */} + + + + @@ -227,35 +204,64 @@ export function SaasHeroAppSurface(): React.ReactElement { - + - {/* Tethers, drawn first so they pass behind everything. They are what - stops the cards reading as unrelated confetti: each one belongs to the - application in the middle. */} - - {CARDS.map((c) => ( - + {[-62, -40, -20, 0, 20, 40, 62].map((a) => ( + ))} - {CARDS.map((c, i) => ( - - + {/* BEHIND the surface. Overlap is what stops the composition reading as a + tidy ring of separate cards. */} + + + + + + {[0, 1, 2].map((i) => ( + + ))} + - ))} - {/* The application. Light against the dark hero so it is unmistakably the - subject, and dense enough to read as a real product rather than a - wireframe: rail, header, two summary tiles, then rows. */} + + + + + + + + + + + + + + + + + + {/* The application surface. Light against the dark hero so it is + unmistakably the subject, and dense enough to read as a real product. */} - ))} - - {[0, 1, 2, 3, 4].map((i) => ( + {[0, 1, 2, 3, 4, 5].map((i) => ( ))} - {[0, 1].map((i) => ( ))} - - {[0, 1, 2, 3].map((i) => ( + {[0, 1, 2, 3, 4].map((i) => ( - + ))} + + {/* IN FRONT, overlapping the surface. */} + + + + + + + + + + + + + + + + + + + + {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} + + + + + + + + + ); } From 8c7480ce1fdbd0369f761d1485e171456b3bceed Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 17:42:47 +0530 Subject: [PATCH 24/85] docs(web): specify SaaS 3D process deck --- .../plans/2026-09-01-saas-3d-process-deck.md | 46 +++++++++++++++++ .../2026-09-01-saas-3d-process-deck-design.md | 50 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-01-saas-3d-process-deck.md create mode 100644 docs/superpowers/specs/2026-09-01-saas-3d-process-deck-design.md 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/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. From 265e2866332a8303824a5f33d156e1bbd42d9f1b Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 17:47:52 +0530 Subject: [PATCH 25/85] feat(web): make the SaaS hero dashboard read as a product, not a wireframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface was grey bars on white. Looking at it large, the specific faults were: stat tiles holding no value, so they read as empty placeholders; five identical list rows; a rail of featureless grey blobs; and no header or primary action anywhere. Nothing in it had hierarchy, and almost nothing had colour. What makes a UI look real is hierarchy and colour, so the surface now has a header with a filled primary action, rail icons that are actually drawn (grid, list, box, shield, cog) with one active in brand blue, three stat tiles each carrying a value bar under a coloured marker, and a list whose rows differ from one another: different chip tints, two-line entries, matching status pills, and one selected row with a tinted background and an accent rail. The floating pieces were also all one fill, which flattened them into a single material. Each now has its own tint — violet on the selection frame and the package, cyan on the cog panel and the code badge, blue on the avatar and image cards, deep navy on the manifest — so they read as different kinds of thing rather than seven copies. Still 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. lint, typecheck and build pass. --- .../sections/saas/SaasHeroAppSurface.tsx | 499 +++++++++++------- 1 file changed, 300 insertions(+), 199 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx index 2f5945ef9..6691d22a1 100644 --- a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx @@ -7,20 +7,21 @@ import type React from 'react'; * application surface carrying the complexity of a real product, with the pieces * it is built from floating around it. * - * A first pass sat six tidy, well-separated, identically-styled cards around the - * window and read as far too simple next to the reference. What the reference - * actually does, and what this now does: + * THE SURFACE. Earlier passes drew it as grey bars on white, which read as a + * wireframe rather than a product: empty stat tiles with no values, five + * identical rows, a rail of featureless grey blobs, no header and no primary + * action. What makes a UI look real is hierarchy and colour, so this one has a + * header with a filled action, drawn rail icons with one active, three stat + * tiles that actually carry a value, and a list whose rows differ from each + * other — different chip tints, two-line entries, distinct status pills, and one + * selected row with an accent rail. * - * - Cards OVERLAP the window and each other, some in front and some behind. - * Separation is what made the first pass look sparse. - * - Cards are TRANSLUCENT, so the surface reads through them. That is what - * creates depth rather than a flat collage. - * - Real object VARIETY, not one card repeated: a code badge, a cog panel, a - * package, an avatar row, a dense manifest, an image grid, a selection frame - * with corner handles. - * - Fine internal DETAIL — thin line runs, small bars — so each piece looks - * like it contains something. - * - Rays behind the subject. + * THE PIECES. Overlapping the surface and each other, some in front and some + * behind; translucent, so the surface reads through them; and each on its own + * tint rather than one fill repeated seven times, which was the other thing + * flattening the composition. Variety of object, not just of position: a code + * badge, a cog panel, a package, an avatar row, a dense manifest, an image grid, + * and a selection frame with corner handles. * * 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 @@ -28,34 +29,54 @@ import type React from 'react'; * which is a free stock vector — attribution-bound and already on thousands of * sites. * - * The security story lives in WHAT floats. These are the things a SaaS - * application is assembled from — source, base images, packages, libraries, a - * signed manifest — and several carry a verified tick. That is what keeps this - * container security rather than generic app development, and it is the reason - * the composition belongs on this page and not on any other landing page using - * the same reference. + * The security story lives in WHAT floats: source, base images, packages, + * libraries, a signed manifest, several carrying a verified tick. That is what + * keeps this container security rather than generic app development. * - * The reference's pink/teal palette and its scattered bubbles are not copied. - * Brand blue, cyan and violet only, and no confetti: that decorative layer is - * the stock-vector look the client rejected in the original render. + * 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 = 150; -const APP_Y = 92; -const APP_W = 268; -const APP_H = 268; +const APP_X = 148; +const APP_Y = 84; +const APP_W = 288; +const APP_H = 292; -const BLUE = '#239cff'; +const RAIL_W = 44; +const PAD = 15; +const BODY_X = APP_X + RAIL_W + PAD; +const BODY_W = APP_W - RAIL_W - PAD * 2; + +const BLUE = '#005be3'; +const VIOLET = '#7c34e8'; +const TEAL = '#0f9fd0'; const CYAN = '#4fe3ff'; -const VIOLET = '#a76bff'; + +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: 82, sub: 52, selected: true }, + { chip: VIOLET, name: 66, sub: 44, selected: false }, + { chip: TEAL, name: 92, sub: 38, selected: false }, + { chip: BLUE, name: 72, sub: 50, selected: false }, +] as const; + +const STATS = [ + { accent: BLUE, value: 26 }, + { accent: VIOLET, value: 19 }, + { accent: TEAL, value: 30 }, +] as const; function Tick({ x, y, r = 9 }: { x: number; y: number; r?: number }): React.ReactElement { 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; - accent?: string; + fill: string; + accent: string; radius?: number; }): React.ReactElement { - return ( - - ); + return ; } -/** Design-tool selection frame. Straight from the reference, and it earns its - place here: it reads as a component being worked on, not just decoration. */ function Handles({ w, h }: { w: number; h: number }): React.ReactElement { - const pts: readonly (readonly [number, number])[] = [ - [0, 0], - [w, 0], - [0, h], - [w, h], - ]; return ( - - {pts.map(([px, py]) => ( + + {( + [ + [0, 0], + [w, 0], + [0, h], + [w, h], + ] as const + ).map(([px, py]) => ( - - - + + + + + + + + + + + + + + + - {/* Bright at the BASE, fading to the tip. The stops were the other way - round at first, which lit the far ends and turned the fan into a dark - crown ringing the surface instead of light coming from behind it. - The polygon runs from y=-210 (tip, bbox top) to y=0 (base, bottom). */} + {/* 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. */} - - + + - {/* Rays fanning from behind the surface. */} {[-62, -40, -20, 0, 20, 40, 62].map((a) => ( - {/* BEHIND the surface. Overlap is what stops the composition reading as a - tidy ring of separate cards. */} + {/* BEHIND the surface. */} - - - - + + + + {[0, 1, 2].map((i) => ( ))} - + - - - - - - + + + + + + - - + + - - - + + + - {/* The application surface. Light against the dark hero so it is - unmistakably the subject, and dense enough to read as a real product. */} + {/* THE APPLICATION SURFACE */} - + + {/* Header: dots, a title, and a filled primary action. */} {[0, 1, 2].map((i) => ( ))} - + + + + {/* Rail with drawn icons, one active. */} - {[0, 1, 2, 3, 4, 5].map((i) => ( - + {[0, 1, 2, 3, 4].map((i) => ( + + + + ))} - {[0, 1].map((i) => ( - + + {/* Stat tiles that carry a value. Empty tiles were what made the earlier + pass read as a placeholder. */} + {STATS.map((s, i) => ( + - - - - ))} - {[0, 1, 2, 3, 4].map((i) => ( - - - - + + + ))} + + + + + {/* Rows that differ from one another, with one selected. */} + {ROWS.map((row, i) => { + const y = APP_Y + 128 + i * 36; + return ( + + {row.selected && ( + <> + + + + )} + + + + + + + + ); + })} {/* IN FRONT, overlapping the surface. */} - - - - + + + + - - + + - - - - - + + + + + - - + + {[0, 1, 2, 3, 4, 5].map((i) => ( ))} - + - - + + From 8d068992a67175ae19b4a9eafddb554df25af62c Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 17:56:47 +0530 Subject: [PATCH 26/85] feat(web): add depth and density to the SaaS hero artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More inside the surface. A tab strip with an active underline, a secondary header chip beside the primary action, and a side panel so the body is no longer one flat column — the panel carries four colour-coded entries and a filled progress bar. The surface itself is wider (312 from 288) to hold the second column without crowding the list. More around it. Two new pieces: a terminal, dark with traffic lights and prompt marks, which is the one element that says a human builds this; and a dependency graph, the one piece that is a relationship rather than an object, which is what a supply chain actually is. Depth is now built three ways rather than assumed: - The back row is blurred through a single shared feGaussianBlur, so those cards sit BEHIND the surface rather than merely under it. - Dashed tethers run from each piece to the centre. Without them the pieces read as confetti; with them each one belongs to the application. - A fixed particle field fills the corners the cards never reach. Fixed, not random: this renders on the server and must match on the client or hydration mismatches. Still no numerals or words. The artifact scales to 316px at 768, where real type would be illegible, so hierarchy stays carried by weight, size and colour. Verified at that width: 316x251, 54px to the headline, no horizontal overflow. lint, typecheck and build pass. --- .../sections/saas/SaasHeroAppSurface.tsx | 352 ++++++++++++++---- 1 file changed, 274 insertions(+), 78 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx index 6691d22a1..466304771 100644 --- a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx @@ -8,20 +8,23 @@ import type React from 'react'; * it is built from floating around it. * * THE SURFACE. Earlier passes drew it as grey bars on white, which read as a - * wireframe rather than a product: empty stat tiles with no values, five - * identical rows, a rail of featureless grey blobs, no header and no primary - * action. What makes a UI look real is hierarchy and colour, so this one has a - * header with a filled action, drawn rail icons with one active, three stat - * tiles that actually carry a value, and a list whose rows differ from each - * other — different chip tints, two-line entries, distinct status pills, and one - * selected row with an accent rail. + * 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, some in front and some - * behind; translucent, so the surface reads through them; and each on its own - * tint rather than one fill repeated seven times, which was the other thing - * flattening the composition. Variety of object, not just of position: a code - * badge, a cog panel, a package, an avatar row, a dense manifest, an image grid, - * and a selection frame with corner handles. + * 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 @@ -30,8 +33,9 @@ import type React from 'react'; * sites. * * The security story lives in WHAT floats: source, base images, packages, - * libraries, a signed manifest, several carrying a verified tick. That is what - * keeps this container security rather than generic app development. + * libraries, a signed manifest, a dependency graph, several carrying a verified + * tick. That is what keeps this container security rather than generic app + * development. * * 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 @@ -41,15 +45,18 @@ import type React from 'react'; const VIEW_W = 580; const VIEW_H = 460; -const APP_X = 148; -const APP_Y = 84; -const APP_W = 288; -const APP_H = 292; +const APP_X = 140; +const APP_Y = 78; +const APP_W = 312; +const APP_H = 302; const RAIL_W = 44; -const PAD = 15; +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'; @@ -61,16 +68,29 @@ const INK_SOFT = 'rgba(16,19,34,0.17)'; const HAIR = 'rgba(16,19,34,0.08)'; const ROWS = [ - { chip: BLUE, name: 82, sub: 52, selected: true }, - { chip: VIOLET, name: 66, sub: 44, selected: false }, - { chip: TEAL, name: 92, sub: 38, selected: false }, - { chip: BLUE, name: 72, sub: 50, selected: false }, + { 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: 26 }, - { accent: VIOLET, value: 19 }, - { accent: TEAL, value: 30 }, + { 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 { @@ -140,8 +160,8 @@ function Cog({ x, y, r, fill }: { x: number; y: number; r: number; fill: string ); } -/** Rail glyphs, actually drawn. Grey blobs were what made the rail read as a - placeholder rather than as navigation. */ +/** 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) { @@ -237,7 +257,52 @@ function Handles({ w, h }: { w: number; h: number }): React.ReactElement { ); } +/** 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) => ( + + ))} + + ); +} + export function SaasHeroAppSurface(): React.ReactElement { + const cx = APP_X + APP_W / 2; + const cy = APP_Y + APP_H / 2; + return ( + + + + @@ -277,25 +346,68 @@ export function SaasHeroAppSurface(): React.ReactElement { + {/* 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. */} - - - + {/* BEHIND the surface, blurred back. */} + + + {[0, 1, 2].map((i) => ( @@ -310,10 +422,10 @@ export function SaasHeroAppSurface(): React.ReactElement { opacity={0.9} /> ))} - + - + @@ -321,7 +433,7 @@ export function SaasHeroAppSurface(): React.ReactElement { - + + + + + {/* THE APPLICATION SURFACE */} @@ -346,7 +462,6 @@ export function SaasHeroAppSurface(): React.ReactElement { strokeWidth={1} /> - {/* Header: dots, a title, and a filled primary action. */} {[0, 1, 2].map((i) => ( ))} - - + + + - {/* Rail with drawn icons, one active. */} {[0, 1, 2, 3, 4].map((i) => ( - + ))} - {/* Stat tiles that carry a value. Empty tiles were what made the earlier - pass read as a placeholder. */} + {/* Tab strip: another layer of real product structure. */} + {[36, 30, 26].map((w, i) => ( + + + {i === 0 && ( + + )} + + ))} + + {STATS.map((s, i) => ( - - + + ))} - - + - {/* Rows that differ from one another, with one selected. */} {ROWS.map((row, i) => { - const y = APP_Y + 128 + i * 36; + 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. */} - + @@ -490,14 +663,14 @@ export function SaasHeroAppSurface(): React.ReactElement { - + - + {[0, 1, 2, 3, 4, 5].map((i) => ( - + {/* Terminal: the one piece that says a human builds this. */} + + + {[0, 1, 2].map((i) => ( + + ))} + + + + + + + + + + + Date: Tue, 1 Sep 2026 18:05:32 +0530 Subject: [PATCH 27/85] fix(web): bring the SaaS hero package card to the front layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The violet package card sat in the blurred back row, where it washed out behind the surface. It is now in the front row: sharp, and overlapping the window's right edge rather than hiding behind it. Placed first within that group rather than last, so the manifest below still crosses over it. Dropping it at the end would have flattened the right side into a single front plane; this way the stack keeps three depths — blurred back, this card, then the manifest across it. lint and build pass. --- .../sections/saas/SaasHeroAppSurface.tsx | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx index 466304771..4b291a414 100644 --- a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx @@ -433,17 +433,6 @@ export function SaasHeroAppSurface(): React.ReactElement { - - - - - - - - @@ -648,6 +637,22 @@ export function SaasHeroAppSurface(): React.ReactElement { {/* 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. */} + + + + + + + + From 700db7c8d4739995a54635aee54835467e586c28 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 18:12:57 +0530 Subject: [PATCH 28/85] fix(web): mark only the supply-chain pieces in the SaaS hero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verified ticks now follow a rule instead of being distributed for visual balance: they mark source, container images, packages and the signed manifest, and nothing else. Adding one to the source badge completes the set at four. The users panel, the build config and the terminal stay unmarked on purpose. CleanStart verifies components, not your tenants or your CLI, and a tick on those would be a claim the product does not make. This also keeps the artifact saying two things at once, which is what the page needs. Its own sections frame this complexity as risk — AI generated code, open source dependencies, public container images, component visibility — so the composition has to read as "look how much is packed in here" AND "the parts that came from outside are accounted for". Ticking everything would collapse it to the first half and turn the marks into wallpaper. lint and build pass. --- .../sections/saas/SaasHeroAppSurface.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx index 4b291a414..8aa0bdd51 100644 --- a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx @@ -32,10 +32,18 @@ import type React from 'react'; * which is a free stock vector — attribution-bound and already on thousands of * sites. * - * The security story lives in WHAT floats: source, base images, packages, - * libraries, a signed manifest, a dependency graph, several carrying a verified - * tick. That is what keeps this container security rather than generic app - * development. + * 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 @@ -733,6 +741,7 @@ export function SaasHeroAppSurface(): React.ReactElement { strokeWidth={2.5} strokeLinecap="round" /> + From 0bfcfda15445d9505c009f27368050233037222a Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 18:21:23 +0530 Subject: [PATCH 29/85] feat(web): add two structural cards to the SaaS hero and enrich two more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new pieces, each chosen for a kind of structure the set did not already have rather than for another rectangle: - A source tree. Indentation is the point: it is the one card whose shape says "this is nested", which no flat run of bars can express. - An image layer stack. Narrow bars for thin layers, wide for fat ones, each with its size read off to the right — the one card that shows a container image's internals rather than referring to it. Two existing cards gained internal detail: a toggle row on the config panel, and a second smaller entry with a status dot on the users card, so neither reads as a single idea repeated. Placement corrections after looking at both in situ: - The tree was on csa-deep at y=2, which put a near-black card against a near-black background at the frame edge. Moved down so it overlaps the surface top, and onto csa-blue so it is actually visible. - The layer stack ran to y 454 in a 460 frame before its rotation was applied, so a corner crossed the edge. Lifted. Eleven pieces now. Verified at 1440: 599x475, 67px to the headline, no horizontal overflow, nothing crossing the viewBox. lint, typecheck and build pass. --- .../sections/saas/SaasHeroAppSurface.tsx | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx index 8aa0bdd51..bdb385446 100644 --- a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx @@ -307,6 +307,85 @@ function Graph(): React.ReactElement { ); } +/* + * 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; @@ -431,6 +510,9 @@ export function SaasHeroAppSurface(): React.ReactElement { /> ))} + + + @@ -444,6 +526,14 @@ export function SaasHeroAppSurface(): React.ReactElement { + + {/* 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 */} @@ -673,7 +763,9 @@ export function SaasHeroAppSurface(): React.ReactElement { opacity={0.9} /> - + + + @@ -722,6 +814,13 @@ export function SaasHeroAppSurface(): React.ReactElement { + {/* Lifted: at translate(452 376) this ran to y 454 in a 460 frame and the + rotation pushed a corner past it. */} + + + + + Date: Tue, 1 Sep 2026 18:34:45 +0530 Subject: [PATCH 30/85] feat(web): drift two pieces of the SaaS hero artifact A 3px vertical drift on the terminal and the code badge, and nothing else. 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. Both pieces are isolated in the composition, so 3px never carries one into another. The pair run at 6s and 8s with a negative delay on the second, because started together they bob in lockstep and the whole image reads as wobbling rather than two things floating. Applied to WRAPPER groups that carry no transform of their own. The cards are positioned with SVG transform="rotate(...) translate(...)" attributes, and animating transform on those would fight the attribute; a bare wrapper has nothing to collide with and needs no transform-origin. Inside the existing prefers-reduced-motion: no-preference block, so reduced motion gets no drift and the wrappers stay untransformed. Verified by driving the animation clock by hand: the preview pane holds the page visibilityState hidden, which freezes currentTime, so measuring position over wall-clock time reports no movement even though the animation is attached and correct. Stepped through the timeline instead: the terminal sweeps -3 to 0 to +3 across its 6s cycle and the badge runs offset on its 8s one. lint and build pass. --- apps/web/src/app/globals.css | 32 +++++++ .../sections/saas/SaasHeroAppSurface.tsx | 88 +++++++++++-------- 2 files changed, 81 insertions(+), 39 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 675875223..28bbf359f 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1336,6 +1336,38 @@ body { .cs-hero-fade { animation: cs-hero-band 560ms ease-out both; } + + /* A slow drift 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 it sits beside. + + Applied to WRAPPER groups that carry no transform attribute of their own. + The cards themselves 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 are isolated in the composition, so a 3px drift never carries + one into another. */ + @keyframes cs-hero-drift { + from { + transform: translateY(-3px); + } + + to { + transform: translateY(3px); + } + } + + .cs-hero-drift { + animation: cs-hero-drift 6s ease-in-out infinite alternate; + } + + .cs-hero-drift-b { + /* Negative delay desynchronises the pair: started together they bob in + lockstep, which reads as the whole image wobbling. */ + animation: cs-hero-drift 8s ease-in-out -2.5s infinite alternate; + } } @keyframes cs-hero-reveal { diff --git a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx index bdb385446..b0581880a 100644 --- a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx @@ -792,26 +792,34 @@ export function SaasHeroAppSurface(): React.ReactElement { {/* Terminal: the one piece that says a human builds this. */} - - - {[0, 1, 2].map((i) => ( - + + - ))} - - - - - - - - + {[0, 1, 2].map((i) => ( + + ))} + + + + + + + + + {/* Lifted: at translate(452 376) this ran to y 454 in a 460 frame and the @@ -821,26 +829,28 @@ export function SaasHeroAppSurface(): React.ReactElement { - - - - - + + + + + + + From b683c3bdd55fdd4fb9e62e73dc0c2ff9df85602d Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 18:53:46 +0530 Subject: [PATCH 31/85] fix(cms): validate hand-typed internal links in the broken-link scan The extractor only emitted absolute http(s) URLs, so a site-relative path an editor typed into rich text was silently dropped and checked by nothing. The slug-change hook does not cover them either: it follows internal-doc relationships, and a typed path carries none. Two shipped as live 404s in published bodies and went unnoticed until a manual crawl: /guide/orchestration and /guide/security on the container-security guide, and /images/redis/details on the redis comparison post. extractAllLinks now resolves root-relative paths against the public origin so they are HEAD-checked like any other link. Protocol-relative URLs are left alone: //other.example/path is a foreign host, and prefixing our origin would retarget it at ourselves and report a broken external link as healthy. Anchors, mailto: and tel: still fall through to the SSRF guard and are dropped. No extra request load. scan.ts already dedupes to one HEAD-chain per unique URL across every document, and resolving means a relative and an absolute reference to the same page collapse into a single check. Reverses the contract the old "drops site-relative URLs" test pinned; that test is rewritten to assert the new behaviour and explain why it changed. --- .../payload/lib/broken-links/extract.test.ts | 68 ++++++++++++++++++- .../src/payload/lib/broken-links/extract.ts | 30 +++++++- 2 files changed, 93 insertions(+), 5 deletions(-) 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 }); } From 8f4583106e9ac988c8bebb33f5f22a6ef5da920a Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 18:56:43 +0530 Subject: [PATCH 32/85] fix(web): make the SaaS hero drift actually perceptible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3px over 6s is half a pixel per second, which sits below the threshold of perception — the animation was running and correct, and there was nothing to see. Now 8px over 5s with a 2px horizontal sway: 16px of travel, still quiet, but visible. The two pieces also run on OPPOSITE paths now 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 independently. lint and build pass. --- apps/web/src/app/globals.css | 48 ++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 28bbf359f..434d98757 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1337,36 +1337,52 @@ body { animation: cs-hero-band 560ms ease-out both; } - /* A slow drift on two isolated pieces, and only two. The point is to stop the + /* 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 it sits beside. + 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 themselves 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 are isolated in the composition, so a 3px drift never carries - one into another. */ - @keyframes cs-hero-drift { + 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: translateY(-3px); + transform: translate(3px, 7px); } to { - transform: translateY(3px); + transform: translate(-3px, -7px); } } .cs-hero-drift { - animation: cs-hero-drift 6s ease-in-out infinite alternate; + animation: cs-hero-drift-a 5s ease-in-out infinite alternate; } .cs-hero-drift-b { - /* Negative delay desynchronises the pair: started together they bob in - lockstep, which reads as the whole image wobbling. */ - animation: cs-hero-drift 8s ease-in-out -2.5s infinite alternate; + animation: cs-hero-drift-b 6.5s ease-in-out -1.5s infinite alternate; } } From 4cd21488dfcf3bd34e440514d32f7664752ed083 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 19:05:12 +0530 Subject: [PATCH 33/85] feat(web): cursor parallax on the SaaS hero artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artifact already had three depth planes — a blurred back row, the application surface, and the front cards. They now track the pointer at different rates (5px, 10px, 18px at the extremes), which turns implied depth into real depth. Implementation notes that matter: - Pointer position is published as two CSS custom properties and nothing else. React state would re-render the whole SVG on every mousemove; custom properties let the compositor do the work. - Writes are rAF-throttled, so a burst of pointermove collapses to one write per frame. - The transition is deliberate, not leftover. A short ease gives the layers a slight trail behind the cursor, which reads as mass; without it the movement snaps. - Gated on (hover: hover) and (pointer: fine) and on prefers-reduced-motion, and it attaches no listener at all when either gate fails. Keyed to the ARTIFACT, not the section. Tracking the whole hero meant the illustration drifted while the cursor was over the headline on the opposite side, which reads as the page moving by itself rather than as a response to pointing at the thing. The artifact is pointer-events-none, so "inside" is computed from its box rather than detected from events. Widened the viewBox to -26 -22 632 504. The parallax translates the front plane up to 18px, and with the cards' rotation overhang the terminal reached x 592 in a 580-wide frame, where the SVG viewport clipped it. Artwork coordinates are unchanged; only the window onto them grew. Verified at both extremes: content peaks at x 594.1 against a frame edge of 606, and nothing crosses on any side. lint, typecheck and build pass. --- apps/web/src/app/globals.css | 33 +++++ .../src/components/sections/saas/SaasHero.tsx | 5 +- .../sections/saas/SaasHeroAppSurface.tsx | 21 +++- .../sections/saas/SaasHeroParallax.tsx | 113 ++++++++++++++++++ 4 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/sections/saas/SaasHeroParallax.tsx diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 434d98757..503b4b3e4 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1384,6 +1384,39 @@ body { .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 { diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index 7cb811e3c..c9ca86e1f 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -1,6 +1,7 @@ import type React from 'react'; import Link from 'next/link'; import { SaasHeroAppSurface } from './SaasHeroAppSurface'; +import { SaasHeroParallax } from './SaasHeroParallax'; import { HeroReveal } from '@/components/ui/Reveal'; /* @@ -87,7 +88,9 @@ export function SaasHero(): React.ReactElement { width: 'clamp(280px, 42vw, 600px)', }} > - + + +
      diff --git a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx index b0581880a..4cf0594b9 100644 --- a/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx +++ b/apps/web/src/components/sections/saas/SaasHeroAppSurface.tsx @@ -391,8 +391,13 @@ export function SaasHeroAppSurface(): React.ReactElement { 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. - + {[-62, -40, -20, 0, 20, 40, 62].map((a) => ( + {( [ [96, 120], @@ -492,7 +497,11 @@ export function SaasHeroAppSurface(): React.ReactElement { {/* BEHIND the surface, blurred back. */} - + @@ -537,7 +546,7 @@ export function SaasHeroAppSurface(): React.ReactElement { {/* THE APPLICATION SURFACE */} - + {/* 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 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} +
      + ); +} From 79d3beda323bef231379e4a2687b417f0baddad0 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 19:24:11 +0530 Subject: [PATCH 34/85] feat(web): rebuild the Move Beyond Shift Left pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigned around the actual idea: a standard pipeline — Code, Build, Test, Deploy, Security Review — with Verified Components arriving from the left as a separate piece and docking into it. One container instead of three. The previous build nested an outer surface, a route stack and a per-stage housing, each with its own border and background, which is what made it read as boxes inside boxes. There is a single deck now, given real treatment (inner grid, corner marks, a slow sheen) with the stages sitting directly on it. Colour is rationed, because it carries the whole idea. Green belongs to the incoming components, the junction they lock into, and the assurance that then travels the track. Every pipeline stage now shares ONE neutral treatment — verified identical in the DOM. Security Review had its own green styling before, which made it look like a different system bolted on the end rather than the last stage of the same pipeline. Verified Components is no longer static. It slides in from the left, its lock ring fires as it seats, the junction snaps in, a pulse then travels link by link with each stage's dot going green as it passes, and the release resolves at the end. Every animation runs on the SAME 7s period and differs only by delay. Mixed durations resynchronise at their lowest common multiple, which reads as a fault rather than a loop. Sixteen animations, one clock. Rendered once, not twice. The old component emitted a full desktop diagram and a full mobile diagram side by side, so every icon existed twice in the DOM. Orientation is a CSS concern; the tree flips from row to column at the breakpoint. The test suite is rewritten to match. It previously read the stylesheet and asserted on rule contents — exact hex values, z-index numbers, gradient strings, whether a selector sat inside a given media query. That pinned the visual implementation in place: every one of those assertions fails on a redesign that changes nothing a user could describe, and none of them would have caught the diagram rendering the wrong stages in the wrong order. It now asserts the copy, the accessible ordered list, stage order, single-render, and that the retired nodes are gone. lint, typecheck, 414 tests and build all pass. --- .../sections/saas/SaasShiftLeft.test.tsx | 184 +--- .../sections/saas/SaasVerifiedCore.module.css | 963 +++++++++--------- .../sections/saas/SaasVerifiedCore.tsx | 270 +++-- 3 files changed, 660 insertions(+), 757 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx index ce8832778..cc9c2aa5f 100644 --- a/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx +++ b/apps/web/src/components/sections/saas/SaasShiftLeft.test.tsx @@ -1,8 +1,18 @@ -import { existsSync, readFileSync } from 'node:fs'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import { SaasShiftLeft } from './SaasShiftLeft'; +/* + * These assertions are about what the section SAYS and the order it says it in. + * + * The previous suite read SaasVerifiedCore.module.css and asserted on rule + * contents — exact hex values, z-index numbers, gradient strings, whether a + * selector appeared inside a particular media query. That pinned the diagram's + * visual implementation in place: every one of those assertions failed on a + * redesign that changed nothing a user could describe, while none of them would + * have caught the diagram rendering the wrong stages in the wrong order. + */ + function toText(markup: string): string { return markup .replace(/<[^>]+>/g, ' ') @@ -17,162 +27,74 @@ 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('renders structurally distinct desktop and mobile Verified Core diagrams', () => { + it('exposes the pipeline to assistive tech as an ordered list', () => { const html = renderSection(); - const mobile = html.slice(html.indexOf('data-verified-core="mobile"')); - - 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='); - expect(html).not.toContain('Unverified Components'); - expect(mobile).not.toContain('data-late-review-path="return"'); - expect(mobile).toContain('data-verified-source="verified-components"'); - expect(mobile).toMatch( - /data-core-stage="code"[\s\S]*data-core-stage="build"[\s\S]*data-core-stage="test"[\s\S]*data-core-stage="deploy"/, - ); - expect(mobile).not.toContain('data-security-review="closed"'); - expect(mobile).toContain('data-security-review="open"'); - expect(mobile).toContain('data-release-exit="approved"'); + 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', + ]); }); - it('carries one verified core through every delivery stage', () => { + it('runs verified components into the pipeline, in order', () => { const html = renderSection(); expect(html).toContain('data-verified-source="verified-components"'); expect(html).toContain('data-trust-ribbon="continuous"'); + expect(html).toContain('data-security-review="open"'); + expect(html).toContain('data-release-exit="approved"'); + + // The source attaches before the first stage, and Security Review closes it. 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"/, + /data-verified-source[\s\S]*data-trust-ribbon[\s\S]*data-core-stage="code"[\s\S]*data-core-stage="build"[\s\S]*data-core-stage="test"[\s\S]*data-core-stage="deploy"[\s\S]*data-security-review[\s\S]*data-release-exit/, ); }); - it('renders a distinct stage icon in both responsive diagrams', () => { + it('renders each stage icon exactly once', () => { const html = renderSection(); - for (const stage of ['code', 'build', 'test', 'deploy']) { - expect(html.match(new RegExp(`data-stage-icon="${stage}"`, 'g'))).toHaveLength(2); + // The diagram used to be emitted twice, once for desktop and once for + // mobile, so every icon existed twice in the DOM. Orientation is now a CSS + // concern and the tree is rendered once. + for (const stage of ['code', 'build', 'test', 'deploy', 'review']) { + expect(html.match(new RegExp(`data-stage-icon="${stage}"`, 'g'))).toHaveLength(1); } }); - it('keeps stage icons visually prominent inside their core windows', () => { - const stylesheet = readFileSync( - new URL('./SaasVerifiedCore.module.css', import.meta.url), - 'utf8', - ); - const stageIconRule = stylesheet.match(/\.stageIcon\s*{([^}]*)}/)?.[1] ?? ''; - - expect(stageIconRule).toContain('width: 27px'); - expect(stageIconRule).toContain('height: 27px'); - }); - - it('layers the provenance rail behind an opaque icon plate', () => { - const stylesheet = readFileSync( - new URL('./SaasVerifiedCore.module.css', import.meta.url), - 'utf8', - ); - const coreLineRule = stylesheet.match(/\.coreLine\s*{([^}]*)}/)?.[1] ?? ''; - const iconPlateRule = stylesheet.match(/\.coreWindow::after\s*{([^}]*)}/)?.[1] ?? ''; - const stageIconRule = stylesheet.match(/\.stageIcon\s*{([^}]*)}/)?.[1] ?? ''; - - expect(coreLineRule).toContain('position: absolute'); - expect(coreLineRule).toContain('z-index: 0'); - expect(iconPlateRule).toContain('position: absolute'); - expect(iconPlateRule).toContain('z-index: 1'); - expect(iconPlateRule).toContain('#0c3548 0%'); - expect(iconPlateRule).toContain('#051725 100%'); - expect(stageIconRule).toContain('position: relative'); - expect(stageIconRule).toContain('z-index: 2'); - }); - - it('masks the rail inside the scanner and fades the desktop grid', () => { - const stylesheet = readFileSync( - new URL('./SaasVerifiedCore.module.css', import.meta.url), - 'utf8', - ); - const scannerFrameRule = stylesheet.match(/\.scannerFrame\s*{([^}]*)}/)?.[1] ?? ''; - const desktopSurfaceRules = [ - ...stylesheet.matchAll(/\.desktopSurface\s*{([^}]*)}/g), - ]; - const desktopSurfaceRule = desktopSurfaceRules[desktopSurfaceRules.length - 1]?.[1] ?? ''; - const gridOverlayRule = stylesheet.match(/\.desktopSurface::after\s*{([^}]*)}/)?.[1] ?? ''; - const routeStackRule = stylesheet.match(/\.routeStack\s*{([^}]*)}/)?.[1] ?? ''; - const gridOverlayMatches = [...stylesheet.matchAll(/\.desktopSurface::after\s*{/g)]; - const desktopMediaStart = stylesheet.indexOf('@media (min-width: 1024px)'); - const desktopMediaEnd = stylesheet.indexOf('@keyframes verifiedSignal'); - const desktopMediaRule = stylesheet.slice(desktopMediaStart, desktopMediaEnd); - - expect(scannerFrameRule).toContain( - 'background: linear-gradient(180deg, #081b2d 0%, #061421 100%)', - ); - expect(desktopSurfaceRule).not.toContain('rgba(138, 174, 228, 0.055)'); - expect(gridOverlayMatches).toHaveLength(1); - expect(desktopMediaRule).toContain('.desktopSurface::after'); - expect(gridOverlayRule).toContain('position: absolute'); - expect(gridOverlayRule).toContain('z-index: 0'); - expect(gridOverlayRule).toContain('rgba(138, 174, 228, 0.028)'); - expect(gridOverlayRule).toContain('-webkit-mask-image: radial-gradient'); - expect(gridOverlayRule).toContain('mask-image: radial-gradient'); - expect(gridOverlayRule).toContain('pointer-events: none'); - expect(routeStackRule).toContain('position: relative'); - expect(routeStackRule).toContain('z-index: 1'); - }); - - it('renders only the verified route and its approved release', () => { + it('keeps the diagram out of the accessibility tree', () => { const html = renderSection(); + const diagram = html.slice(html.indexOf('data-verified-source')); - expect(html).toContain('data-security-review="open"'); - expect(html).toContain('data-release-exit="approved"'); - expect(html).toContain('data-release-arrow="forward"'); - expect(html).not.toContain('data-security-review="closed"'); - expect(html).not.toContain('data-late-review-path="return"'); + // The ordered list above is the accessible reading of the pipeline; the + // tiles are decoration and must not be announced alongside it. + expect(html).toMatch(/aria-hidden="true"[\s\S]*data-verified-source/); + expect(diagram).not.toContain(' { + it('drops the retired container shells and legacy nodes', () => { const html = renderSection(); - expect(html).not.toContain('aria-label="Code, Build, Test, Deploy, Security Review"'); - 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).not.toMatch(/preserveAspectRatio=.none./); - }); - - 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*\.scannerBeam,\s*\.releaseCheck\s*{\s*animation: none !important;/, - ); - expect(stylesheet).not.toContain('.returnPulse'); - expect(stylesheet).not.toContain('.lateRoute'); - expect(stylesheet).not.toContain('.mobileLateCard'); - }); - - it('keeps the trust ribbon cyan-to-mint and animates the approved release state', () => { - const stylesheet = readFileSync( - new URL('./SaasVerifiedCore.module.css', import.meta.url), - 'utf8', - ); - const trustRibbonRule = stylesheet.match(/\.trustRibbon\s*{([\s\S]*?)\n {2}}/)?.[1] ?? ''; - - expect(trustRibbonRule).toContain('#7fe3ff 0%'); - expect(trustRibbonRule).not.toContain('#9a51ff'); - expect(stylesheet).toMatch(/\.releaseCheck\s*{[\s\S]*animation: releaseApproval/); - expect(stylesheet).toContain('@keyframes releaseApproval'); + expect(html).not.toContain('data-cleanroom-reactor='); + expect(html).not.toContain('data-reactor-chamber='); + expect(html).not.toContain('Unverified Components'); + expect(html).not.toContain('data-verified-core="desktop"'); + expect(html).not.toContain('data-verified-core="mobile"'); + expect(html).not.toContain('data-late-review-path="return"'); + expect(html).not.toContain('data-security-review="closed"'); }); }); diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css index f0cda566e..6c050eb18 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css @@ -1,646 +1,633 @@ +/* + * "Move Beyond Shift Left" pipeline. + * + * ONE deck, not three nested shells. The previous stylesheet wrapped the diagram + * in an outer surface, a route stack and a per-stage housing, each with its own + * border and background, which is why it read as boxes inside boxes. There is a + * single container now and the stages sit directly on it. + * + * Colour is rationed, because it carries the whole idea: + * - GREEN belongs to the incoming Verified Components, the junction they lock + * into, and the assurance that then travels the track. Nothing else. + * - Every pipeline stage, Security Review included, shares one neutral + * treatment. Colouring Security Review separately was what made it look like + * a different system bolted on the end rather than the last stage of the + * same pipeline. + * + * Every animation runs on the SAME period (--cycle) and differs only by delay. + * Mixed durations resynchronise at their lowest common multiple, which reads as + * a fault rather than a loop. + */ + .stage { width: 100%; - max-width: 1120px; - margin-inline: auto; + --cycle: 7s; } -.desktopSurface { - display: none; -} +/* ---------- The deck ---------- */ -.mobileSurface { - display: grid; -} - -.mobileVerifiedCard { +.deck { position: relative; overflow: hidden; - border: 1px solid rgba(151, 182, 235, 0.2); - border-radius: 22px; + padding: clamp(30px, 3.4vw, 52px) clamp(20px, 2.8vw, 44px); + border-radius: clamp(20px, 2.2vw, 28px); + border: 1px solid rgba(146, 180, 244, 0.16); background: - linear-gradient(rgba(118, 159, 218, 0.055) 1px, transparent 1px), - linear-gradient(90deg, rgba(118, 159, 218, 0.055) 1px, transparent 1px), - linear-gradient(155deg, rgba(18, 29, 57, 0.96) 0%, rgba(6, 12, 29, 0.98) 100%); - background-size: 34px 34px, 34px 34px, auto; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 24px 50px rgba(3, 7, 21, 0.3); + radial-gradient(120% 140% at 8% 0%, rgba(45, 212, 160, 0.07) 0%, transparent 46%), + linear-gradient(180deg, rgba(16, 24, 66, 0.72) 0%, rgba(8, 12, 40, 0.62) 100%); + box-shadow: + inset 0 1px 0 rgba(196, 218, 255, 0.09), + 0 34px 80px -40px rgba(2, 6, 28, 0.95); } -.mobileVerifiedCard { - padding: 24px 18px 28px; - border-color: rgba(103, 227, 213, 0.3); - background: - radial-gradient(circle at 50% 12%, rgba(44, 193, 235, 0.17), transparent 32%), - linear-gradient(rgba(118, 159, 218, 0.055) 1px, transparent 1px), - linear-gradient(90deg, rgba(118, 159, 218, 0.055) 1px, transparent 1px), - linear-gradient(155deg, rgba(12, 31, 55, 0.97) 0%, rgba(4, 15, 29, 0.99) 100%); - background-size: auto, 34px 34px, 34px 34px, auto; +.deckGrid { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.4; + background-image: + linear-gradient(rgba(150, 184, 244, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(150, 184, 244, 0.05) 1px, transparent 1px); + background-size: 46px 46px; + mask-image: radial-gradient(78% 78% at 50% 50%, #000 32%, transparent 100%); + -webkit-mask-image: radial-gradient(78% 78% at 50% 50%, #000 32%, transparent 100%); +} + +/* A slow sweep across the deck. It is the only thing that keeps the container + itself alive; without it the panel is inert behind a moving diagram. */ +.deckSheen { + position: absolute; + top: 0; + bottom: 0; + left: -30%; + width: 30%; + pointer-events: none; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(120, 190, 255, 0.05) 50%, + transparent 100% + ); } -.mobileCoreTrack { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - gap: 13px; +.corner { + position: absolute; + width: 14px; + height: 14px; + pointer-events: none; + border: 1px solid rgba(146, 190, 255, 0.3); } -.mobileTrustRibbon { - position: absolute; - z-index: 0; - top: 72px; - bottom: 74px; - left: 50%; - width: 5px; - transform: translateX(-50%); - border-radius: 999px; - background: linear-gradient(180deg, #7fe3ff 0%, #57e4d0 74%, #71efb8 100%); - box-shadow: 0 0 18px rgba(95, 227, 192, 0.42); +.cornerTl { + top: 14px; + left: 14px; + border-right: 0; + border-bottom: 0; + border-top-left-radius: 5px; } -.sourceShell, -.stageShell { - position: relative; - z-index: 2; - width: 100%; - padding: 1px; - clip-path: polygon(12px 0, calc(100% - 12px) 0, 100% 12px, 100% calc(100% - 12px), calc(100% - 12px) 100%, 12px 100%, 0 calc(100% - 12px), 0 12px); +.cornerTr { + top: 14px; + right: 14px; + border-left: 0; + border-bottom: 0; + border-top-right-radius: 5px; } -.sourceShell { - max-width: 280px; - min-height: 86px; - background: linear-gradient(135deg, rgba(163, 112, 255, 0.9), rgba(93, 237, 204, 0.95)); - filter: drop-shadow(0 14px 28px rgba(27, 184, 211, 0.2)); +.cornerBl { + bottom: 14px; + left: 14px; + border-right: 0; + border-top: 0; + border-bottom-left-radius: 5px; } -.sourceInner, -.stageInner { +.cornerBr { + bottom: 14px; + right: 14px; + border-left: 0; + border-top: 0; + border-bottom-right-radius: 5px; +} + +/* ---------- Track ---------- */ + +.track { + position: relative; + z-index: 1; + display: flex; + align-items: flex-start; + justify-content: center; width: 100%; - height: 100%; - clip-path: inherit; } -.sourceInner { +.segment { + display: contents; +} + +/* ---------- Verified Components: the piece that arrives ---------- */ + +.source { display: flex; align-items: center; - justify-content: center; gap: 12px; - min-height: 84px; - padding: 14px; - background: - linear-gradient(135deg, rgba(62, 42, 111, 0.88) 0%, rgba(12, 68, 83, 0.94) 100%), - #0b1f35; + flex: 0 0 auto; + padding-top: 4px; } -.sourceSeal { +.sourceTile { + position: relative; display: grid; - flex: 0 0 auto; - width: 40px; - height: 40px; place-items: center; - border: 1px solid rgba(210, 251, 255, 0.72); - border-radius: 50%; - background: linear-gradient(145deg, #c9f8ff 0%, #66e4d1 100%); - color: #0b2734; - box-shadow: 0 0 0 7px rgba(104, 228, 218, 0.08), 0 0 24px rgba(104, 228, 218, 0.34); + width: 48px; + height: 48px; + flex: 0 0 auto; + border-radius: 999px; + color: #04120d; + background: linear-gradient(180deg, #55e9b3 0%, #1fc78b 100%); + box-shadow: + 0 0 0 6px rgba(45, 212, 160, 0.1), + 0 12px 26px -10px rgba(31, 199, 139, 0.8); } -.sourceSeal svg, -.scannerStateIcon svg, -.releaseCheck svg { - width: 20px; - height: 20px; +.sourceTile svg { + width: 22px; + height: 22px; } -.sourceLabel, -.stageLabel, -.scannerLabel { - font-family: var(--font-display); - font-weight: 600; - letter-spacing: -0.02em; +/* Expands once as the piece locks in, then gets out of the way. */ +.sourceRing { + position: absolute; + inset: -6px; + border-radius: 999px; + border: 1.5px solid rgba(78, 230, 173, 0.85); + opacity: 0; } .sourceLabel { - color: #f5fdff; - font-size: var(--fs-body-sm); + font-family: var(--font-sans); + font-size: 14px; + font-weight: 500; line-height: 1.25; - text-align: left; + letter-spacing: -0.01em; + color: #93f3d1; + white-space: nowrap; } -.stageShell { - max-width: 260px; - min-height: 72px; - background: linear-gradient(135deg, rgba(131, 215, 255, 0.56), rgba(96, 225, 193, 0.33)); - filter: drop-shadow(0 10px 20px rgba(9, 25, 46, 0.24)); -} +/* ---------- Links ---------- */ -.stageInner { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 16px; - min-height: 70px; - padding: 13px 16px; - background: - linear-gradient(115deg, rgba(20, 43, 72, 0.95), rgba(7, 28, 45, 0.97)), - #081d31; +.link { + position: relative; + flex: 1 1 auto; + min-width: 28px; + height: 2px; + margin-top: 27px; + border-radius: 2px; + background: rgba(150, 182, 240, 0.2); } -.stageLabel { - color: rgba(241, 250, 255, 0.94); - font-size: var(--fs-body-sm); - line-height: 1.2; +.linkMerge { + min-width: 52px; + background: linear-gradient(90deg, rgba(45, 212, 160, 0.7) 0%, rgba(150, 182, 240, 0.2) 100%); } -.coreWindow { - position: relative; - display: grid; - width: 46px; - height: 46px; - place-items: center; - border: 1px solid rgba(127, 227, 255, 0.45); - border-radius: 50%; - background: rgba(6, 31, 46, 0.86); - box-shadow: 0 0 0 6px rgba(95, 227, 192, 0.045); +.linkExit { + min-width: 44px; + flex: 0 0 auto; } -.coreLine { +.junction { position: absolute; - z-index: 0; top: 50%; - right: -17px; - left: -17px; - height: 3px; - transform: translateY(-50%); - border-radius: 999px; - background: linear-gradient(90deg, #7fe3ff, #5fe3c0); + left: 50%; + width: 9px; + height: 9px; + border-radius: 2px; + background: #2dd4a0; + transform: translate(-50%, -50%) rotate(45deg); } -.coreWindow::after { +.link::after { + content: ''; position: absolute; - z-index: 1; - inset: 2px; + inset: 0; border-radius: inherit; - background: radial-gradient(circle at 50% 42%, #0c3548 0%, #061f31 68%, #051725 100%); - box-shadow: inset 0 0 0 1px rgba(127, 227, 255, 0.08); - content: ''; - pointer-events: none; -} - -.stageIcon { - position: relative; - z-index: 2; - width: 27px; - height: 27px; - overflow: visible; - color: #c9faff; - filter: drop-shadow(0 0 5px rgba(95, 227, 192, 0.76)); - stroke: currentColor; - stroke-width: 1.8; - stroke-linecap: round; - stroke-linejoin: round; -} - -.scanner { - position: relative; - z-index: 3; + opacity: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(45, 212, 160, 0.18) 28%, + #5df0bb 50%, + rgba(45, 212, 160, 0.18) 72%, + transparent 100% + ); +} + +.exitCheck { + position: absolute; + top: 50%; + right: -10px; display: grid; - min-width: 86px; - min-height: 104px; place-items: center; - align-content: center; - gap: 8px; + width: 24px; + height: 24px; + transform: translateY(-50%) scale(0.5); + opacity: 0; + border-radius: 999px; + color: #04120d; + background: linear-gradient(180deg, #55e9b3 0%, #1fc78b 100%); + box-shadow: 0 0 0 5px rgba(45, 212, 160, 0.12); } -.scannerFrame { - position: relative; - width: 68px; - height: 66px; - overflow: hidden; - border-top: 3px solid currentColor; - border-right: 3px solid currentColor; - border-left: 3px solid currentColor; - border-radius: 10px 10px 3px 3px; - background: linear-gradient(180deg, #081b2d 0%, #061421 100%); - box-shadow: 0 0 18px color-mix(in srgb, currentColor 24%, transparent); +.exitCheck svg { + width: 13px; + height: 13px; } -.scannerOpen { - color: #70edbd; -} +/* ---------- Stages: one treatment for all of them ---------- */ -.scannerBeam { - position: absolute; - inset: 4px 7px auto; - height: 2px; - border-radius: 999px; - background: currentColor; - box-shadow: 0 0 12px currentColor; - animation: scannerSweep 8.8s cubic-bezier(0.65, 0, 0.35, 1) infinite; +.node { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: center; + gap: 11px; + width: 96px; } -.scannerBarrier { +.tile { + position: relative; + display: grid; + place-items: center; + width: 56px; + height: 56px; + border-radius: 17px; + color: rgba(208, 226, 255, 0.92); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.035) 100%); + border: 1px solid rgba(168, 196, 245, 0.18); + box-shadow: + inset 0 1px 0 rgba(214, 231, 255, 0.14), + 0 14px 28px -18px rgba(3, 8, 30, 0.95); +} + +/* Turns green as the pulse reaches the stage: the assurance propagating, not a + status the stage had on its own. */ +.tileDot { position: absolute; - right: 8px; - bottom: 11px; - left: 8px; - height: 7px; + top: 7px; + right: 7px; + width: 6px; + height: 6px; border-radius: 999px; - background: currentColor; - transform-origin: right center; + background-color: rgba(168, 196, 245, 0.35); } -.scannerOpen .scannerBarrier { - transform: translateY(-31px) rotate(-90deg); - opacity: 0.72; -} - -.scannerStateIcon { - position: absolute; - top: 50%; - left: 50%; - display: grid; +.icon { width: 26px; height: 26px; - place-items: center; - transform: translate(-50%, -44%); + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; } -.scannerLabel { - color: currentColor; - font-size: var(--fs-caption); - line-height: 1.2; +.label { + font-family: var(--font-sans); + font-size: 13px; + font-weight: 500; + line-height: 1.3; + letter-spacing: -0.01em; text-align: center; + color: rgba(223, 232, 255, 0.78); } -.releaseExit { - position: absolute; - top: 26px; - left: calc(50% + 31px); - display: flex; - align-items: center; -} +/* ---------- Motion ---------- */ -.releaseLine { - width: 28px; - height: 2px; - background: linear-gradient(90deg, #70edbd, rgba(112, 237, 189, 0.18)); -} +@media (prefers-reduced-motion: no-preference) { + .source { + animation: dock var(--cycle) cubic-bezier(0.22, 1, 0.36, 1) infinite; + } -.releaseArrow { - width: 0; - height: 0; - margin-left: -1px; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - border-left: 8px solid #70edbd; - filter: drop-shadow(0 0 5px rgba(112, 237, 189, 0.48)); -} + .sourceRing { + animation: lockRing var(--cycle) ease-out infinite; + } -.releaseCheck { - display: grid; - width: 30px; - height: 30px; - place-items: center; - border: 1px solid rgba(160, 255, 216, 0.74); - border-radius: 50%; - background: rgba(15, 75, 62, 0.92); - color: #a0ffd8; - box-shadow: 0 0 18px rgba(112, 237, 189, 0.3); - animation: releaseApproval 8.8s ease-in-out infinite; -} + .junction { + animation: junctionLock var(--cycle) ease-out infinite; + } -.scannerVertical { - margin-top: 4px; - padding-bottom: 50px; -} + .link::after { + animation: flow var(--cycle) var(--flow-delay, 0ms) linear infinite; + } -.scannerVertical .releaseExit { - top: auto; - bottom: 2px; - left: 50%; - flex-direction: column; - transform: translateX(-50%); -} + .tileDot { + animation: dotLit var(--cycle) var(--lit-delay, 0ms) ease-out infinite; + } -.scannerVertical .releaseLine { - width: 2px; - height: 18px; - background: linear-gradient(180deg, #70edbd, rgba(112, 237, 189, 0.18)); + .exitCheck { + animation: exitPop var(--cycle) cubic-bezier(0.22, 1, 0.36, 1) infinite; + } + + .deckSheen { + animation: sheen var(--cycle) ease-in-out infinite; + } } -.scannerVertical .releaseArrow { - margin-top: -1px; - margin-left: 0; - transform: rotate(90deg); +@keyframes dock { + 0% { + opacity: 0; + transform: translateX(-54px); + } + + 13% { + opacity: 1; + transform: translateX(0); + } + + 93% { + opacity: 1; + transform: translateX(0); + } + + 99%, + 100% { + opacity: 0; + transform: translateX(-54px); + } } -@keyframes scannerSweep { +@keyframes lockRing { 0%, - 14% { - transform: translateY(0); - opacity: 0.32; + 11% { + opacity: 0; + transform: scale(0.72); } - 42%, - 62% { - transform: translateY(50px); - opacity: 0.95; + + 15% { + opacity: 0.9; + transform: scale(1); } - 76%, + + 26%, 100% { - transform: translateY(0); - opacity: 0.32; + opacity: 0; + transform: scale(1.45); } } -@keyframes releaseApproval { +@keyframes junctionLock { 0%, - 56% { - transform: scale(0.92); - opacity: 0.72; + 10% { + opacity: 0; + transform: translate(-50%, -50%) rotate(45deg) scale(0.3); } - 68%, - 84% { - transform: scale(1.08); + + 15% { opacity: 1; - box-shadow: 0 0 24px rgba(112, 237, 189, 0.58); + transform: translate(-50%, -50%) rotate(45deg) scale(1.55); + box-shadow: 0 0 0 7px rgba(45, 212, 160, 0.2); } - 94%, + + 21%, + 94% { + opacity: 1; + transform: translate(-50%, -50%) rotate(45deg) scale(1); + box-shadow: 0 0 0 4px rgba(45, 212, 160, 0.14); + } + + 99%, 100% { - transform: scale(0.92); - opacity: 0.72; - } -} - -@media (min-width: 1024px) { - .desktopSurface { - position: relative; - isolation: isolate; - display: block; - min-height: 284px; - overflow: hidden; - padding: 30px 34px 34px; - border: 1px solid rgba(137, 181, 244, 0.28); - border-radius: 28px; - background: - radial-gradient(circle at 35% 78%, rgba(44, 193, 235, 0.16), transparent 35%), - radial-gradient(circle at 82% 22%, rgba(105, 65, 203, 0.14), transparent 31%), - linear-gradient(145deg, rgba(15, 25, 51, 0.98) 0%, rgba(5, 12, 27, 0.99) 52%, rgba(5, 24, 34, 0.98) 100%); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.06), - inset 0 -1px 0 rgba(87, 218, 208, 0.08), - 0 36px 78px rgba(3, 7, 21, 0.46); + opacity: 0; + transform: translate(-50%, -50%) rotate(45deg) scale(0.3); + box-shadow: 0 0 0 0 rgba(45, 212, 160, 0); } +} - .desktopSurface::before { - position: absolute; - z-index: -1; - inset: 0; - content: ''; - background: linear-gradient(112deg, transparent 0%, rgba(126, 226, 255, 0.035) 44%, transparent 62%); - pointer-events: none; - } - - .desktopSurface::after { - position: absolute; - z-index: 0; - inset: 0; - content: ''; - background: - linear-gradient(rgba(138, 174, 228, 0.028) 1px, transparent 1px), - linear-gradient(90deg, rgba(138, 174, 228, 0.028) 1px, transparent 1px); - background-size: 42px 42px; - -webkit-mask-image: radial-gradient(ellipse 78% 70% at 50% 50%, #000 0%, rgba(0, 0, 0, 0.82) 52%, transparent 100%); - mask-image: radial-gradient(ellipse 78% 70% at 50% 50%, #000 0%, rgba(0, 0, 0, 0.82) 52%, transparent 100%); - pointer-events: none; - } - - .mobileSurface { - display: none; - } - - .surfaceChrome { - position: absolute; - z-index: 0; - inset: 20px; - pointer-events: none; - } - - .cornerTopLeft, - .cornerTopRight, - .cornerBottomLeft, - .cornerBottomRight { - position: absolute; - width: 24px; - height: 24px; - border-color: rgba(136, 200, 247, 0.34); - } - - .cornerTopLeft { - top: 0; - left: 0; - border-top: 1px solid; - border-left: 1px solid; - } - - .cornerTopRight { - top: 0; - right: 0; - border-top: 1px solid; - border-right: 1px solid; - } - - .cornerBottomLeft { - bottom: 0; - left: 0; - border-bottom: 1px solid; - border-left: 1px solid; - } - - .cornerBottomRight { - right: 0; - bottom: 0; - border-right: 1px solid; - border-bottom: 1px solid; - } - - .measureTop, - .measureBottom { - position: absolute; - left: 50%; - width: 116px; - height: 1px; - transform: translateX(-50%); - background: repeating-linear-gradient(90deg, rgba(127, 227, 255, 0.28) 0 2px, transparent 2px 10px); - } - - .measureTop { - top: 0; - } - - .measureBottom { - bottom: 0; +@keyframes flow { + 0% { + opacity: 0; + transform: translateX(-100%); } - .routeStack { - position: relative; - z-index: 1; - display: grid; - gap: 16px; + 2% { + opacity: 1; } - .verifiedGrid { - position: relative; - z-index: 2; - display: grid; - grid-template-columns: 1.38fr repeat(4, minmax(0, 1fr)) 1.2fr; - gap: 12px; - align-items: center; + 10% { + opacity: 1; + transform: translateX(100%); + } + + 11%, + 100% { + opacity: 0; + transform: translateX(100%); } +} - .verifiedRoute { - position: relative; - min-height: 220px; - padding: 30px 24px; - border: 1px solid rgba(95, 227, 192, 0.24); - border-radius: 18px; - background: - radial-gradient(circle at 11% 50%, rgba(136, 83, 255, 0.18), transparent 21%), - linear-gradient(100deg, rgba(29, 54, 91, 0.58) 0%, rgba(6, 33, 48, 0.62) 58%, rgba(8, 46, 45, 0.46) 100%); - box-shadow: inset 0 1px 0 rgba(198, 251, 255, 0.07), 0 0 46px rgba(44, 193, 235, 0.07); +@keyframes dotLit { + 0% { + transform: scale(1); + background-color: rgba(168, 196, 245, 0.35); + box-shadow: 0 0 0 0 rgba(45, 212, 160, 0); } - .verifiedRoute::after { - position: absolute; - inset: 10px; - border: 1px solid rgba(127, 227, 255, 0.07); - border-radius: 12px; - content: ''; - pointer-events: none; + 4% { + transform: scale(1.7); + background-color: #5df0bb; + box-shadow: 0 0 0 4px rgba(45, 212, 160, 0.22); + } + + 10%, + 84% { + transform: scale(1); + background-color: #5df0bb; + box-shadow: 0 0 0 3px rgba(45, 212, 160, 0.12); + } + + 93%, + 100% { + transform: scale(1); + background-color: rgba(168, 196, 245, 0.35); + box-shadow: 0 0 0 0 rgba(45, 212, 160, 0); + } +} + +@keyframes exitPop { + 0%, + 47% { + opacity: 0; + transform: translateY(-50%) scale(0.5); } - .verifiedGrid { - min-height: 158px; + 53% { + opacity: 1; + transform: translateY(-50%) scale(1.28); } - .trustRibbon { - position: absolute; - z-index: 1; - top: 50%; - right: 5.5%; - left: 6.8%; - height: 6px; - transform: translateY(-50%); - border-radius: 999px; - background: linear-gradient(90deg, #7fe3ff 0%, #69e4dd 42%, #5fe3c0 78%, #70edbd 100%); - box-shadow: 0 0 18px rgba(95, 227, 192, 0.38), 0 0 42px rgba(44, 193, 235, 0.12); + 58%, + 92% { + opacity: 1; + transform: translateY(-50%) scale(1); } - .verifiedPulse { - position: absolute; - z-index: 1; - top: 50%; - left: 8%; - width: 13px; - height: 13px; - transform: translate(-50%, -50%); - border: 2px solid #e7ffff; - border-radius: 50%; - background: #70edbd; - box-shadow: 0 0 18px #70edbd; - animation: verifiedSignal 8.8s cubic-bezier(0.55, 0, 0.35, 1) infinite; + 98%, + 100% { + opacity: 0; + transform: translateY(-50%) scale(0.5); } +} - .sourceShell, - .stageShell { - max-width: none; +@keyframes sheen { + 0%, + 8% { + transform: translateX(0); + opacity: 0; } - .sourceShell { - min-height: 112px; + 16% { + opacity: 1; } - .sourceInner { - min-height: 110px; - padding: 14px 13px; + 62% { + transform: translateX(460%); + opacity: 0; } - .sourceSeal { - width: 42px; - height: 42px; + 63%, + 100% { + transform: translateX(460%); + opacity: 0; } +} - .stageShell { - min-height: 100px; +/* ---------- Stacked below the breakpoint ---------- */ + +@media (max-width: 899px) { + .track { + flex-direction: column; + align-items: center; } - .stageInner { - grid-template-columns: 1fr; - justify-items: center; + .source { + flex-direction: column; gap: 10px; - min-height: 98px; - padding: 14px 10px; + padding-top: 0; + } + + .sourceLabel { text-align: center; } - .stageLabel { - font-size: var(--fs-body-sm); + .link { + width: 2px; + min-width: 0; + height: 32px; + margin-top: 0; } - .coreWindow { - width: 48px; - height: 48px; + .linkMerge { + height: 46px; + background: linear-gradient(180deg, rgba(45, 212, 160, 0.7) 0%, rgba(150, 182, 240, 0.2) 100%); } - .coreLine { - right: -26px; - left: -26px; + .linkExit { + height: 36px; } - .verifiedRoute .scanner { - min-width: 0; + .exitCheck { + top: auto; + right: 50%; + bottom: -12px; + transform: translateX(50%) scale(0.5); + } + + .node { + width: auto; + min-width: 128px; + } + + @media (prefers-reduced-motion: no-preference) { + .source { + animation-name: dockVertical; + } + + .link::after { + animation-name: flowVertical; + } + + .exitCheck { + animation-name: exitPopVertical; + } } } -@keyframes verifiedSignal { - 0%, - 8% { - left: 8%; +@keyframes dockVertical { + 0% { opacity: 0; + transform: translateY(-42px); } + 13% { opacity: 1; + transform: translateY(0); } - 78% { - left: 89%; + + 93% { opacity: 1; + transform: translateY(0); } - 86%, + + 99%, 100% { - left: 94%; opacity: 0; + transform: translateY(-42px); } } -@media (prefers-reduced-motion: reduce) { - .verifiedPulse, - .scannerBeam, - .releaseCheck { - animation: none !important; +@keyframes flowVertical { + 0% { + opacity: 0; + transform: translateY(-100%); + } + + 2% { + opacity: 1; } - .verifiedPulse { - left: 89%; + 10% { opacity: 1; + transform: translateY(100%); + } + + 11%, + 100% { + opacity: 0; + transform: translateY(100%); } +} - .scannerBeam { - transform: translateY(28px); - opacity: 0.74; +@keyframes exitPopVertical { + 0%, + 47% { + opacity: 0; + transform: translateX(50%) scale(0.5); } - .releaseCheck { - transform: none; + 53% { opacity: 1; + transform: translateX(50%) scale(1.28); + } + + 58%, + 92% { + opacity: 1; + transform: translateX(50%) scale(1); + } + + 98%, + 100% { + opacity: 0; + transform: translateX(50%) scale(0.5); } } diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx index 9bee539ca..98c1018f8 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx @@ -1,104 +1,144 @@ import type React from 'react'; import styles from './SaasVerifiedCore.module.css'; -type DeliveryStageId = 'code' | 'build' | 'test' | 'deploy'; - -interface DeliveryStage { - readonly id: DeliveryStageId; - readonly label: 'Code' | 'Build' | 'Test' | 'Deploy'; -} - -interface SecurityScannerProps { - readonly vertical?: boolean; +/* + * "Move Beyond Shift Left" diagram. + * + * A standard delivery pipeline — Code, Build, Test, Deploy, Security Review — + * with Verified Components arriving from the LEFT as a separate piece and + * docking into it. Everything from the junction rightwards is one pipeline and + * is styled identically, so the eye reads a single track rather than six + * competing objects. Green belongs to the incoming components, to the junction + * they lock into, and to the assurance that then travels the track. Nothing else + * is green. + * + * ONE container, not three. The version this replaces nested an outer surface, a + * route stack and a per-stage housing, each with its own border and background, + * which is what made it read as boxes inside boxes. There is a single deck now, + * and the stages sit directly on it. + * + * Rendered ONCE, not twice. The previous component emitted a full desktop + * diagram and a full mobile diagram side by side, so every icon existed twice in + * the DOM. Orientation is a CSS concern, so the single tree flips from a row to + * a column at the breakpoint instead. + * + * The whole diagram is aria-hidden: SaasShiftLeft renders the same sequence as + * an ordered list, which is a better reading of a pipeline than a row of + * decorative tiles. + */ + +type StageId = 'code' | 'build' | 'test' | 'deploy' | 'review'; + +interface Stage { + readonly id: StageId; + readonly label: string; } -const DELIVERY_STAGES: readonly DeliveryStage[] = [ +const STAGES: readonly Stage[] = [ { id: 'code', label: 'Code' }, { id: 'build', label: 'Build' }, { id: 'test', label: 'Test' }, { id: 'deploy', label: 'Deploy' }, + { id: 'review', label: 'Security Review' }, ]; +/* + * One shared cycle drives every part of the sequence, and each part carries its + * own delay rather than its own duration. Same period everywhere is what keeps + * the arrival, the lock, the travelling pulse and the release from drifting out + * of phase over time — with mixed durations they resynchronise only at the + * lowest common multiple, which looks like a fault. + * + * Order: the component arrives, locks at the junction, its assurance travels + * stage to stage, and the release is approved. Then it holds and repeats. + */ +const DOCK_MS = 900; +const STEP_MS = 420; +const TRAVEL_MS = 700; + +const linkDelay = (i: number): string => `${DOCK_MS + i * STEP_MS}ms`; +const stageLitDelay = (i: number): string => `${DOCK_MS + i * STEP_MS + TRAVEL_MS - 120}ms`; + export function SaasVerifiedCore(): React.ReactElement { return (
      - - -
      - ); -} - -function DesktopVerifiedCore(): React.ReactElement { - return ( -
    diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css index 301b2c94e..426a330b5 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css @@ -3,32 +3,39 @@ * * Geometry is anchored to one number: RAIL_Y, the distance from the top of a * node column down to the centre of its tile. Every element that has to sit on - * the rail — the base line, the fill, the junction, the end cap, the bottom of - * the merge curve — is positioned from it, so nothing drifts when tile or label - * sizes change. + * the rail — the base line, the fill, the junction, the bottom of the merge + * curve — is positioned from it, so nothing drifts when tile or label sizes + * change. * * Colour is rationed because it carries the argument. GREEN is the incoming - * verified components and the assurance they bring to the rail. Every pipeline - * stage, Security Review included, shares ONE neutral treatment: giving Security - * Review its own colour made it read as a separate system bolted on the end - * rather than the last stage of the same pipeline. + * verified components, the assurance they bring to the rail, and the release + * that inherits it. Every delivery stage, Security Review included, shares ONE + * neutral treatment: giving Security Review its own colour made it read as a + * separate system bolted on the end rather than the last stage of the same + * pipeline. */ .stage { - --cycle: 6.5s; + --cycle: 7.4s; --rail-y: 30px; - /* How far the source sits above the rail. It is a negative margin, so it - borrows from the deck's top padding — the padding has to add it back or the - tile crowds the top edge while the bottom keeps its full gap. */ - --raise: 52px; + /* How far the hub sits above the rail. It is a negative margin, so it borrows + from the deck's top padding — the padding has to add it back or the tile + crowds the top edge while the bottom keeps its full gap. */ + --raise: 60px; + --tile: 60px; + /* The hub carries an orbit outside its hexagon, so it needs a larger box than + a pipeline tile. Every measurement that meets the hub reads THIS, not + --tile. */ + --hub-tile: 76px; + --node-w: 104px; + /* The stacked node column's height: tile + gap + label + underline. Only the + vertical rail reads it. */ + --node-h: 96px; /* The fill's window within the cycle. Shared by the fill itself and by every halo, so a stage lights exactly as the front crosses it. */ - --fill-start: 0.2; - --fill-span: 0.54; - --tile: 60px; - --node-w: 116px; + --fill-start: 0.24; + --fill-span: 0.48; --green: #34e3a4; - --green-soft: rgba(52, 227, 164, 0.16); width: 100%; } @@ -38,14 +45,13 @@ .deck { position: relative; overflow: hidden; - /* Sized to the diagram, not to the column. Content measures ~872px across - (source 128 + merge 104 + rail 640), so the deck sits just wide enough to - frame it with even air on both sides. At 1360 it was a mostly-empty band. */ + /* Sized to the diagram, not to the column. Content measures ~854px across + (hub 112 + merge 88 + rail 654), so the deck sits just wide enough to frame + it with even air on both sides. At 1360 it was a mostly-empty band. */ max-width: 968px; margin: 0 auto; - /* Top padding carries the raise so the air above the source matches the air - below the labels. Without the + var(--raise) the deck rendered top-heavy: - 10px above the tile against 46px under the labels. */ + /* Top padding carries the raise so the air above the hub matches the air + below the labels. Without it the deck renders top-heavy. */ padding: calc(clamp(28px, 2.8vw, 38px) + var(--raise)) clamp(22px, 2.6vw, 40px) clamp(28px, 2.8vw, 38px); border-radius: clamp(20px, 2vw, 26px); @@ -61,7 +67,7 @@ .deckGlow { position: absolute; top: -30%; - left: -6%; + left: -8%; width: 46%; height: 160%; pointer-events: none; @@ -78,48 +84,95 @@ justify-content: center; } -/* ---------- Verified Components ---------- */ +/* ---------- Verified Components hub ---------- */ -.source { +.hub { display: flex; flex: 0 0 auto; flex-direction: column; align-items: center; gap: 12px; - width: 128px; + width: 112px; /* Raised clear of the rail so it reads as a separate thing joining, not as the first stage in the row. */ margin-top: calc(-1 * var(--raise)); } -.sourceTile { +.hubTile { position: relative; display: grid; place-items: center; - width: var(--tile); - height: var(--tile); - border-radius: 999px; - color: #04150e; - background: linear-gradient(180deg, #57ecb5 0%, #1cc389 100%); - box-shadow: - 0 0 0 7px rgba(52, 227, 164, 0.09), - 0 14px 30px -12px rgba(28, 195, 137, 0.85); + width: var(--hub-tile); + height: var(--hub-tile); + /* closest-side on a square box gives a circle, so no radius is needed. */ + background: radial-gradient(closest-side, rgba(52, 227, 164, 0.2) 0%, transparent 100%); } -.sourceTile svg { - width: 26px; - height: 26px; +.hubMark { + width: 100%; + height: 100%; + overflow: visible; +} + +/* Turns continuously and independently of the pipeline's cycle: verification is + an ongoing condition, not a step that completes. */ +.hubOrbit { + transform-box: view-box; + transform-origin: 50% 50%; } -.sourceRing { +.hubOrbitRing { + fill: none; + stroke: rgba(87, 236, 181, 0.34); + stroke-width: 1.2; + stroke-dasharray: 3 7; + stroke-linecap: round; +} + +.hubSat { + fill: rgba(120, 240, 197, 0.8); +} + +.hubPlate { + filter: drop-shadow(0 10px 20px rgba(22, 180, 126, 0.5)); +} + +.hubGlyph { + fill: none; + stroke: #04382a; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.hubBadge { + transform-box: fill-box; + transform-origin: center; +} + +.hubBadgeDisc { + fill: #0a1440; + stroke: rgba(87, 236, 181, 0.9); + stroke-width: 1.5; +} + +.hubBadgeTick { + fill: none; + stroke: #57ecb5; + stroke-width: 2.3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.hubRing { position: absolute; - inset: -7px; + inset: 4px; border-radius: 999px; border: 1.5px solid rgba(87, 236, 181, 0.9); opacity: 0; } -.sourceLabel { +.hubLabel { font-family: var(--font-sans); font-size: 13.5px; font-weight: 600; @@ -138,8 +191,8 @@ /* Overlaps the rail's start by a few pixels. Butted exactly, a 2px seam sat between the curve's cap and the rail and read as a broken join. */ margin-right: -4px; - /* Its top meets the source tile's centre, its bottom meets the rail. */ - margin-top: calc(-1 * var(--raise) + var(--tile) / 2); + /* Its top meets the hub tile's centre, its bottom meets the rail. */ + margin-top: calc(-1 * var(--raise) + var(--hub-tile) / 2); overflow: visible; } @@ -156,7 +209,7 @@ stroke-width: 2; stroke-linecap: round; stroke-dasharray: 130; - stroke-dashoffset: 130; + stroke-dashoffset: 0; filter: drop-shadow(0 0 5px rgba(52, 227, 164, 0.6)); } @@ -166,25 +219,21 @@ position: relative; display: flex; align-items: flex-start; - justify-content: space-between; + justify-content: flex-start; /* Fixed pitch, not space-between: the halos need to know where each stage - sits, and under space-between that depends on the container's width. Width - is 5 nodes + 4 gaps + a tail for the release cap. - - box-sizing is declared explicitly and is load-bearing. Without it this - resolved as content-box, so the declared 638px became the CONTENT width and - the 34px tail was added on top — the rail measured 670px while the halo - fractions were computed against 638px. Every stage then lit after the front - had already passed it. */ + sits, and under space-between that depends on the container's width. + + box-sizing is declared explicitly and is load-bearing. Read as content-box + any horizontal padding would be added on top of the declared width, so the + rail would measure wider than the fractions were computed against and every + stage would light after the front had passed it. */ box-sizing: border-box; - width: calc(5 * var(--node-w) + 4 * 6px + 34px); - justify-content: flex-start; + width: calc(6 * var(--node-w) + 5 * 6px); gap: 6px; - padding-right: 34px; /* NOT flex: 1. Growing absorbed the row's free space and stretched the rail - from its declared 638px to 670px, while the halo fractions stayed computed - against 638 — so every stage lit after the front had passed it. The rail - must keep exactly the width its pitch implies. */ + past its declared width, while the halo fractions stayed computed against + the declared one — so every stage lit late. The rail must keep exactly the + width its pitch implies. */ flex: 0 0 auto; } @@ -196,14 +245,18 @@ border-radius: 2px; } -/* The unfilled track has to be clearly visible on its own. At 0.18 it was - invisible against the deck, so wherever the green had not yet reached there - was nothing at all — and the fill looked like it ended in mid-air rather than - running along a rail that was waiting for it. That is what read as a broken - link, not the geometry. */ +/* Stops on the last node's centre, not on the row's right edge — otherwise half + a node of rail hangs past Trusted Release with nothing at the end of it. The + fractions in the component are measured against this same shortened span, so + the fill completes exactly as it reaches the release. + + The unfilled track also has to be clearly visible on its own. Too faint, and + wherever the green had not yet reached there was nothing at all: the fill + looked like it ended in mid-air rather than running along a rail that was + waiting for it. That is what read as a broken link, not the geometry. */ .railBase { left: 0; - right: 0; + right: calc(var(--node-w) / 2); background: rgba(163, 194, 246, 0.34); } @@ -211,8 +264,8 @@ the pipeline BECOMES verified; a fill says that, a moving pulse does not. */ .railFill { left: 0; - right: 0; - transform: scaleX(0); + right: calc(var(--node-w) / 2); + transform: scaleX(1); transform-origin: left center; background: linear-gradient(90deg, var(--green) 0%, rgba(52, 227, 164, 0.55) 100%); box-shadow: 0 0 10px rgba(52, 227, 164, 0.45); @@ -226,7 +279,8 @@ height: 10px; border-radius: 2px; background: var(--green); - transform: rotate(45deg) scale(0); + transform: rotate(45deg) scale(1); + box-shadow: 0 0 0 4px rgba(52, 227, 164, 0.14); } /* ---------- Stages ---------- */ @@ -238,7 +292,7 @@ flex: 0 0 auto; flex-direction: column; align-items: center; - gap: 12px; + gap: 11px; /* Wide enough that "Security Review" stays on one line; a single wrapping label left the whole bottom edge ragged. */ width: var(--node-w); @@ -259,6 +313,22 @@ 0 14px 26px -16px rgba(2, 6, 26, 0.95); } +/* The destination, not another stage: it is what the verified components + produce, so it carries their colour even at rest. */ +.nodeRelease .tile { + color: #04150e; + border-color: rgba(87, 236, 181, 0.5); + background: linear-gradient(180deg, #57ecb5 0%, #1cc389 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.28), + 0 14px 30px -14px rgba(28, 195, 137, 0.8); +} + +.nodeRelease .label { + font-weight: 600; + color: #8ef0c9; +} + /* Lights as the fill reaches the stage: the assurance arriving, not a status the stage held on its own. */ .tileHalo { @@ -266,14 +336,14 @@ inset: -1px; border-radius: inherit; border: 1px solid var(--green); - opacity: 0; + opacity: 0.5; box-shadow: 0 0 16px rgba(52, 227, 164, 0.32); } .icon { position: relative; - width: 27px; - height: 27px; + width: 26px; + height: 26px; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; @@ -282,7 +352,7 @@ .label { font-family: var(--font-sans); - font-size: 13px; + font-size: 12.5px; font-weight: 500; line-height: 1.3; letter-spacing: -0.01em; @@ -291,73 +361,93 @@ color: rgba(223, 232, 255, 0.76); } -.exit { - position: absolute; - right: 0; - top: calc(var(--rail-y) - 13px); - display: grid; - place-items: center; - width: 26px; - height: 26px; - border-radius: 999px; - color: #04150e; - background: linear-gradient(180deg, #57ecb5 0%, #1cc389 100%); - box-shadow: 0 0 0 5px rgba(52, 227, 164, 0.12); - opacity: 0; - transform: scale(0.4); -} +/* A second, slower marker under each label. Its hold outlasts the halo by a + wide margin, so instead of one tile blinking at a time there is a band of + four or five stages reading as covered at once, trailing the front. -.exit svg { - width: 14px; - height: 14px; + It cannot hold to the end of the pass. Every stage's phase is an offset into + the same cycle, so a hold long enough to keep the FIRST stage lit until the + fill completes would keep the LAST stage lit into the next pass, over an + empty rail. The band is the honest version of the idea; the release node, + green at rest, is what carries "verified" permanently. */ +.underline { + width: 26px; + height: 2px; + margin-top: -4px; + border-radius: 2px; + background: var(--green); + transform: scaleX(1); } /* ---------- Motion ---------- */ @media (prefers-reduced-motion: no-preference) { - .sourceRing { - animation: ring var(--cycle) ease-out infinite; + /* backwards, on every one of these, is load-bearing. The rest state above is + the SETTLED state so that reduced motion renders a finished diagram; during + an animation's delay the element must instead hold its 0% keyframe, or it + would show finished and then snap back to empty when the delay elapsed. */ + .tileHalo, + .underline { + animation-fill-mode: backwards; + } + + .hubOrbit { + animation: orbit 22s linear infinite; + } + + .hubRing { + animation: ring var(--cycle) ease-out backwards infinite; + } + + .hubBadge { + animation: seal var(--cycle) cubic-bezier(0.22, 1, 0.36, 1) backwards infinite; } .mergeFlow { - animation: draw var(--cycle) ease-in-out infinite; + animation: draw var(--cycle) ease-in-out backwards infinite; } .junction { - animation: lock var(--cycle) cubic-bezier(0.22, 1, 0.36, 1) infinite; + animation: lock var(--cycle) cubic-bezier(0.22, 1, 0.36, 1) backwards infinite; } .railFill { /* Linear, so the front travels at constant speed. Eased, it drifted out of step with the halos, which fire on position. */ - animation: fill var(--cycle) linear infinite; + animation: fill var(--cycle) linear backwards infinite; } - .tileHalo { + .tileHalo, + .underline { /* Delay, not a keyframe stop: a custom property cannot be interpolated into a percentage inside @keyframes, so each stage carries its own phase. Derived from the stage's position on the rail and the fill's own window, which is what keeps the two moving together. */ - animation: halo var(--cycle) ease-out infinite; + animation-duration: var(--cycle); + animation-iteration-count: infinite; animation-delay: calc(var(--cycle) * (var(--fill-start) + var(--frac, 0.5) * var(--fill-span))); } - .exit { - animation: pop var(--cycle) cubic-bezier(0.22, 1, 0.36, 1) infinite; - /* Sits at the rail's end, so it resolves as the front arrives there. */ - animation-delay: calc(var(--cycle) * (var(--fill-start) + var(--fill-span))); + .tileHalo { + animation-name: halo; + animation-timing-function: ease-out; + } + + .underline { + animation-name: cover; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); } } -/* Completes exactly at --fill-start. It finished at 16% while the fill began at - 20%, so for ~260ms a fully drawn green curve dead-ended into an unfilled grey - rail, which is what read as a broken link. */ +/* Completes exactly at --fill-start. Finishing early left a fully drawn green + curve dead-ending into an unfilled grey rail, which read as a broken link. */ @keyframes draw { - 0% { + 0%, + 16% { stroke-dashoffset: 130; } - 20%, + 24%, 90% { stroke-dashoffset: 0; } @@ -368,19 +458,46 @@ } } +@keyframes orbit { + to { + transform: rotate(360deg); + } +} + +/* Lands with the ring, so the attestation reads as the moment the hub resolves + rather than as a decoration that was always there. */ +@keyframes seal { + 0%, + 14% { + opacity: 0; + transform: scale(0.3); + } + + 21% { + opacity: 1; + transform: scale(1.22); + } + + 27%, + 100% { + opacity: 1; + transform: scale(1); + } +} + @keyframes ring { 0%, - 12% { + 16% { opacity: 0; transform: scale(0.75); } - 18% { + 22% { opacity: 0.95; transform: scale(1); } - 32%, + 36%, 100% { opacity: 0; transform: scale(1.5); @@ -391,17 +508,17 @@ leave, so the junction marks the joint at the moment it matters. */ @keyframes lock { 0%, - 18% { + 22% { transform: rotate(45deg) scale(0); box-shadow: 0 0 0 0 rgba(52, 227, 164, 0); } - 24% { + 28% { transform: rotate(45deg) scale(1.5); box-shadow: 0 0 0 7px rgba(52, 227, 164, 0.2); } - 30%, + 34%, 90% { transform: rotate(45deg) scale(1); box-shadow: 0 0 0 4px rgba(52, 227, 164, 0.14); @@ -414,14 +531,16 @@ } } +/* 24% to 72% — --fill-start to --fill-start + --fill-span. Change one and the + other must follow, or every halo desynchronises from the front. */ @keyframes fill { 0%, - 20% { + 24% { transform: scaleX(0); opacity: 1; } - 74%, + 72%, 90% { transform: scaleX(1); opacity: 1; @@ -434,63 +553,54 @@ } } -/* One keyframe set for all five stages; --halo-delay puts each at its own point +/* One keyframe set for all six stages; the delay puts each at its own point along the fill. Bright on arrival, then held down so the whole rail reads as - verified rather than five things blinking. */ -/* Peaks on arrival. At 3% the flare landed 195ms behind the front, which reads - as the two being out of step. */ + verified rather than six things blinking. */ +/* The peak sits at 1.5% of the cycle, ~110ms after the front arrives. It is + expressed as a percentage of a 7.4s cycle, so lengthening the cycle without + revisiting this number pushes the flare visibly behind the fill. */ @keyframes halo { 0% { opacity: 0; } - 2% { + 1.5% { opacity: 1; } - 8% { + 8%, + 42% { opacity: 0.5; } - 46% { - opacity: 0.5; - } - - 56%, + 52%, 100% { opacity: 0; } } -/* Phase now lives in animation-delay, so this runs from the moment the front - reaches the rail's end. */ -@keyframes pop { +@keyframes cover { 0% { - opacity: 0; - transform: scale(0.4); + background-color: rgba(163, 194, 246, 0.26); + transform: scaleX(0.5); } - 6% { - opacity: 1; - transform: scale(1.25); - } - - 11%, - 22% { - opacity: 1; - transform: scale(1); + 3%, + 48% { + background-color: var(--green); + transform: scaleX(1); } - 30%, + 58%, 100% { - opacity: 0; - transform: scale(0.4); + background-color: rgba(163, 194, 246, 0.26); + transform: scaleX(0.5); } } /* ---------- Stacked below the breakpoint ---------- */ -@media (max-width: 899px) { +@media (max-width: 959px) { .stage { /* Stacked, nothing is raised, so the deck should not pad for a raise. */ --raise: 0px; @@ -501,7 +611,7 @@ align-items: center; } - .source { + .hub { width: auto; } @@ -517,10 +627,8 @@ flex-direction: column; align-items: center; justify-content: flex-start; - gap: 26px; - max-width: none; - padding-right: 0; - padding-bottom: 40px; + gap: 24px; + width: auto; } .railBase, @@ -528,14 +636,15 @@ left: 50%; right: auto; top: 0; - bottom: 0; + /* Same reasoning as the horizontal rail: it ends on the last node's centre. */ + bottom: calc(var(--node-h) / 2); width: 2px; height: auto; transform: translateX(-50%); } .railFill { - transform: translateX(-50%) scaleY(0); + transform: translateX(-50%) scaleY(1); transform-origin: top center; background: linear-gradient(180deg, var(--green) 0%, rgba(52, 227, 164, 0.55) 100%); } @@ -543,19 +652,12 @@ .junction { left: 50%; top: -5px; - transform: translateX(-50%) rotate(45deg) scale(0); - } - - .exit { - right: auto; - left: 50%; - top: auto; - bottom: 0; - transform: translateX(-50%) scale(0.4); + transform: translateX(-50%) rotate(45deg) scale(1); } @media (prefers-reduced-motion: no-preference) { - .tileHalo { + .tileHalo, + .underline { /* Stacked, the stages sit at different fractions of a taller rail. */ animation-delay: calc( var(--cycle) * (var(--fill-start) + var(--frac-v, 0.5) * var(--fill-span)) @@ -569,21 +671,17 @@ .junction { animation-name: lockVertical; } - - .exit { - animation-name: popVertical; - } } } @keyframes fillVertical { 0%, - 20% { + 24% { transform: translateX(-50%) scaleY(0); opacity: 1; } - 74%, + 72%, 90% { transform: translateX(-50%) scaleY(1); opacity: 1; @@ -598,15 +696,15 @@ @keyframes lockVertical { 0%, - 18% { + 22% { transform: translateX(-50%) rotate(45deg) scale(0); } - 24% { + 28% { transform: translateX(-50%) rotate(45deg) scale(1.5); } - 30%, + 34%, 90% { transform: translateX(-50%) rotate(45deg) scale(1); } @@ -616,27 +714,3 @@ transform: translateX(-50%) rotate(45deg) scale(0); } } - -@keyframes popVertical { - 0% { - opacity: 0; - transform: translateX(-50%) scale(0.4); - } - - 6% { - opacity: 1; - transform: translateX(-50%) scale(1.25); - } - - 11%, - 22% { - opacity: 1; - transform: translateX(-50%) scale(1); - } - - 30%, - 100% { - opacity: 0; - transform: translateX(-50%) scale(0.4); - } -} diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx index 48560034d..6e4551b15 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx @@ -5,34 +5,25 @@ import styles from './SaasVerifiedCore.module.css'; * "Move Beyond Shift Left" diagram. * * The claim: security is not a gate at the end of delivery, it arrives with the - * components you start from. So Verified Components sits ABOVE and LEFT of the - * pipeline and joins it through a curve, ahead of Code — it is visibly a - * separate thing merging in, not simply the first box in a row. Everything from - * that junction onward is one pipeline in one neutral treatment, and the - * assurance it brings then fills the whole rail. + * components you start from. So Verified Components sits above and left of the + * pipeline and joins it through a curve ahead of Code — visibly a separate + * thing merging in, not the first box in a row. From that junction on it is one + * pipeline in one neutral treatment: Code, Build, Test, Deploy, Security + * Review, and the trusted release they produce. Green is rationed to what the + * verified components bring, and to the release that inherits it. * - * What went wrong in the version before this, all of it visible only once - * rendered at full width: + * The sequence: the hub locks, the merge curve draws down into the rail, the + * rail fills, each stage lights and stays marked as the front crosses it, and + * the release resolves. Then it holds and repeats. * - * - The container was 1360x160 of mostly empty space with a thin strip of - * content adrift in it. The deck is now capped and hugs its contents. - * - Two node grammars in one row: the source was a circle with its label - * beside it, the stages were tiles with labels beneath. Nothing shared a - * baseline. Every node now has identical anatomy — tile, then label. - * - `flex: 1` connectors stretched across the full width, so the pipeline read - * as scattered dots rather than a track. One continuous rail runs behind the - * stages now, and it cannot be pulled apart by the container's width. - * - "Security Review" wrapped to two lines while every other label was one, - * leaving a ragged bottom edge. Node width now fits the longest label. - * - * Rendered once. Orientation is a CSS concern: the row becomes a column at the - * breakpoint rather than a second copy of the markup being emitted. - * - * The diagram is aria-hidden. SaasShiftLeft renders the same sequence as an - * ordered list, which is a better reading of a pipeline than a row of tiles. + * GEOMETRY IS LOAD-BEARING. A stage lights on POSITION, so the rail runs on a + * fixed pitch and these constants are the same ones the stylesheet uses. Two + * bugs came from breaking that: `flex: 1` let the rail grow past its declared + * width, and `space-between` made stage positions depend on the container. + * Both desynchronised the halos from the fill. */ -type StageId = 'code' | 'build' | 'test' | 'deploy' | 'review'; +type StageId = 'code' | 'build' | 'test' | 'deploy' | 'review' | 'release'; interface Stage { readonly id: StageId; @@ -45,52 +36,38 @@ const STAGES: readonly Stage[] = [ { id: 'test', label: 'Test' }, { id: 'deploy', label: 'Deploy' }, { id: 'review', label: 'Security Review' }, + { id: 'release', label: 'Trusted Release' }, ]; +/* Must match .railWrap in the stylesheet. */ +const NODE_W = 104; +const NODE_GAP = 6; + /* - * One clock. Every part of the sequence shares --cycle and differs only by - * delay: mixed durations resynchronise at their lowest common multiple, which - * reads as a fault rather than a loop. - * - * The rail FILLS rather than passing a dot along it. The claim is that the - * pipeline becomes verified, not that something travels through it, and a fill - * says that where a moving pulse does not. - */ -/* - * A stage lights the instant the fill front crosses it, so its phase is its - * POSITION along the rail, not its index. Two things had to be true for that to - * hold, and neither was: - * - * - The fill has to advance at constant speed. It was eased, so the front - * accelerated and decelerated while the halos fired at evenly spaced times, - * and the two drifted apart mid-sweep. The fill is linear now. - * - The stage positions have to be knowable. Under `space-between` they - * depend on the container's width, so no fixed fraction could be right. The - * rail is now laid out on a fixed pitch, and these constants are the same - * ones the stylesheet uses. + * The line does NOT span the whole node row. It starts at the row's left edge, + * so there is a lead-in from the merge junction to Code, and it stops dead on + * the last node's CENTRE. Run to the row's right edge instead and ~22px of rail + * hangs off the end of Trusted Release with nothing to arrive at. * - * Fractions are published as CSS custom properties rather than finished - * delays, so the stacked layout can supply its own without this file needing to - * know which breakpoint is active. + * Both halves of that decision live here: the stylesheet insets the line by + * half a node on the right, and the fractions below are measured against the + * same shortened span. So stage 5 sits at exactly 1.0 and the fill completes on + * the release rather than past it. */ -const NODE_W = 116; -const NODE_GAP = 6; -const RAIL_TAIL = 34; - -const railWidth = (n: number): number => n * NODE_W + (n - 1) * NODE_GAP + RAIL_TAIL; +const railSpan = (nodeW: number, gap: number): number => + STAGES.length * nodeW + (STAGES.length - 1) * gap - nodeW / 2; /** Centre of stage i along the horizontal rail, as a 0..1 fraction. */ -const fracH = (i: number, n: number): number => - (i * (NODE_W + NODE_GAP) + NODE_W / 2) / railWidth(n); +const fracH = (i: number): number => + (i * (NODE_W + NODE_GAP) + NODE_W / 2) / railSpan(NODE_W, NODE_GAP); -/* Stacked: tile + gap + one line of label, on a 26px gap, with the same tail. */ -const NODE_H = 89; -const NODE_GAP_V = 26; -const railHeight = (n: number): number => n * NODE_H + (n - 1) * NODE_GAP_V + 40; +/* Stacked: tile 60 + gap 11 + label 16.25 + underline 9, measured. */ +const NODE_H = 96; +const NODE_GAP_V = 24; /** Centre of stage i along the vertical rail, as a 0..1 fraction. */ -const fracV = (i: number, n: number): number => - (i * (NODE_H + NODE_GAP_V) + NODE_H / 2) / railHeight(n); +const fracV = (i: number): number => + (i * (NODE_H + NODE_GAP_V) + NODE_H / 2) / railSpan(NODE_H, NODE_GAP_V); export function SaasVerifiedCore(): React.ReactElement { return ( @@ -99,25 +76,24 @@ export function SaasVerifiedCore(): React.ReactElement {
    - {/* The piece that merges in: raised above the rail, joined by a curve. */} -
    - - - +
    + + + - Verified Components + Verified Components
    @@ -128,28 +104,27 @@ export function SaasVerifiedCore(): React.ReactElement { {STAGES.map((stage, i) => (
    {stage.label} +
    ))} - - - -
    @@ -200,19 +175,68 @@ function StageIcon({ stage }: { readonly stage: StageId }): React.ReactElement { ); + case 'release': + return ( + + + + + + ); } } -function CheckIcon(): React.ReactElement { +/* + * The seal on the incoming components. + * + * A hexagon, because in developer tooling that shape already means package or + * module — and because every pipeline stage is a rounded square, so the thing + * arriving from outside should not share their silhouette. Inside it, stacked + * plates: a component is layers, not a single object. Around it, a dashed orbit + * that never stops turning, since verification is continuous rather than a + * state something was left in. The badge is the attestation, and it is a badge + * ON the component rather than the whole mark, which is the actual claim. + */ +function HubMark(): React.ReactElement { return ( - ); } From ced9d3426f7949ab2fe56a776958ce44c022a491 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 21:15:51 +0530 Subject: [PATCH 48/85] fix(web): remove lavender outline stroke, keep cards' original glow art Removes the #dab6f3 lavender border from the outer enclosure and the Intelligence Center bar (the actual glowing outline being reported). Reverts the desktop cards to their original factory-card-bg.webp art with baked-in glow, which was never the issue and is restored as-is. --- .../sections/home/PlatformPipeline.tsx | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/sections/home/PlatformPipeline.tsx b/apps/web/src/components/sections/home/PlatformPipeline.tsx index 62c804868..d0bd4c6bd 100644 --- a/apps/web/src/components/sections/home/PlatformPipeline.tsx +++ b/apps/web/src/components/sections/home/PlatformPipeline.tsx @@ -66,17 +66,17 @@ function FactoryCard({ data, isFirst }: { data: CardData; isFirst: boolean }) { className="factory-card relative flex h-full min-w-0 flex-col" style={{ containerType: "inline-size" }} > - {/* Blue glass card body — flat CSS gradient (no baked glow), matching - the mobile card's tones so the surface is consistent across - breakpoints. */} -
    {/* Content layer — drives the card height (no fixed aspect ratio), so the @@ -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)", }} > From f46ef926f12210ab5738a2f52c7426ca439d56bf Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 1 Sep 2026 21:24:58 +0530 Subject: [PATCH 49/85] fix(web): land the merge curve on the rail instead of beside it The join looked broken because it was. Measured, the curve ended at (2, 28) against a rail whose centre is (0, 30): with a 2px stroke the two only grazed along a single row of pixels. Its other end was 2px off the hub's centre and 20px clear of the mark, so it began as a floating stub. The path had been inset 2px inside its own box on both ends, and the box was then nudged back with a -4px margin to hide the resulting seam. Now the box IS the join: its corners sit on the two points being connected, and the path runs corner to corner, so there is no arithmetic left over to get wrong. --raise is derived from that constraint rather than picked by eye. stroke-dasharray was 130 against a path measuring 109.5, so the dash pattern ran out before the offset did and the curve finished drawing at 22.7% of the cycle while the rail did not start filling until 24% -- a fully drawn green curve dead-ended into a still-grey rail for ~96ms, every pass. Set to the measured length, it now completes at exactly 24%. Verified in the browser: curve start (-92, -22) == orbit edge and hub centre; curve end (0, 30) == junction centre == rail start. Stacked, the curve, hub, junction and rail all centre on 52 and the curve overlaps the junction rather than gapping. Deck air 41px on both sides. lint, typecheck, 414 tests, build all pass. --- .../sections/saas/SaasVerifiedCore.module.css | 56 +++++++++++++------ .../sections/saas/SaasVerifiedCore.tsx | 9 ++- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css index 426a330b5..394a53d92 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css @@ -21,12 +21,21 @@ /* How far the hub sits above the rail. It is a negative margin, so it borrows from the deck's top padding — the padding has to add it back or the tile crowds the top edge while the bottom keeps its full gap. */ - --raise: 60px; + /* DERIVED, never picked. The merge box spans from the hub tile's centre down + to the rail, so the raise is whatever puts those two --merge-h apart. + Choosing it by eye is what left the curve ending 2px off the rail. */ + --raise: calc(var(--hub-tile) / 2 - var(--rail-y) + var(--merge-h)); + --merge-h: 52px; + --merge-w: 92px; + --hub-w: 112px; --tile: 60px; /* The hub carries an orbit outside its hexagon, so it needs a larger box than a pipeline tile. Every measurement that meets the hub reads THIS, not --tile. */ --hub-tile: 76px; + /* How far the orbit ring stops short of the hub tile's edge: the ring is + r=32.5 in a 76 box, so 38 - 32.5. */ + --orbit-inset: 5.5px; --node-w: 104px; /* The stacked node column's height: tile + gap + label + underline. Only the vertical rail reads it. */ @@ -92,7 +101,7 @@ flex-direction: column; align-items: center; gap: 12px; - width: 112px; + width: var(--hub-w); /* Raised clear of the rail so it reads as a separate thing joining, not as the first stage in the row. */ margin-top: calc(-1 * var(--raise)); @@ -184,15 +193,23 @@ /* ---------- Merge curve ---------- */ +/* + * The box IS the join. Its bottom-right corner sits exactly on the junction — + * which is also where the rail starts — and its top-left corner sits on the + * hub's orbit at the hub tile's centre height. The path runs corner to corner, + * so there is no arithmetic left over to get wrong. + * + * The left inset pulls the box back over the hub column: that column is wider + * than its tile, and the orbit stops short of the tile's own edge, so without + * it the curve began ~24px clear of the mark and read as a floating stub. + */ .merge { flex: 0 0 auto; - width: 92px; - height: 52px; - /* Overlaps the rail's start by a few pixels. Butted exactly, a 2px seam sat - between the curve's cap and the rail and read as a broken join. */ - margin-right: -4px; - /* Its top meets the hub tile's centre, its bottom meets the rail. */ - margin-top: calc(-1 * var(--raise) + var(--hub-tile) / 2); + width: var(--merge-w); + height: var(--merge-h); + margin-right: 0; + margin-left: calc((var(--hub-tile) - var(--hub-w)) / 2 - var(--orbit-inset)); + margin-top: calc(var(--rail-y) - var(--merge-h)); overflow: visible; } @@ -208,7 +225,12 @@ stroke: var(--green); stroke-width: 2; stroke-linecap: round; - stroke-dasharray: 130; + /* 110 is the path's measured length (109.5), not a round number that clears + it. Set longer, the dash pattern runs out before the offset does and the + curve finishes drawing early — leaving a fully drawn green curve dead-ended + into a still-grey rail, which is the other half of what read as a broken + link. */ + stroke-dasharray: 110; stroke-dashoffset: 0; filter: drop-shadow(0 0 5px rgba(52, 227, 164, 0.6)); } @@ -444,7 +466,7 @@ @keyframes draw { 0%, 16% { - stroke-dashoffset: 130; + stroke-dashoffset: 110; } 24%, @@ -454,7 +476,7 @@ 96%, 100% { - stroke-dashoffset: 130; + stroke-dashoffset: 110; } } @@ -616,10 +638,12 @@ } .merge { - width: 52px; - height: 46px; - /* Stacked, the overlap runs downward instead of to the right. */ - margin: 6px 0 -4px; + --merge-w: 52px; + --merge-h: 46px; + + /* Rotated, so the corner-to-corner path now runs top to bottom. Its ends + land on the hub above and the vertical rail below. */ + margin: 4px 0 0; transform: rotate(90deg); } diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx index 6e4551b15..ab35f9300 100644 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx +++ b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx @@ -92,8 +92,13 @@ export function SaasVerifiedCore(): React.ReactElement { preserveAspectRatio="none" aria-hidden="true" > - - + {/* Corner to corner. The endpoints are the two things this curve + has to join, so the stylesheet positions the BOX and the path + runs its full diagonal — inset the path even 2px and it ends + beside the rail instead of on it, which is exactly how the join + came to look broken. */} + +
    From fa590f91d0781ebcd0fed46b15e1d70cc7e42e31 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 10:06:35 +0530 Subject: [PATCH 50/85] feat(web): 301 the short SaaS industry path to its canonical URL The slug already names the industry, so /saas-container-security is the natural guess and returned a hard 404. Same courtesy 301 as /guides -> /guide above it: never a live URL, just one worth catching. Verified: 308 with location /industries/saas-container-security, following through to a 200 on the real page. lint, typecheck, build all pass. --- apps/web/next.config.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index ae698706b..5d1677980 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -79,6 +79,15 @@ 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: never a live URL, + // just one worth catching. + { + source: "/saas-container-security", + destination: "/industries/saas-container-security", + 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 From 46af5740aff7992fed293dfd86346890befff1fe Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 10:10:18 +0530 Subject: [PATCH 51/85] feat(web): 301 the short finance industry path to its canonical URL Matches the SaaS entry above it. That covers both industry pages, so the comment now asks for a new entry whenever another is added rather than leaving the next one to 404. Verified: 308 to /industries/financial-services-container-security, following through to a 200 titled "Container Security for Financial Services". lint, typecheck, build all pass. --- apps/web/next.config.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 5d1677980..8bdafb187 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -79,15 +79,21 @@ const nextConfig: NextConfig = { destination: "/guide/:slug*", permanent: true, }, - // Industry pages live under `/industries/`, but the slug already names + // Industry pages live under `/industries/`, but each slug already names // the industry, so the shorter path is the natural guess and returns a - // hard 404. Same courtesy 301 as `/guides` above: never a live URL, - // just one worth catching. + // hard 404. Same courtesy 301 as `/guides` above: neither was ever a + // live URL, just ones worth catching. Add an entry here whenever an + // industry page is added. { source: "/saas-container-security", destination: "/industries/saas-container-security", permanent: true, }, + { + source: "/financial-services-container-security", + destination: "/industries/financial-services-container-security", + 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 From c5b2e68d445213935ccb8f9f96f40bb5dcddd966 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 13:54:57 +0530 Subject: [PATCH 52/85] copy(web): rename the fourth stack item to Golden Images Client's wording for the "Modern Financial Applications Are More Than Code" row on the finance page. "Third Party Components" was the fourth of four. Alt text no longer claims the artwork depicts third-party supply, since it never did: the slot still carries the AI-generated-code icon (code brackets with sparkles), which now matches its label even less. The note asking for a replacement is updated to say what shape a golden image wants. --- .../sections/financial-services/FinanceStack.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/sections/financial-services/FinanceStack.tsx b/apps/web/src/components/sections/financial-services/FinanceStack.tsx index 598adb711..2dc0c76ee 100644 --- a/apps/web/src/components/sections/financial-services/FinanceStack.tsx +++ b/apps/web/src/components/sections/financial-services/FinanceStack.tsx @@ -36,13 +36,15 @@ const COMPONENTS: readonly [ComponentItem, ComponentItem, ComponentItem, Compone line2: 'Dependencies', }, { - // The icon is still the AI-generated-code artifact (code brackets with - // sparkles) and reads as AI rather than third-party supply. Regenerate it - // in the icon-stack style before this page goes live. + // The icon is still the AI-generated-code artifact: code brackets with + // sparkles, which read as AI and now match the label even less than they + // did. Regenerate it in the icon-stack style — a golden image wants a + // base-image or stamped-master form, distinct from the container stack in + // the first slot. icon: '/images/financial-services/icon-stack-ai-generated-code-v2.png', - iconAlt: '3D purple code brackets representing third party components', - line1: 'Third Party', - line2: 'Components', + iconAlt: '3D purple code brackets with sparkles', + line1: 'Golden', + line2: 'Images', }, ]; From 4d085946491a31723273789921a9ad91560f21df Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 13:59:08 +0530 Subject: [PATCH 53/85] fix(web): slow down the hero H1 reveal sequence (~2.5s to ~3.5s) --- apps/web/src/app/globals.css | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index df56e40e2..4b52851b4 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1474,9 +1474,9 @@ body { 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. */ + 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; } @@ -1504,8 +1504,8 @@ body { .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; + 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 { @@ -1534,8 +1534,8 @@ body { .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-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. */ } @@ -1578,7 +1578,7 @@ body { background: #e2726c; border-radius: 2px; transform-origin: left center; - animation: cs-hh-strike 0.4s ease-out 1.1s both; + animation: cs-hh-strike 0.55s ease-out 1.6s both; } @keyframes cs-hh-strike { @@ -1592,7 +1592,7 @@ body { } .cs-hh-hardened-grad { - animation: cs-hh-fade-out 0.2s ease-out 1.1s both; + animation: cs-hh-fade-out 0.3s ease-out 1.6s both; } .cs-hh-hardened-gray { @@ -1600,7 +1600,7 @@ body { inset: 0; color: #8892a4; opacity: 0; - animation: cs-hh-fade-in 0.2s ease-out 1.1s both; + animation: cs-hh-fade-in 0.3s ease-out 1.6s both; } @keyframes cs-hh-fade-out { @@ -1638,9 +1638,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 { @@ -1675,7 +1675,7 @@ body { has landed. */ .cs-hh-phrase { display: block; - animation: cs-hh-focus 0.8s cubic-bezier(0.16, 1, 0.3, 1) 1.7s both; + 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. */ } From 48c82a4b5d690366c8d91a73b1377b6fa724d045 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 14:17:29 +0530 Subject: [PATCH 54/85] fix(web): lighten struck 'Hardened' to 500 and thin its strike to 2px --- apps/web/src/app/globals.css | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 4b52851b4..dd1785d15 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1566,6 +1566,12 @@ body { .cs-hh-hardened { 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 { @@ -1574,7 +1580,8 @@ body { left: -2%; right: -2%; top: 50%; - height: 3px; + /* 2px, not 3px: balanced against the lighter 500-weight letterforms. */ + height: 2px; background: #e2726c; border-radius: 2px; transform-origin: left center; From e0941586bd88c0465dd7eaf1c3884c7b4f6bd338 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 14:20:20 +0530 Subject: [PATCH 55/85] copy(web): terminate the hero's 'Verified' line with a period --- apps/web/src/components/sections/home/HeroHeading.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/sections/home/HeroHeading.tsx b/apps/web/src/components/sections/home/HeroHeading.tsx index 138aad912..ec3cc5417 100644 --- a/apps/web/src/components/sections/home/HeroHeading.tsx +++ b/apps/web/src/components/sections/home/HeroHeading.tsx @@ -32,7 +32,7 @@ export function HeroHeading() { aria-label="Verified. Secure. Built for the AI Era." >
    -
      -
    1. Verified Components
    2. -
    3. Code
    4. -
    5. Build
    6. -
    7. Test
    8. -
    9. Deploy
    10. -
    11. Security Review
    12. -
    13. Trusted Release
    14. +
        +
      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..64834c2b4 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasTrustPipeline.module.css @@ -0,0 +1,397 @@ +/* + * 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; + --flight: 2.6s; + 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 */ + +.railBase { + fill: none; + stroke: rgba(255, 255, 255, 0.11); + stroke-width: 3; +} + +/* 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 */ + +.socketFace { + fill: rgba(255, 255, 255, 0.025); +} + +.socketRing { + fill: none; + stroke: rgba(255, 255, 255, 0.3); + stroke-width: 1.5; + stroke-dasharray: 6 7; + animation: march 1.6s linear infinite; + transition: stroke 0.35s ease; +} + +.scene[data-docked] .socketRing { + stroke: var(--green); + stroke-dasharray: 1000 0; + animation: none; +} + +.socketGlow { + opacity: 0.35; + 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 */ + +.hero { + offset-rotate: 0deg; + offset-distance: 0%; + transition: offset-distance var(--flight) cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +.scene[data-flying] .hero { + offset-distance: 100%; +} + +.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-approach] .trail { + opacity: 0; +} + +.chip { + transition: + transform 0.55s ease-in, + opacity 0.4s ease-in; + transition-delay: calc(var(--i) * 70ms); +} + +.chipFace { + fill: rgba(52, 227, 166, 0.12); + stroke: rgba(52, 227, 166, 0.5); + stroke-width: 1; +} + +.chipText { + fill: #bdf5df; +} + +.scene[data-approach] .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] .socketRing { + stroke: rgba(255, 255, 255, 0.3); +} + +.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 march { + to { + stroke-dashoffset: -26; + } +} + +@keyframes burst { + 0% { + transform: scale(0.7); + opacity: 0.8; + } + + 100% { + transform: scale(1.9); + opacity: 0; + } +} + +@keyframes pulse { + 0% { + transform: scale(0.6); + opacity: 0.9; + } + + 100% { + transform: scale(1.8); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .socketRing, + .amberRing, + .burst, + .bloom { + animation: none !important; + } + + .scene * { + transition: none !important; + } +} 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..318d7d274 --- /dev/null +++ b/apps/web/src/components/sections/saas/SaasTrustPipeline.tsx @@ -0,0 +1,518 @@ +'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', 'approach', 'docked', 'fill', 'settled', 'exit'] as const; +type Phase = (typeof PHASES)[number]; + +/* Beat length (ms) for each phase. `flight` + `approach` add up to the hero's + 2.6 s flight in the stylesheet; `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: 600, + flight: 1600, + approach: 1000, + docked: 500, + fill: 4200, + settled: 3000, + exit: 450, +}; + +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 path, off-canvas to the socket centre. */ + readonly flight: string; + 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: 'M -170 108 L 90 108 C 170 108, 216 142, 216 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: 'M -170 70 C -20 70, 78 60, 78 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 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; + + let i = 0; + let timer = 0; + const step = (): void => { + const current = PHASES[i] ?? 'idle'; + setPhase(current); + timer = window.setTimeout(() => { + i = (i + 1) % PHASES.length; + step(); + }, BEATS[current]); + }; + step(); + return () => window.clearTimeout(timer); + }, [active, reduce]); + + return ( +
      + + +
      + ); +} diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css b/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css deleted file mode 100644 index 394a53d92..000000000 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.module.css +++ /dev/null @@ -1,740 +0,0 @@ -/* - * "Move Beyond Shift Left" pipeline. - * - * Geometry is anchored to one number: RAIL_Y, the distance from the top of a - * node column down to the centre of its tile. Every element that has to sit on - * the rail — the base line, the fill, the junction, the bottom of the merge - * curve — is positioned from it, so nothing drifts when tile or label sizes - * change. - * - * Colour is rationed because it carries the argument. GREEN is the incoming - * verified components, the assurance they bring to the rail, and the release - * that inherits it. Every delivery stage, Security Review included, shares ONE - * neutral treatment: giving Security Review its own colour made it read as a - * separate system bolted on the end rather than the last stage of the same - * pipeline. - */ - -.stage { - --cycle: 7.4s; - --rail-y: 30px; - /* How far the hub sits above the rail. It is a negative margin, so it borrows - from the deck's top padding — the padding has to add it back or the tile - crowds the top edge while the bottom keeps its full gap. */ - /* DERIVED, never picked. The merge box spans from the hub tile's centre down - to the rail, so the raise is whatever puts those two --merge-h apart. - Choosing it by eye is what left the curve ending 2px off the rail. */ - --raise: calc(var(--hub-tile) / 2 - var(--rail-y) + var(--merge-h)); - --merge-h: 52px; - --merge-w: 92px; - --hub-w: 112px; - --tile: 60px; - /* The hub carries an orbit outside its hexagon, so it needs a larger box than - a pipeline tile. Every measurement that meets the hub reads THIS, not - --tile. */ - --hub-tile: 76px; - /* How far the orbit ring stops short of the hub tile's edge: the ring is - r=32.5 in a 76 box, so 38 - 32.5. */ - --orbit-inset: 5.5px; - --node-w: 104px; - /* The stacked node column's height: tile + gap + label + underline. Only the - vertical rail reads it. */ - --node-h: 96px; - /* The fill's window within the cycle. Shared by the fill itself and by every - halo, so a stage lights exactly as the front crosses it. */ - --fill-start: 0.24; - --fill-span: 0.48; - --green: #34e3a4; - - width: 100%; -} - -/* ---------- Deck ---------- */ - -.deck { - position: relative; - overflow: hidden; - /* Sized to the diagram, not to the column. Content measures ~854px across - (hub 112 + merge 88 + rail 654), so the deck sits just wide enough to frame - it with even air on both sides. At 1360 it was a mostly-empty band. */ - max-width: 968px; - margin: 0 auto; - /* Top padding carries the raise so the air above the hub matches the air - below the labels. Without it the deck renders top-heavy. */ - padding: calc(clamp(28px, 2.8vw, 38px) + var(--raise)) clamp(22px, 2.6vw, 40px) - clamp(28px, 2.8vw, 38px); - border-radius: clamp(20px, 2vw, 26px); - border: 1px solid rgba(146, 180, 244, 0.15); - background: linear-gradient(180deg, rgba(17, 25, 68, 0.66) 0%, rgba(9, 13, 42, 0.58) 100%); - box-shadow: - inset 0 1px 0 rgba(196, 218, 255, 0.08), - 0 30px 70px -40px rgba(2, 6, 28, 0.9); -} - -/* Anchored under the merge, so the light in the panel comes from where the - verified components enter rather than from nowhere. */ -.deckGlow { - position: absolute; - top: -30%; - left: -8%; - width: 46%; - height: 160%; - pointer-events: none; - background: radial-gradient(closest-side, rgba(52, 227, 164, 0.13) 0%, transparent 100%); -} - -/* ---------- Layout ---------- */ - -.diagram { - position: relative; - z-index: 1; - display: flex; - align-items: flex-start; - justify-content: center; -} - -/* ---------- Verified Components hub ---------- */ - -.hub { - display: flex; - flex: 0 0 auto; - flex-direction: column; - align-items: center; - gap: 12px; - width: var(--hub-w); - /* Raised clear of the rail so it reads as a separate thing joining, not as - the first stage in the row. */ - margin-top: calc(-1 * var(--raise)); -} - -.hubTile { - position: relative; - display: grid; - place-items: center; - width: var(--hub-tile); - height: var(--hub-tile); - /* closest-side on a square box gives a circle, so no radius is needed. */ - background: radial-gradient(closest-side, rgba(52, 227, 164, 0.2) 0%, transparent 100%); -} - -.hubMark { - width: 100%; - height: 100%; - overflow: visible; -} - -/* Turns continuously and independently of the pipeline's cycle: verification is - an ongoing condition, not a step that completes. */ -.hubOrbit { - transform-box: view-box; - transform-origin: 50% 50%; -} - -.hubOrbitRing { - fill: none; - stroke: rgba(87, 236, 181, 0.34); - stroke-width: 1.2; - stroke-dasharray: 3 7; - stroke-linecap: round; -} - -.hubSat { - fill: rgba(120, 240, 197, 0.8); -} - -.hubPlate { - filter: drop-shadow(0 10px 20px rgba(22, 180, 126, 0.5)); -} - -.hubGlyph { - fill: none; - stroke: #04382a; - stroke-width: 1.7; - stroke-linecap: round; - stroke-linejoin: round; -} - -.hubBadge { - transform-box: fill-box; - transform-origin: center; -} - -.hubBadgeDisc { - fill: #0a1440; - stroke: rgba(87, 236, 181, 0.9); - stroke-width: 1.5; -} - -.hubBadgeTick { - fill: none; - stroke: #57ecb5; - stroke-width: 2.3; - stroke-linecap: round; - stroke-linejoin: round; -} - -.hubRing { - position: absolute; - inset: 4px; - border-radius: 999px; - border: 1.5px solid rgba(87, 236, 181, 0.9); - opacity: 0; -} - -.hubLabel { - font-family: var(--font-sans); - font-size: 13.5px; - font-weight: 600; - line-height: 1.3; - letter-spacing: -0.01em; - text-align: center; - color: #8ef0c9; -} - -/* ---------- Merge curve ---------- */ - -/* - * The box IS the join. Its bottom-right corner sits exactly on the junction — - * which is also where the rail starts — and its top-left corner sits on the - * hub's orbit at the hub tile's centre height. The path runs corner to corner, - * so there is no arithmetic left over to get wrong. - * - * The left inset pulls the box back over the hub column: that column is wider - * than its tile, and the orbit stops short of the tile's own edge, so without - * it the curve began ~24px clear of the mark and read as a floating stub. - */ -.merge { - flex: 0 0 auto; - width: var(--merge-w); - height: var(--merge-h); - margin-right: 0; - margin-left: calc((var(--hub-tile) - var(--hub-w)) / 2 - var(--orbit-inset)); - margin-top: calc(var(--rail-y) - var(--merge-h)); - overflow: visible; -} - -/* Same reasoning as the rail base: the curve's unfilled track needs to read - before the draw reaches it. */ -.mergeTrack { - stroke: rgba(112, 236, 190, 0.38); - stroke-width: 2; - stroke-linecap: round; -} - -.mergeFlow { - stroke: var(--green); - stroke-width: 2; - stroke-linecap: round; - /* 110 is the path's measured length (109.5), not a round number that clears - it. Set longer, the dash pattern runs out before the offset does and the - curve finishes drawing early — leaving a fully drawn green curve dead-ended - into a still-grey rail, which is the other half of what read as a broken - link. */ - stroke-dasharray: 110; - stroke-dashoffset: 0; - filter: drop-shadow(0 0 5px rgba(52, 227, 164, 0.6)); -} - -/* ---------- Rail ---------- */ - -.railWrap { - position: relative; - display: flex; - align-items: flex-start; - justify-content: flex-start; - /* Fixed pitch, not space-between: the halos need to know where each stage - sits, and under space-between that depends on the container's width. - - box-sizing is declared explicitly and is load-bearing. Read as content-box - any horizontal padding would be added on top of the declared width, so the - rail would measure wider than the fractions were computed against and every - stage would light after the front had passed it. */ - box-sizing: border-box; - width: calc(6 * var(--node-w) + 5 * 6px); - gap: 6px; - /* NOT flex: 1. Growing absorbed the row's free space and stretched the rail - past its declared width, while the halo fractions stayed computed against - the declared one — so every stage lit late. The rail must keep exactly the - width its pitch implies. */ - flex: 0 0 auto; -} - -.railBase, -.railFill { - position: absolute; - top: calc(var(--rail-y) - 1px); - height: 2px; - border-radius: 2px; -} - -/* Stops on the last node's centre, not on the row's right edge — otherwise half - a node of rail hangs past Trusted Release with nothing at the end of it. The - fractions in the component are measured against this same shortened span, so - the fill completes exactly as it reaches the release. - - The unfilled track also has to be clearly visible on its own. Too faint, and - wherever the green had not yet reached there was nothing at all: the fill - looked like it ended in mid-air rather than running along a rail that was - waiting for it. That is what read as a broken link, not the geometry. */ -.railBase { - left: 0; - right: calc(var(--node-w) / 2); - background: rgba(163, 194, 246, 0.34); -} - -/* One continuous fill, not a dot travelling between links. The claim is that - the pipeline BECOMES verified; a fill says that, a moving pulse does not. */ -.railFill { - left: 0; - right: calc(var(--node-w) / 2); - transform: scaleX(1); - transform-origin: left center; - background: linear-gradient(90deg, var(--green) 0%, rgba(52, 227, 164, 0.55) 100%); - box-shadow: 0 0 10px rgba(52, 227, 164, 0.45); -} - -.junction { - position: absolute; - left: -5px; - top: calc(var(--rail-y) - 5px); - width: 10px; - height: 10px; - border-radius: 2px; - background: var(--green); - transform: rotate(45deg) scale(1); - box-shadow: 0 0 0 4px rgba(52, 227, 164, 0.14); -} - -/* ---------- Stages ---------- */ - -.node { - position: relative; - z-index: 1; - display: flex; - flex: 0 0 auto; - flex-direction: column; - align-items: center; - gap: 11px; - /* Wide enough that "Security Review" stays on one line; a single wrapping - label left the whole bottom edge ragged. */ - width: var(--node-w); -} - -.tile { - position: relative; - display: grid; - place-items: center; - width: var(--tile); - height: var(--tile); - border-radius: 18px; - color: rgba(211, 228, 255, 0.94); - background: linear-gradient(180deg, rgba(31, 44, 96, 0.95) 0%, rgba(15, 22, 58, 0.95) 100%); - border: 1px solid rgba(168, 196, 245, 0.2); - box-shadow: - inset 0 1px 0 rgba(214, 231, 255, 0.13), - 0 14px 26px -16px rgba(2, 6, 26, 0.95); -} - -/* The destination, not another stage: it is what the verified components - produce, so it carries their colour even at rest. */ -.nodeRelease .tile { - color: #04150e; - border-color: rgba(87, 236, 181, 0.5); - background: linear-gradient(180deg, #57ecb5 0%, #1cc389 100%); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.28), - 0 14px 30px -14px rgba(28, 195, 137, 0.8); -} - -.nodeRelease .label { - font-weight: 600; - color: #8ef0c9; -} - -/* Lights as the fill reaches the stage: the assurance arriving, not a status - the stage held on its own. */ -.tileHalo { - position: absolute; - inset: -1px; - border-radius: inherit; - border: 1px solid var(--green); - opacity: 0.5; - box-shadow: 0 0 16px rgba(52, 227, 164, 0.32); -} - -.icon { - position: relative; - width: 26px; - height: 26px; - stroke: currentColor; - stroke-width: 1.7; - stroke-linecap: round; - stroke-linejoin: round; -} - -.label { - font-family: var(--font-sans); - font-size: 12.5px; - font-weight: 500; - line-height: 1.3; - letter-spacing: -0.01em; - text-align: center; - white-space: nowrap; - color: rgba(223, 232, 255, 0.76); -} - -/* A second, slower marker under each label. Its hold outlasts the halo by a - wide margin, so instead of one tile blinking at a time there is a band of - four or five stages reading as covered at once, trailing the front. - - It cannot hold to the end of the pass. Every stage's phase is an offset into - the same cycle, so a hold long enough to keep the FIRST stage lit until the - fill completes would keep the LAST stage lit into the next pass, over an - empty rail. The band is the honest version of the idea; the release node, - green at rest, is what carries "verified" permanently. */ -.underline { - width: 26px; - height: 2px; - margin-top: -4px; - border-radius: 2px; - background: var(--green); - transform: scaleX(1); -} - -/* ---------- Motion ---------- */ - -@media (prefers-reduced-motion: no-preference) { - /* backwards, on every one of these, is load-bearing. The rest state above is - the SETTLED state so that reduced motion renders a finished diagram; during - an animation's delay the element must instead hold its 0% keyframe, or it - would show finished and then snap back to empty when the delay elapsed. */ - .tileHalo, - .underline { - animation-fill-mode: backwards; - } - - .hubOrbit { - animation: orbit 22s linear infinite; - } - - .hubRing { - animation: ring var(--cycle) ease-out backwards infinite; - } - - .hubBadge { - animation: seal var(--cycle) cubic-bezier(0.22, 1, 0.36, 1) backwards infinite; - } - - .mergeFlow { - animation: draw var(--cycle) ease-in-out backwards infinite; - } - - .junction { - animation: lock var(--cycle) cubic-bezier(0.22, 1, 0.36, 1) backwards infinite; - } - - .railFill { - /* Linear, so the front travels at constant speed. Eased, it drifted out of - step with the halos, which fire on position. */ - animation: fill var(--cycle) linear backwards infinite; - } - - .tileHalo, - .underline { - /* Delay, not a keyframe stop: a custom property cannot be interpolated into - a percentage inside @keyframes, so each stage carries its own phase. - Derived from the stage's position on the rail and the fill's own window, - which is what keeps the two moving together. */ - animation-duration: var(--cycle); - animation-iteration-count: infinite; - animation-delay: calc(var(--cycle) * (var(--fill-start) + var(--frac, 0.5) * var(--fill-span))); - } - - .tileHalo { - animation-name: halo; - animation-timing-function: ease-out; - } - - .underline { - animation-name: cover; - animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); - } -} - -/* Completes exactly at --fill-start. Finishing early left a fully drawn green - curve dead-ending into an unfilled grey rail, which read as a broken link. */ -@keyframes draw { - 0%, - 16% { - stroke-dashoffset: 110; - } - - 24%, - 90% { - stroke-dashoffset: 0; - } - - 96%, - 100% { - stroke-dashoffset: 110; - } -} - -@keyframes orbit { - to { - transform: rotate(360deg); - } -} - -/* Lands with the ring, so the attestation reads as the moment the hub resolves - rather than as a decoration that was always there. */ -@keyframes seal { - 0%, - 14% { - opacity: 0; - transform: scale(0.3); - } - - 21% { - opacity: 1; - transform: scale(1.22); - } - - 27%, - 100% { - opacity: 1; - transform: scale(1); - } -} - -@keyframes ring { - 0%, - 16% { - opacity: 0; - transform: scale(0.75); - } - - 22% { - opacity: 0.95; - transform: scale(1); - } - - 36%, - 100% { - opacity: 0; - transform: scale(1.5); - } -} - -/* Lands on the handoff: the curve has just arrived and the fill is about to - leave, so the junction marks the joint at the moment it matters. */ -@keyframes lock { - 0%, - 22% { - transform: rotate(45deg) scale(0); - box-shadow: 0 0 0 0 rgba(52, 227, 164, 0); - } - - 28% { - transform: rotate(45deg) scale(1.5); - box-shadow: 0 0 0 7px rgba(52, 227, 164, 0.2); - } - - 34%, - 90% { - transform: rotate(45deg) scale(1); - box-shadow: 0 0 0 4px rgba(52, 227, 164, 0.14); - } - - 97%, - 100% { - transform: rotate(45deg) scale(0); - box-shadow: 0 0 0 0 rgba(52, 227, 164, 0); - } -} - -/* 24% to 72% — --fill-start to --fill-start + --fill-span. Change one and the - other must follow, or every halo desynchronises from the front. */ -@keyframes fill { - 0%, - 24% { - transform: scaleX(0); - opacity: 1; - } - - 72%, - 90% { - transform: scaleX(1); - opacity: 1; - } - - 97%, - 100% { - transform: scaleX(1); - opacity: 0; - } -} - -/* One keyframe set for all six stages; the delay puts each at its own point - along the fill. Bright on arrival, then held down so the whole rail reads as - verified rather than six things blinking. */ -/* The peak sits at 1.5% of the cycle, ~110ms after the front arrives. It is - expressed as a percentage of a 7.4s cycle, so lengthening the cycle without - revisiting this number pushes the flare visibly behind the fill. */ -@keyframes halo { - 0% { - opacity: 0; - } - - 1.5% { - opacity: 1; - } - - 8%, - 42% { - opacity: 0.5; - } - - 52%, - 100% { - opacity: 0; - } -} - -@keyframes cover { - 0% { - background-color: rgba(163, 194, 246, 0.26); - transform: scaleX(0.5); - } - - 3%, - 48% { - background-color: var(--green); - transform: scaleX(1); - } - - 58%, - 100% { - background-color: rgba(163, 194, 246, 0.26); - transform: scaleX(0.5); - } -} - -/* ---------- Stacked below the breakpoint ---------- */ - -@media (max-width: 959px) { - .stage { - /* Stacked, nothing is raised, so the deck should not pad for a raise. */ - --raise: 0px; - } - - .diagram { - flex-direction: column; - align-items: center; - } - - .hub { - width: auto; - } - - .merge { - --merge-w: 52px; - --merge-h: 46px; - - /* Rotated, so the corner-to-corner path now runs top to bottom. Its ends - land on the hub above and the vertical rail below. */ - margin: 4px 0 0; - transform: rotate(90deg); - } - - .railWrap { - flex-direction: column; - align-items: center; - justify-content: flex-start; - gap: 24px; - width: auto; - } - - .railBase, - .railFill { - left: 50%; - right: auto; - top: 0; - /* Same reasoning as the horizontal rail: it ends on the last node's centre. */ - bottom: calc(var(--node-h) / 2); - width: 2px; - height: auto; - transform: translateX(-50%); - } - - .railFill { - transform: translateX(-50%) scaleY(1); - transform-origin: top center; - background: linear-gradient(180deg, var(--green) 0%, rgba(52, 227, 164, 0.55) 100%); - } - - .junction { - left: 50%; - top: -5px; - transform: translateX(-50%) rotate(45deg) scale(1); - } - - @media (prefers-reduced-motion: no-preference) { - .tileHalo, - .underline { - /* Stacked, the stages sit at different fractions of a taller rail. */ - animation-delay: calc( - var(--cycle) * (var(--fill-start) + var(--frac-v, 0.5) * var(--fill-span)) - ); - } - - .railFill { - animation-name: fillVertical; - } - - .junction { - animation-name: lockVertical; - } - } -} - -@keyframes fillVertical { - 0%, - 24% { - transform: translateX(-50%) scaleY(0); - opacity: 1; - } - - 72%, - 90% { - transform: translateX(-50%) scaleY(1); - opacity: 1; - } - - 97%, - 100% { - transform: translateX(-50%) scaleY(1); - opacity: 0; - } -} - -@keyframes lockVertical { - 0%, - 22% { - transform: translateX(-50%) rotate(45deg) scale(0); - } - - 28% { - transform: translateX(-50%) rotate(45deg) scale(1.5); - } - - 34%, - 90% { - transform: translateX(-50%) rotate(45deg) scale(1); - } - - 97%, - 100% { - transform: translateX(-50%) rotate(45deg) scale(0); - } -} diff --git a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx b/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx deleted file mode 100644 index ab35f9300..000000000 --- a/apps/web/src/components/sections/saas/SaasVerifiedCore.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import type React from 'react'; -import styles from './SaasVerifiedCore.module.css'; - -/* - * "Move Beyond Shift Left" diagram. - * - * The claim: security is not a gate at the end of delivery, it arrives with the - * components you start from. So Verified Components sits above and left of the - * pipeline and joins it through a curve ahead of Code — visibly a separate - * thing merging in, not the first box in a row. From that junction on it is one - * pipeline in one neutral treatment: Code, Build, Test, Deploy, Security - * Review, and the trusted release they produce. Green is rationed to what the - * verified components bring, and to the release that inherits it. - * - * The sequence: the hub locks, the merge curve draws down into the rail, the - * rail fills, each stage lights and stays marked as the front crosses it, and - * the release resolves. Then it holds and repeats. - * - * GEOMETRY IS LOAD-BEARING. A stage lights on POSITION, so the rail runs on a - * fixed pitch and these constants are the same ones the stylesheet uses. Two - * bugs came from breaking that: `flex: 1` let the rail grow past its declared - * width, and `space-between` made stage positions depend on the container. - * Both desynchronised the halos from the fill. - */ - -type StageId = 'code' | 'build' | 'test' | 'deploy' | 'review' | 'release'; - -interface Stage { - readonly id: StageId; - readonly label: string; -} - -const STAGES: readonly Stage[] = [ - { 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' }, -]; - -/* Must match .railWrap in the stylesheet. */ -const NODE_W = 104; -const NODE_GAP = 6; - -/* - * The line does NOT span the whole node row. It starts at the row's left edge, - * so there is a lead-in from the merge junction to Code, and it stops dead on - * the last node's CENTRE. Run to the row's right edge instead and ~22px of rail - * hangs off the end of Trusted Release with nothing to arrive at. - * - * Both halves of that decision live here: the stylesheet insets the line by - * half a node on the right, and the fractions below are measured against the - * same shortened span. So stage 5 sits at exactly 1.0 and the fill completes on - * the release rather than past it. - */ -const railSpan = (nodeW: number, gap: number): number => - STAGES.length * nodeW + (STAGES.length - 1) * gap - nodeW / 2; - -/** Centre of stage i along the horizontal rail, as a 0..1 fraction. */ -const fracH = (i: number): number => - (i * (NODE_W + NODE_GAP) + NODE_W / 2) / railSpan(NODE_W, NODE_GAP); - -/* Stacked: tile 60 + gap 11 + label 16.25 + underline 9, measured. */ -const NODE_H = 96; -const NODE_GAP_V = 24; - -/** Centre of stage i along the vertical rail, as a 0..1 fraction. */ -const fracV = (i: number): number => - (i * (NODE_H + NODE_GAP_V) + NODE_H / 2) / railSpan(NODE_H, NODE_GAP_V); - -export function SaasVerifiedCore(): React.ReactElement { - return ( -
      - -
      - ); -} - -function StageIcon({ stage }: { readonly stage: StageId }): React.ReactElement { - const props = { - 'aria-hidden': true, - className: styles.icon, - 'data-stage-icon': stage, - fill: 'none', - viewBox: '0 0 24 24', - } as const; - - switch (stage) { - case 'code': - return ( - - - - ); - case 'build': - return ( - - - - - ); - case 'test': - return ( - - - - - ); - case 'deploy': - return ( - - - - ); - case 'review': - return ( - - - - - ); - case 'release': - return ( - - - - - - ); - } -} - -/* - * The seal on the incoming components. - * - * A hexagon, because in developer tooling that shape already means package or - * module — and because every pipeline stage is a rounded square, so the thing - * arriving from outside should not share their silhouette. Inside it, stacked - * plates: a component is layers, not a single object. Around it, a dashed orbit - * that never stops turning, since verification is continuous rather than a - * state something was left in. The badge is the attestation, and it is a badge - * ON the component rather than the whole mark, which is the actual claim. - */ -function HubMark(): React.ReactElement { - 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'; From 27bfc2b79f1bdd831086b79fa3cc6965967f087e Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 15:00:55 +0530 Subject: [PATCH 58/85] style(web): thicken the hero strike line to 2.5px --- apps/web/src/app/globals.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index dd1785d15..7c19609ee 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1580,8 +1580,9 @@ body { left: -2%; right: -2%; top: 50%; - /* 2px, not 3px: balanced against the lighter 500-weight letterforms. */ - height: 2px; + /* Sits just under the 500-weight stem width — reads as a deliberate + cross-out rather than a hairline, without outweighing the letterforms. */ + height: 2.5px; background: #e2726c; border-radius: 2px; transform-origin: left center; From 05c9fdda125386ad883401f39ed506e600121471 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 15:03:08 +0530 Subject: [PATCH 59/85] style(web): thicken the hero strike line to 3px --- apps/web/src/app/globals.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 7c19609ee..46d1669b6 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1580,9 +1580,9 @@ body { left: -2%; right: -2%; top: 50%; - /* Sits just under the 500-weight stem width — reads as a deliberate - cross-out rather than a hairline, without outweighing the letterforms. */ - height: 2.5px; + /* 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; background: #e2726c; border-radius: 2px; transform-origin: left center; From ff7bb79fde31c6adc463db7e599a0684cbec2053 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 15:11:32 +0530 Subject: [PATCH 60/85] style(web): saturate the hero strike to vermillion --- apps/web/src/app/globals.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 46d1669b6..f2b674a94 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1583,7 +1583,10 @@ body { /* 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; - background: #e2726c; + /* 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; From b01a1ce087dcf78f5dc56a7b33399d75ea4c49ea Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 16:17:53 +0530 Subject: [PATCH 61/85] fix(web): stop the hero strike overshooting past 'Hardened.' --- apps/web/src/app/globals.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index f2b674a94..9e26e1f2b 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1577,8 +1577,10 @@ body { .cs-hh-hardened::after { content: ""; position: absolute; - left: -2%; - right: -2%; + /* Flush to the word — no overshoot, so the bar stops at the period instead + of running on into the gap before "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). */ @@ -5817,7 +5819,7 @@ body { } } -/* ROI calculator — "compounding chain" (RoiHowItWorks). Metro-line on desktop, +/* Estimate Your Outcome page: "compounding chain" (OutcomeHowItWorks). 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 { From d7ef69bcaea60b3c72ae05621ce5cf6afc0b9469 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 16:19:41 +0530 Subject: [PATCH 62/85] fix(web): exclude the period from the hero strike --- apps/web/src/app/globals.css | 11 +++++++---- apps/web/src/components/sections/home/HeroHeading.tsx | 10 ++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 9e26e1f2b..30148b1a0 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1562,8 +1562,11 @@ 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. */ -.cs-hh-hardened { + 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 @@ -1577,8 +1580,8 @@ body { .cs-hh-hardened::after { content: ""; position: absolute; - /* Flush to the word — no overshoot, so the bar stops at the period instead - of running on into the gap before "Secure." */ + /* 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%; diff --git a/apps/web/src/components/sections/home/HeroHeading.tsx b/apps/web/src/components/sections/home/HeroHeading.tsx index ec3cc5417..f5d18ed5c 100644 --- a/apps/web/src/components/sections/home/HeroHeading.tsx +++ b/apps/web/src/components/sections/home/HeroHeading.tsx @@ -36,9 +36,15 @@ export function HeroHeading() { {/* Caret lives on the wrapper so the inner clip-path doesn't crop it. */} + {/* The period sits outside .cs-hh-hardened so the strike crosses + the word only, while still sharing its gradient→gray fade. */} - Hardened. - Hardened. + Hardened + Hardened + + + . + . {" "} Secure. From 3c2f91e3d031b6f4991f28ac597e2ef3f0b632aa Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 16:39:45 +0530 Subject: [PATCH 63/85] fix(web): refine the SaaS Shift Left scene choreography Drop the dashed socket and the grey base rail: the tiles stand alone until Verified Components lands, then the green line draws. Swap the stage icons for lucide glyphs at 40% of the tile (hammer, flask, rocket, shield with magnifier, checked package); none of the pipeline glyphs carries a check, since the badge that lands on each tile is the check. Restructure the flight into glide, hover, absorb and descend so the chips are legible for about two seconds and fold into the hero before touchdown. Drive the phase clock from animation frames with elapsed-time beats: timers in a background tab fire in one-second bursts, which fast-forwarded the phases while the transitions sat frozen. Add a fake-timer test that pins the beat order and the absorb-before-descend invariant. --- .../saas/SaasTrustPipeline.module.css | 87 ++++--- .../sections/saas/SaasTrustPipeline.test.tsx | 110 +++++++++ .../sections/saas/SaasTrustPipeline.tsx | 213 +++++++++++++----- 3 files changed, 311 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/components/sections/saas/SaasTrustPipeline.test.tsx diff --git a/apps/web/src/components/sections/saas/SaasTrustPipeline.module.css b/apps/web/src/components/sections/saas/SaasTrustPipeline.module.css index 64834c2b4..2526a54cc 100644 --- a/apps/web/src/components/sections/saas/SaasTrustPipeline.module.css +++ b/apps/web/src/components/sections/saas/SaasTrustPipeline.module.css @@ -17,7 +17,8 @@ --green: #34e3a6; --green-soft: #7cf3c6; --sweep: 3.2s; - --flight: 2.6s; + --glide: 1.8s; + --drop: 0.75s; position: relative; overflow: hidden; border-radius: 28px; @@ -76,12 +77,6 @@ /* ---------------------------------------------------------------- statics */ -.railBase { - fill: none; - stroke: rgba(255, 255, 255, 0.11); - stroke-width: 3; -} - /* Opaque so the rail passes behind the tile, not across its icon. */ .tile { fill: #151a3d; @@ -111,27 +106,8 @@ /* ------------------------------------------------------------------ socket */ -.socketFace { - fill: rgba(255, 255, 255, 0.025); -} - -.socketRing { - fill: none; - stroke: rgba(255, 255, 255, 0.3); - stroke-width: 1.5; - stroke-dasharray: 6 7; - animation: march 1.6s linear infinite; - transition: stroke 0.35s ease; -} - -.scene[data-docked] .socketRing { - stroke: var(--green); - stroke-dasharray: 1000 0; - animation: none; -} - .socketGlow { - opacity: 0.35; + opacity: 0; transition: opacity 0.8s ease; } @@ -178,14 +154,34 @@ /* -------------------------------------------------------------------- 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(--flight) cubic-bezier(0.25, 0.46, 0.45, 0.94); + 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 { @@ -208,15 +204,17 @@ opacity: 1; } -.scene[data-approach] .trail { +.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.55s ease-in, - opacity 0.4s ease-in; - transition-delay: calc(var(--i) * 70ms); + transform 0.6s cubic-bezier(0.65, 0, 0.35, 1), + opacity 0.45s ease-in; + transition-delay: calc(var(--i) * 90ms); } .chipFace { @@ -229,7 +227,7 @@ fill: #bdf5df; } -.scene[data-approach] .chip { +.scene[data-absorb] .chip { transform: translate(var(--dx), var(--dy)) scale(0.3); opacity: 0; } @@ -337,10 +335,6 @@ transition-delay: 0s; } -.scene[data-exit] .socketRing { - stroke: rgba(255, 255, 255, 0.3); -} - .scene[data-exit] .releaseIcon { color: rgba(255, 255, 255, 0.86); transition-delay: 0s; @@ -353,12 +347,6 @@ /* -------------------------------------------------------------- keyframes */ -@keyframes march { - to { - stroke-dashoffset: -26; - } -} - @keyframes burst { 0% { transform: scale(0.7); @@ -371,6 +359,17 @@ } } +@keyframes bob { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-5px); + } +} + @keyframes pulse { 0% { transform: scale(0.6); @@ -384,7 +383,7 @@ } @media (prefers-reduced-motion: reduce) { - .socketRing, + .heroFloat, .amberRing, .burst, .bloom { 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 index 318d7d274..4beb54991 100644 --- a/apps/web/src/components/sections/saas/SaasTrustPipeline.tsx +++ b/apps/web/src/components/sections/saas/SaasTrustPipeline.tsx @@ -29,20 +29,37 @@ import styles from './SaasTrustPipeline.module.css'; * list in SaasShiftLeft is the accessible reading. */ -const PHASES = ['idle', 'flight', 'approach', 'docked', 'fill', 'settled', 'exit'] as const; +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` + `approach` add up to the hero's - 2.6 s flight in the stylesheet; `fill` is the 3.2 s rail sweep plus the - 0.9 s release bloom, so the hold starts on a still frame. */ +/* 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: 600, - flight: 1600, - approach: 1000, - docked: 500, + idle: 400, + flight: 1800, + hover: 1500, + absorb: 700, + descend: 750, + docked: 400, fill: 4200, - settled: 3000, - exit: 450, + settled: 1400, + exit: 400, }; const SOURCE_LABEL = 'Verified Components'; @@ -66,8 +83,9 @@ interface Layout { readonly height: number; readonly socket: Point; readonly socketR: number; - /** Hero flight path, off-canvas to the socket centre. */ - readonly flight: string; + /** 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; @@ -86,7 +104,11 @@ const HORIZONTAL: Layout = { height: 380, socket: { x: 216, y: 224 }, socketR: 56, - flight: 'M -170 108 L 90 108 C 170 108, 216 142, 216 224', + 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' }), @@ -101,7 +123,11 @@ const VERTICAL: Layout = { height: 860, socket: { x: 78, y: 150 }, socketR: 56, - flight: 'M -170 70 C -20 70, 78 60, 78 150', + 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' }), @@ -113,6 +139,24 @@ const VERTICAL: Layout = { 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); } @@ -229,7 +273,9 @@ function Scene({ orientation, phase }: SceneProps): React.ReactElement { data-scene={orientation} data-phase={phase} data-flying={reached('flight')} - data-approach={reached('approach')} + data-hover={reached('hover')} + data-absorb={reached('absorb')} + data-descend={reached('descend')} data-docked={reached('docked')} data-fill={reached('fill')} data-settled={reached('settled')} @@ -271,7 +317,7 @@ function Scene({ orientation, phase }: SceneProps): React.ReactElement { - {/* Ambient glow around the socket, brightens once docked. */} + {/* Ambient glow under the docked hero. */} - {/* Rail: base, then the green fill that sweeps it. */} - + {/* Rail: drawn only by the green sweep, once the hero has docked. */} - {/* Socket: the empty slot at the head of the pipeline. */} + {/* Landing flash at the head of the pipeline. */} - - @@ -312,7 +355,9 @@ function Scene({ orientation, phase }: SceneProps): React.ReactElement { className={styles.stage} style={{ ['--frac' as string]: frac.toFixed(4) }} > - {isRelease ? : null} + {isRelease ? ( + + ) : null} + {withChips ? ( - + ) : null} {withChips ? CHIPS.map((chip, i) => { + /* Trailing the hero, and clear of the card's left edge once it + has docked: the leftmost chip spans to x=22 at the socket. */ const spots: readonly Point[] = [ - { x: -142, y: -42 }, - { x: -172, y: 0 }, - { x: -142, y: 42 }, + { x: -120, y: -44 }, + { x: -152, y: 0 }, + { x: -120, y: 44 }, ]; const at = spots[i] ?? { x: -150, y: 0 }; return ( @@ -422,8 +483,21 @@ function Scene({ orientation, phase }: SceneProps): React.ReactElement { ['--i' as string]: i, }} > - - + + {chip} @@ -431,26 +505,38 @@ function Scene({ orientation, phase }: SceneProps): React.ReactElement { ); }) : null} - - - - - - - - - - - - - + + + + + + + strokeLinecap="round" + > + + + + + + + + @@ -495,18 +581,35 @@ export function SaasTrustPipeline(): React.ReactElement { 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 timer = 0; - const step = (): void => { + let startedAt = performance.now(); + let frame = 0; + const tick = (now: number): void => { const current = PHASES[i] ?? 'idle'; - setPhase(current); - timer = window.setTimeout(() => { + if (now - startedAt >= BEATS[current]) { i = (i + 1) % PHASES.length; - step(); - }, BEATS[current]); + 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); }; - step(); - return () => window.clearTimeout(timer); }, [active, reduce]); return ( From c107d2b6578bcbb4699c84306a748a91183b7857 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 16:39:45 +0530 Subject: [PATCH 64/85] copy(web): trim the fourth SaaS foundation caption to one line --- apps/web/src/components/sections/saas/SaasFoundation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.', }, ]; From 6a756324eb6f6616905c5d0a4538c85e4db5d1de Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 16:40:46 +0530 Subject: [PATCH 65/85] feat(web): launch the SaaS industry page Drop the noindex,nofollow pair, list the path in the sitemap's STATIC_ROUTES and restore the Solutions > By industry nav row. Breadcrumb, JsonLdGraph and pageRegistry row were already in place. Render the Outcomes band as one responsive tree instead of a desktop and a mobile copy, so each H3 exists once in the DOM. --- .../saas-container-security/page.tsx | 10 +++----- apps/web/src/app/sitemap.ts | 6 ++--- .../components/sections/saas/SaasOutcomes.tsx | 24 ++++-------------- apps/web/src/lib/nav-config.ts | 25 ++++++++----------- docs/web/WEB-PAGES.md | 2 +- 5 files changed, 23 insertions(+), 44 deletions(-) diff --git a/apps/web/src/app/industries/saas-container-security/page.tsx b/apps/web/src/app/industries/saas-container-security/page.tsx index e9ef6f764..3dcde949f 100644 --- a/apps/web/src/app/industries/saas-container-security/page.tsx +++ b/apps/web/src/app/industries/saas-container-security/page.tsx @@ -25,11 +25,11 @@ import { getPageGraph } from '@/lib/seo/compose-page'; * Renamed from /saas, which never resolved in production (it returned 404 * there, so no redirect is needed — unlike its sibling, which did resolve). * - * 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. @@ -46,8 +46,6 @@ export const metadata = buildPageMetadata({ 'Protect SaaS applications with hardened container images, near-zero CVEs, SBOMs, signed provenance, and continuous software supply chain visibility.', path: '/industries/saas-container-security', eyebrow: 'Solutions', - noindex: true, - nofollow: true, }); export const revalidate = 21600; // 6h ISR fallback — on-demand publish revalidation keeps this fresh diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index 2c40ec562..a8aab4cd1 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -113,10 +113,8 @@ const STATIC_ROUTES: ReadonlyArray<{ path: string }> = [ { path: '/for-developers' }, { path: '/guide' }, { 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/saas-container-security' }, + // `/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/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/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index 33399096c..447c22b5a 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -158,21 +158,12 @@ export const NAV_TREE: NavItem[] = [ ], }, { - // /industries/financial-services-container-security is live: indexable - // and listed in the sitemap. + // Both /industries/* pages are 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" } - // - // 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 +172,12 @@ export const NAV_TREE: NavItem[] = [ description: "Verified components for regulated financial software.", icon: "bank", }, + { + label: "SaaS", + href: "/industries/saas-container-security", + description: "Ship faster on a verified software foundation.", + icon: "cloud", + }, ], }, ], diff --git a/docs/web/WEB-PAGES.md b/docs/web/WEB-PAGES.md index 814badf06..8cd47cce2 100644 --- a/docs/web/WEB-PAGES.md +++ b/docs/web/WEB-PAGES.md @@ -72,7 +72,7 @@ page slugs, categories, types, and build status across the dev journey. | 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. | | 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 | 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). **Live 2026-09-02**: indexable (the `noindex,nofollow` pair dropped), listed in `STATIC_ROUTES` and nav-linked (Solutions › By industry). Breadcrumb, `JsonLdGraph` and `pageRegistry` row (id=43) in place. | | 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`). | --- From cc65dd75edb39aea4111ab4c84d6d28ca3efea59 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 16:55:43 +0530 Subject: [PATCH 66/85] copy(web): change the SaaS hero H1 to "Applications Move Faster. Security Must Be Smarter." --- apps/web/src/components/sections/saas/SaasHero.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/sections/saas/SaasHero.tsx b/apps/web/src/components/sections/saas/SaasHero.tsx index c9ca86e1f..3f59536ff 100644 --- a/apps/web/src/components/sections/saas/SaasHero.tsx +++ b/apps/web/src/components/sections/saas/SaasHero.tsx @@ -144,8 +144,8 @@ export function SaasHero(): React.ReactElement { 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
      . */} - Modern Applications Move Faster. - Security Must Keep Up. + Applications Move Faster. + Security Must Be Smarter. From 6003ae19946aa53bc61a4b06f7a3debf74033c9a Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 17:02:36 +0530 Subject: [PATCH 67/85] feat(web): rename the SaaS industry page to /industries/modern-technology Nav label becomes Modern Technology. The page had not reached main, but /industries/saas-container-security resolved publicly (noindex) for two days, so it 301s to the new path; the short /saas-container-security 301 now points straight at the final URL so nothing chains. Breadcrumb, metadata path, sitemap entry and JSON-LD path follow. The pageRegistry row keys on path and must be updated in prod to keep the WebPage node in the graph. --- apps/web/next.config.ts | 11 ++++++++++- .../page.tsx | 4 ++-- .../page.tsx | 18 +++++++++++------- apps/web/src/app/sitemap.ts | 2 +- apps/web/src/lib/nav-config.ts | 4 ++-- docs/web/WEB-PAGES.md | 2 +- 6 files changed, 27 insertions(+), 14 deletions(-) rename apps/web/src/app/industries/{saas-container-security => modern-technology}/page.tsx (80%) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 8bdafb187..1647c9b79 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -86,7 +86,16 @@ const nextConfig: NextConfig = { // industry page is added. { source: "/saas-container-security", - destination: "/industries/saas-container-security", + destination: "/industries/modern-technology", + permanent: true, + }, + // The SaaS page was renamed from `/industries/saas-container-security` on + // 2026-09-02 before it reached main. That path resolved publicly (noindex) + // for two days, so it is caught here; the short path above points straight + // at the final URL so nothing chains. + { + source: "/industries/saas-container-security", + destination: "/industries/modern-technology", permanent: true, }, { 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..3606d2f6c 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,7 +19,7 @@ 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 + * First page under the /industries segment, with modern-technology (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 @@ -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-technology 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-technology/page.tsx similarity index 80% rename from apps/web/src/app/industries/saas-container-security/page.tsx rename to apps/web/src/app/industries/modern-technology/page.tsx index 3dcde949f..71cbe6ca9 100644 --- a/apps/web/src/app/industries/saas-container-security/page.tsx +++ b/apps/web/src/app/industries/modern-technology/page.tsx @@ -16,14 +16,18 @@ import { JsonLdGraph } from '@/components/JsonLdGraph'; import { getPageGraph } from '@/lib/seo/compose-page'; /* - * /industries/saas-container-security + * /industries/modern-technology * * 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. * * Renamed from /saas, which never resolved in production (it returned 404 - * there, so no redirect is needed — unlike its sibling, which did resolve). + * there, so no redirect was needed), then from /industries/saas-container-security + * on 2026-09-02, before the page reached main. That path did resolve publicly + * (noindex), so it 301s here from next.config alongside the short-path 301. + * The pageRegistry row keys on path; update it to this path or the WebPage + * node drops out of the graph. * * 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. @@ -31,7 +35,7 @@ import { getPageGraph } from '@/lib/seo/compose-page'; * its sibling, so it emits the full Organization + WebSite + WebPage + * BreadcrumbList graph. * - * The breadcrumb is Home > SaaS, with no Industries crumb, because /industries + * The breadcrumb is Home > Modern Technology, with no Industries crumb, because /industries * has no page yet and the crumb would link to a 404. * * Band rhythm, in order: dark hero, white, tinted, DARK, white, tinted, DARK. @@ -44,19 +48,19 @@ export const metadata = buildPageMetadata({ 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', + path: '/industries/modern-technology', eyebrow: 'Solutions', }); 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-technology', [ + breadcrumbSchema([{ name: 'Home', path: '/' }, { name: 'Modern Technology' }]), ]); return ( <> - +
      diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index a8aab4cd1..eeeab43b1 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -113,7 +113,7 @@ const STATIC_ROUTES: ReadonlyArray<{ path: string }> = [ { path: '/for-developers' }, { path: '/guide' }, { path: '/industries/financial-services-container-security' }, - { path: '/industries/saas-container-security' }, + { path: '/industries/modern-technology' }, // `/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. diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index 447c22b5a..470e3ee99 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -173,8 +173,8 @@ export const NAV_TREE: NavItem[] = [ icon: "bank", }, { - label: "SaaS", - href: "/industries/saas-container-security", + label: "Modern Technology", + href: "/industries/modern-technology", description: "Ship faster on a verified software foundation.", icon: "cloud", }, diff --git a/docs/web/WEB-PAGES.md b/docs/web/WEB-PAGES.md index 8cd47cce2..5109eb123 100644 --- a/docs/web/WEB-PAGES.md +++ b/docs/web/WEB-PAGES.md @@ -72,7 +72,7 @@ page slugs, categories, types, and build status across the dev journey. | 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. | | 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). **Live 2026-09-02**: indexable (the `noindex,nofollow` pair dropped), listed in `STATIC_ROUTES` and nav-linked (Solutions › By industry). Breadcrumb, `JsonLdGraph` and `pageRegistry` row (id=43) in place. | +| 14 | Modern Technology (SaaS) | `/industries/modern-technology` | Static | ✅ | Title, description and H1 are the SEO team's, applied verbatim; nav label "Modern Technology". **Renamed from `/saas` 2026-08-31** (404 in production, no redirect needed) and **from `/industries/saas-container-security` 2026-09-02** before reaching main; that path resolved publicly (noindex) so it 301s in `next.config.ts`, and the short `/saas-container-security` 301 points straight at the final URL. **Live 2026-09-02**: indexable, listed in `STATIC_ROUTES` and nav-linked (Solutions › By industry). Breadcrumb and `JsonLdGraph` in place; the `pageRegistry` row (id=43) keys on path and must be updated to `/industries/modern-technology` 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`). | --- From 7129767c7faf79c0b8a91ec044ae468c6a478d43 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 17:20:53 +0530 Subject: [PATCH 68/85] feat(web): move the SaaS page to /modern-applications with the SEO team's title and description The SEO doc specifies URL /modern-applications, title "Modern Application Security | CleanStart" and a new description; all three applied verbatim. The page leaves the /industries segment; nav label stays Modern Technology. Both legacy 301s (/saas-container-security and /industries/saas-container-security) point straight at the final URL. The intermediate /industries/modern-technology never deployed, so it gets no redirect. The pageRegistry row keys on path and must be updated in prod to keep the WebPage node in the graph. --- apps/web/next.config.ts | 13 +++++---- .../page.tsx | 12 ++++---- .../page.tsx | 28 +++++++++---------- apps/web/src/app/sitemap.ts | 2 +- apps/web/src/lib/nav-config.ts | 2 +- docs/web/WEB-PAGES.md | 2 +- 6 files changed, 30 insertions(+), 29 deletions(-) rename apps/web/src/app/{industries/modern-technology => modern-applications}/page.tsx (75%) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 1647c9b79..cbed37d90 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -86,16 +86,17 @@ const nextConfig: NextConfig = { // industry page is added. { source: "/saas-container-security", - destination: "/industries/modern-technology", + destination: "/modern-applications", permanent: true, }, - // The SaaS page was renamed from `/industries/saas-container-security` on - // 2026-09-02 before it reached main. That path resolved publicly (noindex) - // for two days, so it is caught here; the short path above points straight - // at the final URL so nothing chains. + // The SaaS page was renamed from `/industries/saas-container-security` to + // the SEO doc's `/modern-applications` on 2026-09-02 before it reached + // main. The old path resolved publicly (noindex) for two days, so it is + // caught here; the short path above points straight at the final URL so + // nothing chains. { source: "/industries/saas-container-security", - destination: "/industries/modern-technology", + destination: "/modern-applications", permanent: true, }, { 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 3606d2f6c..754c9dc8e 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 modern-technology (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. + * The only page under the /industries segment: the SEO doc put its sibling, + * the SaaS page, at the top-level /modern-applications. The segment is an + * exception to this site's otherwise flat routing (every other static page is + * a single segment, including the /for-developers + /for-ciso role family); + * it stays because the URL is the SEO team's and is live and indexed. * * 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/modern-technology stays + * were already in place. Its sibling /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/modern-technology/page.tsx b/apps/web/src/app/modern-applications/page.tsx similarity index 75% rename from apps/web/src/app/industries/modern-technology/page.tsx rename to apps/web/src/app/modern-applications/page.tsx index 71cbe6ca9..2a42d108c 100644 --- a/apps/web/src/app/industries/modern-technology/page.tsx +++ b/apps/web/src/app/modern-applications/page.tsx @@ -16,18 +16,19 @@ import { JsonLdGraph } from '@/components/JsonLdGraph'; import { getPageGraph } from '@/lib/seo/compose-page'; /* - * /industries/modern-technology + * /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. + * URL, title and description are the SEO team's, applied verbatim (the H1 is + * the client's). The SEO doc puts this page at the top level, not under the + * /industries segment its sibling financial-services-container-security uses. * * Renamed from /saas, which never resolved in production (it returned 404 * there, so no redirect was needed), then from /industries/saas-container-security * on 2026-09-02, before the page reached main. That path did resolve publicly * (noindex), so it 301s here from next.config alongside the short-path 301. - * The pageRegistry row keys on path; update it to this path or the WebPage - * node drops out of the graph. + * (An intermediate /industries/modern-technology existed on development for + * an hour and never deployed, so it has no redirect.) The pageRegistry row + * keys on path; update it to this path or the WebPage node drops out. * * 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. @@ -35,8 +36,7 @@ import { getPageGraph } from '@/lib/seo/compose-page'; * its sibling, so it emits the full Organization + WebSite + WebPage + * BreadcrumbList graph. * - * The breadcrumb is Home > Modern Technology, with no Industries crumb, because /industries - * has no page yet and the crumb would link to a 404. + * The breadcrumb is Home > Modern Applications. * * 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 @@ -44,23 +44,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/modern-technology', + 'Secure modern applications with verified software components, hardened container images, and trusted open-source libraries built for faster, safer software delivery.', + path: '/modern-applications', eyebrow: 'Solutions', }); 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/modern-technology', [ - breadcrumbSchema([{ name: 'Home', path: '/' }, { name: 'Modern Technology' }]), + const graph = await getPageGraph('/modern-applications', [ + breadcrumbSchema([{ name: 'Home', path: '/' }, { name: 'Modern Applications' }]), ]); return ( <> - +
      diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index eeeab43b1..9015026ef 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -113,7 +113,7 @@ const STATIC_ROUTES: ReadonlyArray<{ path: string }> = [ { path: '/for-developers' }, { path: '/guide' }, { path: '/industries/financial-services-container-security' }, - { path: '/industries/modern-technology' }, + { path: '/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. diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index 470e3ee99..a3afb1d2b 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -174,7 +174,7 @@ export const NAV_TREE: NavItem[] = [ }, { label: "Modern Technology", - href: "/industries/modern-technology", + href: "/modern-applications", description: "Ship faster on a verified software foundation.", icon: "cloud", }, diff --git a/docs/web/WEB-PAGES.md b/docs/web/WEB-PAGES.md index 5109eb123..fd658619c 100644 --- a/docs/web/WEB-PAGES.md +++ b/docs/web/WEB-PAGES.md @@ -72,7 +72,7 @@ page slugs, categories, types, and build status across the dev journey. | 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. | | 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 | Modern Technology (SaaS) | `/industries/modern-technology` | Static | ✅ | Title, description and H1 are the SEO team's, applied verbatim; nav label "Modern Technology". **Renamed from `/saas` 2026-08-31** (404 in production, no redirect needed) and **from `/industries/saas-container-security` 2026-09-02** before reaching main; that path resolved publicly (noindex) so it 301s in `next.config.ts`, and the short `/saas-container-security` 301 points straight at the final URL. **Live 2026-09-02**: indexable, listed in `STATIC_ROUTES` and nav-linked (Solutions › By industry). Breadcrumb and `JsonLdGraph` in place; the `pageRegistry` row (id=43) keys on path and must be updated to `/industries/modern-technology` in prod or the WebPage node drops out. | +| 14 | Modern Applications (SaaS) | `/modern-applications` | Static | ✅ | URL, title ("Modern Application Security \| CleanStart") and description are the SEO team's, applied verbatim; the H1 is the client's; nav label "Modern Technology" (Solutions › By industry). **Renamed from `/saas` 2026-08-31** (404 in production, no redirect needed) and **from `/industries/saas-container-security` 2026-09-02** before reaching main; that path resolved publicly (noindex) so it 301s in `next.config.ts`, and the short `/saas-container-security` 301 points straight at the final URL. **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 `/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`). | --- From c8cfa63fbb0e0f882225c4b1e966bed8ebd602cd Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 17:23:06 +0530 Subject: [PATCH 69/85] feat(web): settle the SaaS page at /industries/modern-applications Final URL, back under the /industries segment beside the finance page. Nav label becomes Modern Applications. The page was never indexed or linked under its earlier slugs, so the two saas-container-security redirects are removed rather than repointed. The pageRegistry row keys on path and must be updated in prod to keep the WebPage node in the graph. --- apps/web/next.config.ts | 23 +++------------- .../page.tsx | 12 ++++----- .../modern-applications/page.tsx | 27 +++++++++---------- apps/web/src/app/sitemap.ts | 2 +- apps/web/src/lib/nav-config.ts | 4 +-- docs/web/WEB-PAGES.md | 2 +- 6 files changed, 27 insertions(+), 43 deletions(-) rename apps/web/src/app/{ => industries}/modern-applications/page.tsx (75%) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index cbed37d90..aec14b868 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -79,26 +79,11 @@ const nextConfig: NextConfig = { destination: "/guide/:slug*", permanent: true, }, - // Industry pages live under `/industries/`, but each slug already names + // 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: neither was ever a - // live URL, just ones worth catching. Add an entry here whenever an - // industry page is added. - { - source: "/saas-container-security", - destination: "/modern-applications", - permanent: true, - }, - // The SaaS page was renamed from `/industries/saas-container-security` to - // the SEO doc's `/modern-applications` on 2026-09-02 before it reached - // main. The old path resolved publicly (noindex) for two days, so it is - // caught here; the short path above points straight at the final URL so - // nothing chains. - { - source: "/industries/saas-container-security", - destination: "/modern-applications", - permanent: true, - }, + // 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", 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 754c9dc8e..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. * - * The only page under the /industries segment: the SEO doc put its sibling, - * the SaaS page, at the top-level /modern-applications. The segment is an - * exception to this site's otherwise flat routing (every other static page is - * a single segment, including the /for-developers + /for-ciso role family); - * it stays because the URL is the SEO team's and is live and indexed. + * 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 /modern-applications 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/modern-applications/page.tsx b/apps/web/src/app/industries/modern-applications/page.tsx similarity index 75% rename from apps/web/src/app/modern-applications/page.tsx rename to apps/web/src/app/industries/modern-applications/page.tsx index 2a42d108c..90cce8bdc 100644 --- a/apps/web/src/app/modern-applications/page.tsx +++ b/apps/web/src/app/industries/modern-applications/page.tsx @@ -16,19 +16,17 @@ import { JsonLdGraph } from '@/components/JsonLdGraph'; import { getPageGraph } from '@/lib/seo/compose-page'; /* - * /modern-applications + * /industries/modern-applications * - * URL, title and description are the SEO team's, applied verbatim (the H1 is - * the client's). The SEO doc puts this page at the top level, not under the - * /industries segment its sibling financial-services-container-security uses. + * 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 was needed), then from /industries/saas-container-security - * on 2026-09-02, before the page reached main. That path did resolve publicly - * (noindex), so it 301s here from next.config alongside the short-path 301. - * (An intermediate /industries/modern-technology existed on development for - * an hour and never deployed, so it has no redirect.) The pageRegistry row - * keys on path; update it to this path or the WebPage node drops out. + * 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. * * 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. @@ -36,7 +34,8 @@ import { getPageGraph } from '@/lib/seo/compose-page'; * its sibling, so it emits the full Organization + WebSite + WebPage + * BreadcrumbList graph. * - * The breadcrumb is Home > Modern Applications. + * 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 @@ -48,14 +47,14 @@ export const metadata = buildPageMetadata({ absoluteTitle: true, description: 'Secure modern applications with verified software components, hardened container images, and trusted open-source libraries built for faster, safer software delivery.', - path: '/modern-applications', + path: '/industries/modern-applications', eyebrow: 'Solutions', }); export const revalidate = 21600; // 6h ISR fallback — on-demand publish revalidation keeps this fresh export default async function SaasPage(): Promise { - const graph = await getPageGraph('/modern-applications', [ + const graph = await getPageGraph('/industries/modern-applications', [ breadcrumbSchema([{ name: 'Home', path: '/' }, { name: 'Modern Applications' }]), ]); return ( diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index 9015026ef..be29abd5d 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -113,7 +113,7 @@ const STATIC_ROUTES: ReadonlyArray<{ path: string }> = [ { path: '/for-developers' }, { path: '/guide' }, { path: '/industries/financial-services-container-security' }, - { path: '/modern-applications' }, + { 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. diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index a3afb1d2b..5febbecfc 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -173,8 +173,8 @@ export const NAV_TREE: NavItem[] = [ icon: "bank", }, { - label: "Modern Technology", - href: "/modern-applications", + label: "Modern Applications", + href: "/industries/modern-applications", description: "Ship faster on a verified software foundation.", icon: "cloud", }, diff --git a/docs/web/WEB-PAGES.md b/docs/web/WEB-PAGES.md index fd658619c..34d158a63 100644 --- a/docs/web/WEB-PAGES.md +++ b/docs/web/WEB-PAGES.md @@ -72,7 +72,7 @@ page slugs, categories, types, and build status across the dev journey. | 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. | | 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 | Modern Applications (SaaS) | `/modern-applications` | Static | ✅ | URL, title ("Modern Application Security \| CleanStart") and description are the SEO team's, applied verbatim; the H1 is the client's; nav label "Modern Technology" (Solutions › By industry). **Renamed from `/saas` 2026-08-31** (404 in production, no redirect needed) and **from `/industries/saas-container-security` 2026-09-02** before reaching main; that path resolved publicly (noindex) so it 301s in `next.config.ts`, and the short `/saas-container-security` 301 points straight at the final URL. **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 `/modern-applications` in prod or the WebPage node drops out. | +| 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`). | --- From 0dcb7ce952a66ba7273fb8d13807e5fda29e47c7 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 17:25:18 +0530 Subject: [PATCH 70/85] feat(web): give the Modern Applications nav row the stacked-layers glyph A cloud reads as hosting. The layers mark is the one the page's own hero uses for Verified Components, so the menu row and the page share a symbol. --- apps/web/src/components/nav/icons/glyphs.tsx | 9 +++++++++ apps/web/src/lib/nav-config.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/nav/icons/glyphs.tsx b/apps/web/src/components/nav/icons/glyphs.tsx index 8186a8682..f732dddb5 100644 --- a/apps/web/src/components/nav/icons/glyphs.tsx +++ b/apps/web/src/components/nav/icons/glyphs.tsx @@ -150,6 +150,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/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index 5febbecfc..1db927562 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -176,7 +176,7 @@ export const NAV_TREE: NavItem[] = [ label: "Modern Applications", href: "/industries/modern-applications", description: "Ship faster on a verified software foundation.", - icon: "cloud", + icon: "layers", }, ], }, From e737c0f229f0d37db67e0edd9524eb3f8d7f303c Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 17:49:17 +0530 Subject: [PATCH 71/85] feat(web): rename the ROI calculator to Impact Estimator and rebuild the page The page never produced a currency figure. It scores an Operational Burden and reports reductions and recovered hours, so it is now /impact-estimator, with a 308 from /roi-calculator (never indexed: still noindex,nofollow, nav built:false). Page work, in the order it matters: - Sticky inputs beside the results on desktop; a fixed live summary strip on phones while the gauge is below the fold. - Inputs round-trip through the URL (url-state.ts, unit-tested). "Copy link to results" and "Book a demo with these numbers" under the hours card. - "What drives your score" renders the model's contribution breakdown, and the gauge carries its Operational Burden Score caption. - Chain link 1 is "Inherited base-OS packages", so the cut marker is honest. - Impact Estimator eyebrow, tier-aware rail labels, both cadence controls run least-frequent first, defaults moved mid-band (200 images, 40 engineers), hours-per-week stat replaces the duplicated FTE figure. - Two-column KPI grid on phones, 3-up stats that never wrap 2 plus 1, 11px minimum labels, release accent from the tier palette, no em-dashes anywhere in the page's copy or comments. Scoring, bands and the client-owned metric names are untouched; model.test.ts still pins them. New Playwright spec covers link adoption, address-bar sync, invalid-link fallback, clipboard contents and the mobile strip. --- apps/web/next.config.ts | 8 + apps/web/src/app/globals.css | 2 +- apps/web/src/app/impact-estimator/page.tsx | 55 ++ apps/web/src/app/roi-calculator/page.tsx | 55 -- .../ImpactCTA.tsx} | 8 +- .../ImpactHero.tsx} | 24 +- .../ImpactHowItWorks.tsx} | 20 +- .../impact-estimator/ImpactSimulator.tsx | 683 ++++++++++++++++++ .../model.test.ts | 13 +- .../model.ts | 32 +- .../impact-estimator/url-state.test.ts | 76 ++ .../sections/impact-estimator/url-state.ts | 77 ++ .../sections/roi-calculator/RoiSimulator.tsx | 516 ------------- apps/web/src/lib/nav-config.ts | 4 +- apps/web/tests/e2e/impact-estimator.spec.ts | 90 +++ docs/seo/03-onpage-and-metadata.md | 2 +- docs/seo/07-rendering-and-delivery.md | 2 +- docs/web/WEB-PAGES.md | 2 +- 18 files changed, 1051 insertions(+), 618 deletions(-) create mode 100644 apps/web/src/app/impact-estimator/page.tsx delete mode 100644 apps/web/src/app/roi-calculator/page.tsx rename apps/web/src/components/sections/{roi-calculator/RoiCTA.tsx => impact-estimator/ImpactCTA.tsx} (88%) rename apps/web/src/components/sections/{roi-calculator/RoiHero.tsx => impact-estimator/ImpactHero.tsx} (80%) rename apps/web/src/components/sections/{roi-calculator/RoiHowItWorks.tsx => impact-estimator/ImpactHowItWorks.tsx} (87%) create mode 100644 apps/web/src/components/sections/impact-estimator/ImpactSimulator.tsx rename apps/web/src/components/sections/{roi-calculator => impact-estimator}/model.test.ts (96%) rename apps/web/src/components/sections/{roi-calculator => impact-estimator}/model.ts (88%) create mode 100644 apps/web/src/components/sections/impact-estimator/url-state.test.ts create mode 100644 apps/web/src/components/sections/impact-estimator/url-state.ts delete mode 100644 apps/web/src/components/sections/roi-calculator/RoiSimulator.tsx create mode 100644 apps/web/tests/e2e/impact-estimator.spec.ts diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index aec14b868..fd0f16c39 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -89,6 +89,14 @@ const nextConfig: NextConfig = { 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/src/app/globals.css b/apps/web/src/app/globals.css index 30148b1a0..445c0376e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -5822,7 +5822,7 @@ body { } } -/* Estimate Your Outcome page: "compounding chain" (OutcomeHowItWorks). 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..9806f4a5e --- /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 { ImpactCTA } from "@/components/sections/impact-estimator/ImpactCTA"; +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: "Impact Estimator for Hardened Container 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: "/impact-estimator", + 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 ImpactEstimatorPage(): Promise { + const graph = await getPageGraph("/impact-estimator", [ + breadcrumbSchema([ + { name: "Home", path: "/" }, + { name: "Impact Estimator" }, + ]), + ]); + + 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/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/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..6690b6084 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 +

      +
      +

      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 87% rename from apps/web/src/components/sections/roi-calculator/RoiHowItWorks.tsx rename to apps/web/src/components/sections/impact-estimator/ImpactHowItWorks.tsx index 36cf8d405..46bd9e393 100644 --- a/apps/web/src/components/sections/roi-calculator/RoiHowItWorks.tsx +++ b/apps/web/src/components/sections/impact-estimator/ImpactHowItWorks.tsx @@ -2,12 +2,12 @@ import type React from "react"; 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 +15,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,16 +32,16 @@ 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 @@ -49,13 +49,13 @@ export function RoiHowItWorks(): React.ReactElement {
      -

      +

      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 +89,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 +123,7 @@ 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.

    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..82ce7be43 --- /dev/null +++ b/apps/web/src/components/sections/impact-estimator/ImpactSimulator.tsx @@ -0,0 +1,683 @@ +"use client"; + +/* + * The interactive centrepiece of /impact-estimator. A four-part narrative: + * Your environment (inputs) → Operational Burden Score (gauge + breakdown) + * → Expected improvements (KPIs) → Engineering Hours Recovered (+ actions) + * 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 Link from "next/link"; +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 BurdenContribution, + 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 ( + + ); + })} +
    +
    + ); +} + +/* ── score breakdown: which input contributes what to the burden score ── */ +const CONTRIBUTION_UI: ReadonlyArray<{ label: string; color: string }> = [ + { label: "Production images", color: "#471ec0" }, + { label: "Team size", color: "#3960F9" }, + { label: "Remediation frequency", color: "#2cc1eb" }, + { label: "Release cadence", color: "#8b1fc3" }, +]; + +function ScoreBreakdown({ contributions, burden }: { contributions: readonly BurdenContribution[]; burden: number }): React.ReactElement { + const rows = contributions.flatMap((c, i) => { + const ui = CONTRIBUTION_UI[i]; + return ui ? [{ ...c, ...ui }] : []; + }); + const summary = rows.map((r) => `${r.label} ${Math.round(r.points)}`).join(", "); + return ( +
    +
    + What drives your score + {Math.round(burden)} of {BURDEN_SCALE.max} points +
    +
    + {rows.map((r) => ( +
    + ))} +
    +
      + {rows.map((r) => ( +
    • + + {r.label} + {Math.round(r.points)} +
    • + ))} +
    +
    + ); +} + +/* ── result actions: take the numbers somewhere ── */ +function ResultActions({ 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 ( +
    +
    + + Book a demo with these numbers + + +
    +

    + The link carries only these four inputs, so a teammate opens the same result. Nothing else is stored. +

    +
    + ); +} + +/* ── 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); + + 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 ── */} +
    + {/* 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, then what to do with it */} +
    +
    +
    +
    + + {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 */} +

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

    +
    +
    +
    + + +
    + ); +} 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/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index 1db927562..5ec73ca90 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -115,8 +115,8 @@ 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, 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..8490f14bc --- /dev/null +++ b/apps/web/tests/e2e/impact-estimator.spec.ts @@ -0,0 +1,90 @@ +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(); + + await expect(page.locator('[data-section="ImpactSimulator"] a[href^="/book-a-demo"]')).toHaveAttribute( + "href", + "/book-a-demo?images=400&team=120&remediation=Weekly&release=Continuous", + ); + }); + + 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(); + }); +}); 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/web/WEB-PAGES.md b/docs/web/WEB-PAGES.md index 34d158a63..16a609bd8 100644 --- a/docs/web/WEB-PAGES.md +++ b/docs/web/WEB-PAGES.md @@ -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. | --- From 559a0984a6c19d5b4f61853064ac9544a6829ec2 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 17:50:20 +0530 Subject: [PATCH 72/85] feat(web): rebuild the Docker Hardened Images comparison page Rebuilt /compare/cleanstart-vs-docker-hardened-images from scratch against the new SEO source doc ("Docker Hardened Images vs CleanStart - Final"). It replaces the 2026-07-31 build, which was never signed off: the new doc has a different meta title, a five-section outline instead of fourteen, a 20-row matrix grouped into four categories instead of a flat 15-row one, and eight FAQs. Six bands in the site's light/dark rhythm, one per document heading: CompareHero title, standfirst, CTA pair, and a new CompareFoundationStacks diagram drawing the one asymmetry the page turns on: Docker's stack has an inherited parent block, CleanStart's has an empty frame where that parent would be CompareFoundations the two platforms side by side CompareMatrix the capability table CompareBuildFlow both build pipelines on one baseline, on dark CompareDifferentiators three corner tiles on the existing 3D icon set CompareFAQ single-open accordion CompareCTA the footer-slot card The matrix is one real
    (caption, column headers, scope="colgroup" group rows) that reflows to labelled cards below lg rather than shipping a second mobile copy of twenty rows. Its column head is sticky, which is why the card wrapper uses overflow: clip and the section moves overflow-hidden onto its own decoration layer: either one would otherwise have become the scroll container that sticky resolves against. Copy is the document's, verbatim, with one class of edit: two em-dashes became colons per the house writing rule. Heading levels are the document's, shifted one for the page title, giving 1 H1, 6 H2 and 3 H3 plus the eight FAQ questions. Vendor names, matrix group labels and the "focuses on" lead-ins stay non-headings. Every string, the matrix and the FAQ live in compare-data.ts; the matrix counts and the FAQPage JSON-LD both derive from it. CTAs are the stock cs-btn-blue and cs-btn-ghost pair at the sizings SaasHero and PricingHero already use. Still noindex,nofollow and de-listed from app/sitemap.ts pending sign-off. --- .../page.tsx | 69 +- .../sections/compare/CompareBeyondCves.tsx | 180 ---- .../sections/compare/CompareBuildFlow.tsx | 218 +++++ .../sections/compare/CompareBuilds.tsx | 281 ------ .../sections/compare/CompareCTA.tsx | 204 ++-- .../sections/compare/CompareChoose.tsx | 240 ----- .../compare/CompareDifferentiators.tsx | 76 ++ .../sections/compare/CompareFAQ.tsx | 335 ++++--- .../compare/CompareFoundationStacks.tsx | 226 +++++ .../sections/compare/CompareFoundations.tsx | 206 ++++ .../sections/compare/CompareHero.tsx | 107 ++- .../sections/compare/CompareHeroArtifact.tsx | 209 ---- .../sections/compare/CompareIntro.tsx | 146 --- .../sections/compare/CompareMatrix.tsx | 687 ++++++------- .../sections/compare/ComparePhilosophies.tsx | 218 ----- .../sections/compare/CompareProvenance.tsx | 282 ------ .../sections/compare/CompareReadiness.tsx | 309 ------ .../sections/compare/CompareSocialProof.tsx | 170 ---- .../sections/compare/compare-artifacts.tsx | 289 ------ .../sections/compare/compare-data.ts | 903 +++++++++--------- .../sections/compare/compare-editorial.tsx | 136 --- .../sections/compare/compare-visuals.tsx | 18 +- 22 files changed, 1832 insertions(+), 3677 deletions(-) delete mode 100644 apps/web/src/components/sections/compare/CompareBeyondCves.tsx create mode 100644 apps/web/src/components/sections/compare/CompareBuildFlow.tsx delete mode 100644 apps/web/src/components/sections/compare/CompareBuilds.tsx delete mode 100644 apps/web/src/components/sections/compare/CompareChoose.tsx create mode 100644 apps/web/src/components/sections/compare/CompareDifferentiators.tsx create mode 100644 apps/web/src/components/sections/compare/CompareFoundationStacks.tsx create mode 100644 apps/web/src/components/sections/compare/CompareFoundations.tsx delete mode 100644 apps/web/src/components/sections/compare/CompareHeroArtifact.tsx delete mode 100644 apps/web/src/components/sections/compare/CompareIntro.tsx delete mode 100644 apps/web/src/components/sections/compare/ComparePhilosophies.tsx delete mode 100644 apps/web/src/components/sections/compare/CompareProvenance.tsx delete mode 100644 apps/web/src/components/sections/compare/CompareReadiness.tsx delete mode 100644 apps/web/src/components/sections/compare/CompareSocialProof.tsx delete mode 100644 apps/web/src/components/sections/compare/compare-artifacts.tsx delete mode 100644 apps/web/src/components/sections/compare/compare-editorial.tsx 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/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, From c4d998ac2d3f27268a4cc2842b1ecee2662ed989 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 17:50:28 +0530 Subject: [PATCH 73/85] docs(web): restore the Impact Estimator row in WEB-PAGES.md Row 12 went back to reading "ROI Calculator" in e737c0f2. Two sessions were editing this file at once and the losing write happened to be the one that got committed; nothing about the rename itself changed, only its entry in the inventory. Restored verbatim from the version that was on disk before the race. --- docs/web/WEB-PAGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/web/WEB-PAGES.md b/docs/web/WEB-PAGES.md index 16a609bd8..9e01f64ec 100644 --- a/docs/web/WEB-PAGES.md +++ b/docs/web/WEB-PAGES.md @@ -70,7 +70,7 @@ 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 + score breakdown, KPI cards, hours card with copy-link and demo actions, mobile summary strip), How-it's-calculated chain, Footer CTA. Inputs round-trip through the URL via `url-state.ts` (unit-tested; e2e in `tests/e2e/impact-estimator.spec.ts`). Nav leaf added `built:false` under Solutions › Capability, flip to live once verified. 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 | 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`). | From c525b75f221413176ca1f7147c902fe4251dc77c Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 2 Sep 2026 17:56:42 +0530 Subject: [PATCH 74/85] fix(web): make the estimator inputs card actually stick, tighten the scale The simulator section carried overflow-hidden to clip a decorative blob, and an overflow-clipping ancestor turns position: sticky off, so the inputs card never pinned. The blob now lives in its own clipped absolute layer and the section no longer clips. A Playwright test scrolls the row and asserts the pin offset. Scale is brought down a notch across the results column (gauge 240 to 208px, tier heading h2 to h3, KPI and hours figures smaller, tighter card padding and control spacing) after the client found the page too large at 1440. The "What drives your score" breakdown is removed at the client's request; the gauge, tier blurb and tooltip still explain the score. --- .../impact-estimator/ImpactSimulator.tsx | 77 +++++-------------- apps/web/tests/e2e/impact-estimator.spec.ts | 34 ++++++++ docs/web/WEB-PAGES.md | 2 +- 3 files changed, 56 insertions(+), 57 deletions(-) diff --git a/apps/web/src/components/sections/impact-estimator/ImpactSimulator.tsx b/apps/web/src/components/sections/impact-estimator/ImpactSimulator.tsx index 82ce7be43..fb0229c99 100644 --- a/apps/web/src/components/sections/impact-estimator/ImpactSimulator.tsx +++ b/apps/web/src/components/sections/impact-estimator/ImpactSimulator.tsx @@ -2,7 +2,7 @@ /* * The interactive centrepiece of /impact-estimator. A four-part narrative: - * Your environment (inputs) → Operational Burden Score (gauge + breakdown) + * Your environment (inputs) → Operational Burden Score (gauge) * → Expected improvements (KPIs) → Engineering Hours Recovered (+ actions) * Numbers tween smoothly; technical terms carry accessible tooltips. Math and * the client-owned naming both live in ./model.ts; the shareable-link state @@ -28,7 +28,6 @@ import { RELEASE_OPTIONS, TEAM_WEIGHT_THRESHOLDS, TIER_NAMES, - type BurdenContribution, type RoiInput, type TierName, } from "./model"; @@ -159,7 +158,7 @@ function RadialGauge({ progress, tier, burden }: { progress: number; tier: TierN }); return ( -
    +
    @@ -189,7 +188,7 @@ function RadialGauge({ progress, tier, burden }: { progress: number; tier: TierN
    -
    {Math.round(burden)}
    +
    {Math.round(burden)}
    of {BURDEN_SCALE.max}
    @@ -270,7 +269,7 @@ function Slider({ label, tip, value, min, max, step, onChange, context, ticks }: }): React.ReactElement { const pct = ((value - min) / (max - min)) * 100; return ( -
    +
    {label} @@ -316,7 +315,7 @@ function Segmented({ label, tip, options, value, onChange }: { label: string; tip: string; options: readonly T[]; value: T; onChange: (v: T) => void; }): React.ReactElement { return ( -
    +
    {label} @@ -342,44 +341,6 @@ function Segmented({ label, tip, options, value, onChange }: { ); } -/* ── score breakdown: which input contributes what to the burden score ── */ -const CONTRIBUTION_UI: ReadonlyArray<{ label: string; color: string }> = [ - { label: "Production images", color: "#471ec0" }, - { label: "Team size", color: "#3960F9" }, - { label: "Remediation frequency", color: "#2cc1eb" }, - { label: "Release cadence", color: "#8b1fc3" }, -]; - -function ScoreBreakdown({ contributions, burden }: { contributions: readonly BurdenContribution[]; burden: number }): React.ReactElement { - const rows = contributions.flatMap((c, i) => { - const ui = CONTRIBUTION_UI[i]; - return ui ? [{ ...c, ...ui }] : []; - }); - const summary = rows.map((r) => `${r.label} ${Math.round(r.points)}`).join(", "); - return ( -
    -
    - What drives your score - {Math.round(burden)} of {BURDEN_SCALE.max} points -
    -
    - {rows.map((r) => ( -
    - ))} -
    -
      - {rows.map((r) => ( -
    • - - {r.label} - {Math.round(r.points)} -
    • - ))} -
    -
    - ); -} - /* ── result actions: take the numbers somewhere ── */ function ResultActions({ input }: { input: RoiInput }): React.ReactElement { const [copied, setCopied] = useState(false); @@ -533,9 +494,14 @@ export function ImpactSimulator(): React.ReactElement { // 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 ( -
    -
    +
    +
    +
    +