diff --git a/apps/web/public/images/tricorder/hero-intelligence-cube.webp b/apps/web/public/images/tricorder/hero-intelligence-cube.webp deleted file mode 100644 index 0a6397b8d..000000000 Binary files a/apps/web/public/images/tricorder/hero-intelligence-cube.webp and /dev/null differ diff --git a/apps/web/public/images/vulnerability-remediation/trust-icon-hermetic-builds.webp b/apps/web/public/images/vulnerability-remediation/trust-icon-hermetic-builds.webp new file mode 100644 index 000000000..80ea0b202 Binary files /dev/null and b/apps/web/public/images/vulnerability-remediation/trust-icon-hermetic-builds.webp differ diff --git a/apps/web/public/images/vulnerability-remediation/trust-icon-source-built.webp b/apps/web/public/images/vulnerability-remediation/trust-icon-source-built.webp new file mode 100644 index 000000000..56ae22aa0 Binary files /dev/null and b/apps/web/public/images/vulnerability-remediation/trust-icon-source-built.webp differ diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index f4b8aebb4..828f79474 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -6433,6 +6433,68 @@ body { } } +/* Tricorder page — the hero verdict loop (TricorderHeroVerdict). Dashes flow + along each input stream toward the package, and a scan line passes over the + package once per stage (the element is re-keyed per stage, so it restarts). + The loop itself is JS-driven and stops under reduced motion; these are off + there too. */ +@keyframes cs-tri-stream { + to { + stroke-dashoffset: -20; + } +} +@keyframes cs-tri-scanline { + from { + transform: translateY(0); + opacity: 0; + } + 15% { + opacity: 1; + } + 85% { + opacity: 1; + } + to { + transform: translateY(132px); + opacity: 0; + } +} +@keyframes cs-tri-orbit { + to { + transform: rotate(360deg); + } +} +@keyframes cs-tri-intel-pulse { + from { + transform: scale(1); + opacity: 0.9; + } + to { + transform: scale(2.6); + opacity: 0; + } +} +.cs-tri-stream { + animation: cs-tri-stream 0.8s linear infinite; +} +.cs-tri-orbit { + animation: cs-tri-orbit 9s linear infinite; +} +.cs-tri-intel-pulse { + animation: cs-tri-intel-pulse 0.9s cubic-bezier(0.16, 1, 0.3, 1) both; +} +.cs-tri-scanline { + animation: cs-tri-scanline 0.85s ease-in-out both; +} +@media (prefers-reduced-motion: reduce) { + .cs-tri-stream, + .cs-tri-scanline, + .cs-tri-orbit, + .cs-tri-intel-pulse { + animation: none; + } +} + /* Vulnerability Remediation hero: "remediate at the source" panorama (VulnHeroScene.tsx). CVE chips ride `--dx`/`--dy` into the gate and a `cs-vuln-flash` with the same duration and delay fires as each one lands. diff --git a/apps/web/src/components/sections/events/UpcomingEventCarousel.tsx b/apps/web/src/components/sections/events/UpcomingEventCarousel.tsx index bfe7a8d38..b3dc78da1 100644 --- a/apps/web/src/components/sections/events/UpcomingEventCarousel.tsx +++ b/apps/web/src/components/sections/events/UpcomingEventCarousel.tsx @@ -1,8 +1,9 @@ "use client"; -import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react"; +import { LazyMotion, domAnimation, m } from "motion/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { EASE_OUT } from "@/lib/motion"; +import { useHydratedReducedMotion } from "@/lib/use-hydrated-reduced-motion"; const AUTOPLAY_MS = 6500; const SWIPE_THRESHOLD = 48; @@ -38,7 +39,7 @@ export function UpcomingEventCarousel({ // +1 = advancing forward (slide in from the right), -1 = going back. const [direction, setDirection] = useState(1); const [paused, setPaused] = useState(false); - const reduceMotion = useReducedMotion(); + const reduceMotion = useHydratedReducedMotion(); const go = useCallback( (next: number, dir: number) => { diff --git a/apps/web/src/components/sections/financial-services/FinanceRiskChain.tsx b/apps/web/src/components/sections/financial-services/FinanceRiskChain.tsx index 01a429013..cceb021a7 100644 --- a/apps/web/src/components/sections/financial-services/FinanceRiskChain.tsx +++ b/apps/web/src/components/sections/financial-services/FinanceRiskChain.tsx @@ -2,8 +2,8 @@ import type React from 'react'; import { useEffect, useRef, useState } from 'react'; -import { useReducedMotion } from 'motion/react'; import { Reveal, RevealStagger, RevealItem } from '@/components/ui/Reveal'; +import { useHydratedReducedMotion } from '@/lib/use-hydrated-reduced-motion'; /* * "Risk Enters Long Before Production" — a continuous supply-chain scan. @@ -1520,7 +1520,7 @@ function ScanRail({ /* -------------------------------------------------------------------------- */ export function FinanceRiskChain(): React.ReactElement { - const reduceMotion = useReducedMotion(); + const reduceMotion = useHydratedReducedMotion(); const animated = !reduceMotion; const { beamRef, scan } = useScanClock(animated); diff --git a/apps/web/src/components/sections/home/security/SecurityDiagram.tsx b/apps/web/src/components/sections/home/security/SecurityDiagram.tsx index bf54abdad..4e43cf663 100644 --- a/apps/web/src/components/sections/home/security/SecurityDiagram.tsx +++ b/apps/web/src/components/sections/home/security/SecurityDiagram.tsx @@ -1,9 +1,10 @@ "use client"; import { Fragment, useEffect, useRef, useState } from "react"; -import { useInView, useReducedMotion } from "motion/react"; +import { useInView } from "motion/react"; import { cn } from "@/lib/cn"; +import { useHydratedReducedMotion } from "@/lib/use-hydrated-reduced-motion"; import { FlowArrow } from "@/components/ui/FlowArrow"; import { ShieldCheck } from "./ShieldCheck"; import { VerifyBeams } from "./VerifyBeams"; @@ -174,7 +175,7 @@ function GuaranteeCard({ export function SecurityDiagram(): React.ReactElement { const ref = useRef(null); - const reduce = useReducedMotion(); + const reduce = useHydratedReducedMotion(); const inView = useInView(ref, { margin: "0px 0px -80px 0px", amount: 0.05 }); const [started, setStarted] = useState(false); const [hovered, setHovered] = useState(null); diff --git a/apps/web/src/components/sections/tricorder/TricorderContext.tsx b/apps/web/src/components/sections/tricorder/TricorderContext.tsx index 71d9d22a1..e0269c4f4 100644 --- a/apps/web/src/components/sections/tricorder/TricorderContext.tsx +++ b/apps/web/src/components/sections/tricorder/TricorderContext.tsx @@ -355,7 +355,7 @@ function RelationshipsContent({ accent }: { accent: string }): React.ReactElemen }} /> ) : null} -
+
{item.label}
@@ -466,11 +466,14 @@ function VerdictCard(): React.ReactElement { /* ── Desktop scene: fixed 1320 × 538 canvas, scaled to fit ───────────────── */ const VB = { w: 1320, h: 538 } as const; -/** Panel geometry. Widths differ because the doc's three panels carry different loads. */ +/** + * Panel geometry: equal thirds, so the Behavior panel is centred on CARD_X and + * its current drops straight through the junction into the component. + */ const PANELS = { - history: { x: 0, w: 360 }, - behavior: { x: 386, w: 440 }, - relationships: { x: 852, w: 468 }, + history: { x: 0, w: 424 }, + behavior: { x: 448, w: 424 }, + relationships: { x: 896, w: 424 }, } as const; const PANEL_Y = 0; const PANEL_H = 200; diff --git a/apps/web/src/components/sections/tricorder/TricorderHero.tsx b/apps/web/src/components/sections/tricorder/TricorderHero.tsx index aebc2a657..b18c72548 100644 --- a/apps/web/src/components/sections/tricorder/TricorderHero.tsx +++ b/apps/web/src/components/sections/tricorder/TricorderHero.tsx @@ -1,16 +1,18 @@ -import Image from 'next/image'; import Link from 'next/link'; import { HeroReveal } from '@/components/ui/Reveal'; +import { TricorderHeroVerdict } from './TricorderHeroVerdict'; /** * Tricorder hero, on the solution-page hero shell (FinanceHero / SaasHero): * bg-cs-hero mesh, gridline overlay, left-aligned copy with the gradient - * accent, a 3D artifact on the right and a fade into the white section below. + * accent and an artifact on the right. No bottom fade: the hero ends cleanly + * on its gradient. * - * The artifact is the copy doc's own hero concept: the intelligence brain in a - * glass cube on a pedestal. The render was produced on black and matted to a - * real alpha channel, so it composites straight onto the gradient. + * The artifact is the verdict loop (TricorderHeroVerdict), built in code: one + * package examined by the four stages and judged before it is trusted. It is + * decorative, so phones drop it rather than stack it under the headline, as + * the other solution heroes do. */ export function TricorderHero(): React.ReactElement { return ( @@ -27,20 +29,9 @@ export function TricorderHero(): React.ReactElement { />
- -
@@ -57,7 +48,7 @@ export function TricorderHero(): React.ReactElement { }} > The Intelligence Layer{' '} - for Software Trust + for Software Trust @@ -101,31 +92,9 @@ export function TricorderHero(): React.ReactElement { y={40} delay={0.2} duration={1.0} - className="mx-auto w-full max-w-[560px] lg:max-w-[640px]" + className="mx-auto hidden w-full max-w-[440px] md:block lg:max-w-[520px]" > -
- {/* Halo, so the render sits in light rather than on flat navy. */} -
-
- A glowing glass cube holding a circuit-trace brain on a glass pedestal, surrounded by floating security panels -
-
+
diff --git a/apps/web/src/components/sections/tricorder/TricorderHeroVerdict.tsx b/apps/web/src/components/sections/tricorder/TricorderHeroVerdict.tsx new file mode 100644 index 000000000..f1b696185 --- /dev/null +++ b/apps/web/src/components/sections/tricorder/TricorderHeroVerdict.tsx @@ -0,0 +1,447 @@ +'use client'; + +import { ShieldAlert, ShieldCheck, ShieldQuestionMark, type LucideIcon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import { SIGNAL, VERDICT } from './tricorder-palette'; + +/** + * Tricorder hero artifact: one dependency, judged before it is trusted. + * + * The loop tells the product's story in four beats: + * 1. ARRIVE: a neutral package settles at the centre. + * 2. EXAMINE: four inputs feed it in pipeline order, each stage reading its + * own signal: Analyze reads behaviour (teal), Compare reads history (blue), + * Correlate reads relationships (violet), Enrich brings threat + * intelligence (amber). A scan line passes over the package per stage and + * the ring quarter facing that input fills. + * 3. DECIDE: the ring resolves into one verdict, shown as colour AND shape + * (shield-alert, shield-question, shield-check) so it survives greyscale + * and small sizes. + * 4. RESET: the package leaves and the next one arrives. + * + * Passes cycle Malicious, Uncertain, Pass, so the first thing a visitor sees + * is a package rejected. The artifact never rests on Pass: the page's argument + * is that "safe" cannot be assumed. + * + * The only words are the four stage names, which are the copy doc's. + * + * Deliberately not a radar (that reads as runtime monitoring, CleanSight's job) + * and deliberately free of the Isolation section's labelled icons, so that + * section keeps its explanation. Before hydration, under reduced motion and + * off screen the loop does not run; the still frame shows the package, all + * four inputs and the three outcomes with equal weight. + * + * Everything is drawn in one 400 x 400 SVG space; the verdict badge is HTML + * positioned in percent of the same square. + */ + +const C = 200; +const RING_R = 100; +const SOURCE_R = 172; +/** Half-width of a stage's ring quarter, leaving a small gap between quarters. */ +const SEG_HALF = 40; + +interface Input { + color: string; + /** Degrees clockwise from 12 o'clock. */ + angle: number; + /** The copy doc's stage name, the only words in the artifact. */ + label: string; + /** + * Threat intelligence is the one input a CVE scanner does not have, so its + * source is drawn larger, carries an orbit and fires a pulse when it feeds. + */ + external?: boolean; +} + +/** Pipeline order: Analyze, Compare, Correlate, Enrich. */ +const INPUTS: readonly [Input, Input, Input, Input] = [ + { color: SIGNAL.behavior, angle: 315, label: 'Analyze' }, + { color: SIGNAL.history, angle: 45, label: 'Compare' }, + { color: SIGNAL.relationships, angle: 135, label: 'Correlate' }, + { color: SIGNAL.intel, angle: 225, label: 'Enrich', external: true }, +]; + +interface Outcome { + color: string; + icon: LucideIcon; +} + +const OUTCOMES: readonly [Outcome, Outcome, Outcome] = [ + { color: VERDICT.malicious, icon: ShieldAlert }, + { color: VERDICT.uncertain, icon: ShieldQuestionMark }, + { color: VERDICT.pass, icon: ShieldCheck }, +]; + +/** Beat durations: arrive, four inputs, decide, reset. */ +const STEP_MS = [700, 900, 900, 900, 900, 1900, 550] as const; +const DECIDE = 5; +const RESET = 6; + +const NEUTRAL = '#8d94c4'; +/** + * Examination colour. The ring and cube never take an input's colour, because + * the signal palette overlaps the verdict palette (intel and uncertain are both + * amber, behavior and pass both teal): verdict colours belong to the decision. + */ +const EXAMINE = '#8fb6ff'; + +const round = (n: number): number => Math.round(n * 100) / 100; + +function polar(r: number, angle: number): { x: number; y: number } { + const rad = (angle * Math.PI) / 180; + return { x: round(C + r * Math.sin(rad)), y: round(C - r * Math.cos(rad)) }; +} + +/** A stream curving from its source into the middle of its own ring quarter. */ +function streamPath(angle: number): string { + const s = polar(SOURCE_R, angle); + const ctrl = polar(142, angle - 14); + const e = polar(RING_R + 5, angle); + return `M ${s.x} ${s.y} Q ${ctrl.x} ${ctrl.y} ${e.x} ${e.y}`; +} + +function arc(r: number, from: number, to: number): string { + const a = polar(r, from); + const b = polar(r, to); + return `M ${a.x} ${a.y} A ${r} ${r} 0 0 1 ${b.x} ${b.y}`; +} + +interface LoopState { + step: number; + outcome: number; + animated: boolean; +} + +function useVerdictLoop(target: React.RefObject): LoopState { + const [state, setState] = useState({ step: 0, outcome: 0, animated: false }); + + useEffect(() => { + const node = target.current; + if (!node) return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + let timer: number | undefined; + let visible = false; + let current: LoopState = { step: 0, outcome: 0, animated: true }; + + const schedule = (): void => { + timer = window.setTimeout(() => { + timer = undefined; + const step = (current.step + 1) % STEP_MS.length; + current = { + step, + outcome: step === 0 ? (current.outcome + 1) % OUTCOMES.length : current.outcome, + animated: true, + }; + setState(current); + if (visible) schedule(); + }, STEP_MS[current.step]); + }; + + const observer = new IntersectionObserver( + ([entry]) => { + visible = entry?.isIntersecting ?? false; + if (visible && timer === undefined) { + setState(current); + schedule(); + } else if (!visible && timer !== undefined) { + window.clearTimeout(timer); + timer = undefined; + } + }, + { threshold: 0.2 }, + ); + observer.observe(node); + return () => { + observer.disconnect(); + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [target]); + + return state; +} + +/** Isometric package: three shaded faces and a tape band across the top. */ +function PackageCube({ tint, glow }: { tint: string; glow: string | null }): React.ReactElement { + const face = (mix: string): React.CSSProperties => ({ fill: mix, transition: 'fill 500ms ease' }); + return ( + + + + + + + + + + + ); +} + +export function TricorderHeroVerdict(): React.ReactElement { + const root = useRef(null); + const { step, outcome, animated } = useVerdictLoop(root); + + const verdict = OUTCOMES[outcome] ?? OUTCOMES[0]; + const activeInput = animated && step >= 1 && step <= 4 ? step - 1 : -1; + const deciding = animated && step === DECIDE; + const resetting = animated && step === RESET; + const cubeTint = deciding ? `color-mix(in srgb, ${verdict.color} 70%, ${NEUTRAL})` : NEUTRAL; + const cubeGlow = deciding ? verdict.color : activeInput >= 0 ? EXAMINE : null; + const VerdictIcon = verdict.icon; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + {/* The four inputs, in pipeline order. */} + {INPUTS.map((input, i) => { + const active = activeInput === i; + const lit = !animated || active; + const src = polar(SOURCE_R, input.angle); + const d = streamPath(input.angle); + const core = input.external ? (active ? 12 : 10) : active ? 9 : 7; + return ( + + + + + {input.external ? ( + <> + + {active ? ( + + ) : null} + + ) : null} + + + + ); + })} + + {/* Verdict ring: four quarters, one per stage, each centred on where + its stream lands. A quarter fills during its own stage and stays + lit; at the decision all four take the verdict colour. */} + + {animated + ? INPUTS.map((input, i) => { + const filled = deciding || (step >= i + 1 && step <= INPUTS.length); + const stepActive = activeInput === i; + return ( + + ); + }) + : OUTCOMES.map((o, i) => ( + + ))} + + + + {activeInput >= 0 ? ( + + + + ) : null} + + + + {/* Stage names, set outward from each source. The artifact only renders + from md up, where caption type is legible. */} + {INPUTS.map((input, i) => { + const src = polar(SOURCE_R, input.angle); + const top = src.y < C; + const active = activeInput === i; + return ( + + {input.label} + + ); + })} + + {/* The verdict: colour and shape, so it reads in greyscale and at phone size. */} + + + +
+ ); +} diff --git a/apps/web/src/components/sections/tricorder/TricorderThreatGap.tsx b/apps/web/src/components/sections/tricorder/TricorderThreatGap.tsx index e5204bc55..617394819 100644 --- a/apps/web/src/components/sections/tricorder/TricorderThreatGap.tsx +++ b/apps/web/src/components/sections/tricorder/TricorderThreatGap.tsx @@ -40,7 +40,7 @@ export function TricorderThreatGap(): React.ReactElement { return (
diff --git a/apps/web/src/components/sections/vulnerability-remediation/VulnVerificationTrust.tsx b/apps/web/src/components/sections/vulnerability-remediation/VulnVerificationTrust.tsx index fee6cd164..ecb59bc69 100644 --- a/apps/web/src/components/sections/vulnerability-remediation/VulnVerificationTrust.tsx +++ b/apps/web/src/components/sections/vulnerability-remediation/VulnVerificationTrust.tsx @@ -8,7 +8,8 @@ import { Reveal, RevealItem, RevealStagger } from "@/components/ui/Reveal"; * the Lifecycle section above: that one is a red loop that never ends, this * one is a straight verified line with a pulse travelling along it. * - * Icons are the site's violet 3D family (compare + ASR sets), not line glyphs. + * Icons are the site's violet 3D family, not line glyphs: four from the compare + * set plus two generated to match it (Source Built, Hermetic Builds). * Desktop runs the chain horizontally across six columns; below `lg` it turns * into a vertical timeline with the rail on the left. The travelling pulse is * transform-only and stops under reduced motion (see `cs-vr-rail-*` in @@ -25,12 +26,12 @@ const ATTRIBUTES: readonly Attribute[] = [ { title: "Source Built", body: "Built from source to improve transparency and reduce inherited risk.", - icon: "/images/compare/icon-origin.webp", + icon: "/images/vulnerability-remediation/trust-icon-source-built.webp", }, { title: "Hermetic Builds", body: "Isolated builds ensure consistency, security, and repeatability.", - icon: "/images/attack-surface-reduction/approach-icon-secure.webp", + icon: "/images/vulnerability-remediation/trust-icon-hermetic-builds.webp", }, { title: "Verified Provenance", diff --git a/apps/web/src/components/ui/Reveal.tsx b/apps/web/src/components/ui/Reveal.tsx index 8ceb159df..822bd4508 100644 --- a/apps/web/src/components/ui/Reveal.tsx +++ b/apps/web/src/components/ui/Reveal.tsx @@ -5,8 +5,8 @@ import { domAnimation, m, useInView, - useReducedMotion, type HTMLMotionProps, + type Variants, } from "motion/react"; import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react"; @@ -17,9 +17,19 @@ import { staggerChild, staggerParent, } from "@/lib/motion"; +import { useHydratedReducedMotion } from "@/lib/use-hydrated-reduced-motion"; type RevealViewport = typeof REVEAL_VIEWPORT | typeof HEADER_VIEWPORT; +const INSTANT = { duration: 0 } as const; + +const instantStaggerParent: Variants = { hidden: {}, visible: {} }; + +const instantStaggerChild: Variants = { + hidden: { opacity: 0, y: 24 }, + visible: { opacity: 1, y: 0, transition: INSTANT }, +}; + /** * Fail-open in-view detection. Drives reveals off `useInView`, but if the * IntersectionObserver hasn't reported an element that is *already* within the @@ -68,7 +78,9 @@ function useRevealInView( * - `as` — element to render (default `div`); pass `"span"` for inline * - `header` — use the section-header viewport margin (-80px instead of -60px) * - * Respects `prefers-reduced-motion` — renders an unwrapped element. + * Respects `prefers-reduced-motion`: the content snaps to its final state + * after hydration instead of animating. The element tree stays the same so the + * server markup and the hydration render match. */ interface RevealProps extends Omit, "children"> { children: ReactNode; @@ -86,17 +98,10 @@ export function Reveal({ header = false, ...rest }: RevealProps) { - const reduce = useReducedMotion(); + const reduce = useHydratedReducedMotion(); const ref = useRef(null); const show = useRevealInView(ref, header ? HEADER_VIEWPORT : REVEAL_VIEWPORT); - - if (reduce) { - return ( -
)}> - {children} -
- ); - } + const visible = reduce || show; // `useInView` returns false on the server and first client render, so the // SSR markup matches the client's initial state (no hydration mismatch). @@ -106,8 +111,8 @@ export function Reveal({ {children} @@ -189,25 +194,17 @@ export function RevealStagger({ header = false, ...rest }: RevealStaggerProps) { - const reduce = useReducedMotion(); + const reduce = useHydratedReducedMotion(); const ref = useRef(null); const show = useRevealInView(ref, header ? HEADER_VIEWPORT : REVEAL_VIEWPORT); - if (reduce) { - return ( -
)}> - {children} -
- ); - } - return ( {children} @@ -222,19 +219,11 @@ interface RevealItemProps extends Omit, "children"> { } export function RevealItem({ children, ...rest }: RevealItemProps) { - const reduce = useReducedMotion(); - - if (reduce) { - return ( -
)}> - {children} -
- ); - } + const reduce = useHydratedReducedMotion(); return ( - + {children} diff --git a/apps/web/src/lib/use-hydrated-reduced-motion.ts b/apps/web/src/lib/use-hydrated-reduced-motion.ts new file mode 100644 index 000000000..efe39048a --- /dev/null +++ b/apps/web/src/lib/use-hydrated-reduced-motion.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +function subscribe(onChange: () => void): () => void { + const mql = window.matchMedia(REDUCED_MOTION_QUERY); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); +} + +function getSnapshot(): boolean { + return window.matchMedia(REDUCED_MOTION_QUERY).matches; +} + +function getServerSnapshot(): boolean { + return false; +} + +/** + * Hydration-safe reduced-motion flag. Use it instead of motion's + * `useReducedMotion` whenever the value changes render output. + * + * motion's hook reads the media query during the first client render, so a + * reduced-motion visitor hydrates different markup than the server sent. React + * then logs a mismatch, or for structural differences discards the server HTML + * and re-renders the whole Suspense boundary on the client. This hook hydrates + * with the server snapshot (`false`) and re-renders with the real value + * straight after, so SSR and the hydration render always agree. + */ +export function useHydratedReducedMotion(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +}