Files
doormile_react/src/components/sections/PlatformCapabilities.tsx
2026-07-30 20:30:26 +05:30

1122 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
/* ============================================================
PLATFORM CAPABILITIES — Solutions page
A Logico-style horizontal CARD carousel: multiple rounded image cards on a
sliding track, three visible at a time on desktop. Each card is a full-bleed
operational photograph under a graded scrim, with glassmorphic UI widgets
composited on top, plus a title, one-line description and a CTA — the point
being that a visitor reads "real logistics platform", not "concept art".
Placement: this renders INSIDE the Miles3 dark card (as its `children`),
directly below the First/Mid/Last Mile grid — so the whole thing reads as
ONE continuous "What We Offer" section, exactly like the Logico reference
where the feature cards sit underneath the logistics overview. It is
therefore transparent (no inset card of its own) and inherits the dark
#1F1F1F surface from the card above.
Everything is scoped to `#pcap-root`; nothing here touches the surrounding
`.sol-*` / `.elementor-*` styles.
============================================================ */
/* ---------------------------------------------------------------
Icons
--------------------------------------------------------------- */
function Arrow({ dir }: { dir: "prev" | "next" }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{dir === "next" ? <path d="M5 12h13M12.5 5.5L19 12l-6.5 6.5" /> : <path d="M19 12H6M11.5 5.5L5 12l6.5 6.5" />}
</svg>
);
}
/* ---------------------------------------------------------------
Slide data
--------------------------------------------------------------- */
type Capability = {
id: string;
title: string;
/** One-line description shown on the card face. */
blurb: string;
desc: string;
benefits: string[];
cta: { label: string; href: string };
/**
* Full-bleed operational photograph behind the glass overlays. Every shot is
* a real Doormile scene from the project's own library — deliberately none of
* the images already used elsewhere on /solutions, so no photo repeats within
* the page. `pos` is object-position, tuned per image for the 63:90 crop.
*/
photo: {
src: string;
alt: string;
pos?: string;
/**
* How many card-widths of source pixels `object-fit: cover` actually needs.
* The cards are portrait (63:90) but most of these photographs are
* landscape, so cover scales them to match the card's HEIGHT and the
* intrinsic width required is `cardHeight × sourceAspect` — up to 3.6× the
* card's own width. Feeding next/image a plain "31vw" would hand back a
* 468px file that then gets upscaled 3.5× and looks soft, so `sizes` is
* derived from this instead. Value = (90/63) × (sourceW / sourceH).
*/
cover: number;
};
/** Glass UI widgets composited over the photograph. */
ui: {
live: string;
metric: string;
metricLabel: string;
rows?: { k: string; v: string }[];
bars?: number[];
/** Thin progress meter under the figures. */
progress?: { label: string; value: number };
/** Small confirmation line closing the panel. */
stamp?: string;
note?: string;
};
};
const CAPS: Capability[] = [
{
id: "route",
title: "AI Route Optimization",
blurb:
"The best route, planned before the driver ever pulls out of the depot.",
desc:
"MileTruth™ plans every run before the day starts — sequencing drops, respecting capacity and reading traffic, so the route the driver gets is already the best one available.",
benefits: [
"Multi-stop sequencing solved in milliseconds, not on a whiteboard",
"Live traffic and road conditions folded back into the plan mid-trip",
"Capacity and SLA constraints respected on every candidate route",
"42% average distance saved against manually planned runs",
],
cta: { label: "See how MileTruth™ plans", href: "/miletruth" },
photo: {
src: "/images/miletruth-bg.webp",
alt: "Doormile trucks running an expressway corridor, seen from above",
pos: "50% 50%",
cover: 3.6,
},
ui: {
live: "ROUTE SOLVER",
metric: "45ms",
metricLabel: "Plan latency",
rows: [
{ k: "Candidates evaluated", v: "47" },
{ k: "Chosen route", v: "4B" },
{ k: "Distance saved", v: "42%" },
],
stamp: "Route 4B locked",
},
},
{
id: "pickup",
title: "Smart Pickup Management",
blurb:
"Every pickup booked, batched and sent to the nearest available unit.",
desc:
"Pickups are booked, batched and assigned to the nearest available unit automatically — no phone tree, no guessing who is closest to your dock.",
benefits: [
"Nearest-unit assignment with skill and vehicle matching",
"Pickup windows confirmed to the shipper before the driver moves",
"Geofenced arrival and departure, timestamped on the record",
"Failed-pickup exceptions escalated the moment they happen",
],
cta: { label: "Book a pickup demo", href: "/contact" },
photo: {
src: "/images/card%203.png",
alt: "Doormile crew loading a truck at a dispatch dock",
pos: "50% 50%",
cover: 1.8,
},
ui: {
live: "DISPATCH QUEUE",
metric: "4 min",
metricLabel: "ETA to origin",
rows: [
{ k: "Driver", v: "#481 · EV Van" },
{ k: "Distance", v: "1.2 km" },
{ k: "Window", v: "09:00 10:00" },
],
stamp: "Unit #481 assigned",
},
},
{
id: "fleet",
title: "Fleet Intelligence",
blurb:
"Position, load, charge and health for every vehicle on one live canvas.",
desc:
"Every vehicle reports position, load and health continuously, so utilisation, charge state and maintenance stop being a monthly spreadsheet exercise.",
benefits: [
"Live position, speed and load for every unit on one canvas",
"Charge and range planned into the day before the vehicle leaves",
"Utilisation and idle time surfaced per vehicle, not per depot",
"Maintenance flags raised from telemetry, ahead of a breakdown",
],
cta: { label: "Explore fleet intelligence", href: "/miletruth" },
photo: {
src: "/images/mid-mile-approach.webp",
alt: "A line of Doormile vans staged at a mid-mile hub",
pos: "50% 55%",
cover: 1.45,
},
ui: {
live: "FLEET TELEMETRY",
metric: "94%",
metricLabel: "Capacity fill",
bars: [46, 72, 58, 88, 64, 94, 76],
progress: { label: "Charge across fleet", value: 78 },
stamp: "GPS · 128 units reporting",
note: "Illustrative console data",
},
},
{
id: "warehouse",
title: "Warehouse Coordination",
blurb:
"Dock time booked against the plan the road is already running on.",
desc:
"Inbound and outbound are sequenced against the same plan the road runs on, so dock time is booked rather than queued for and nothing waits on a phone call.",
benefits: [
"Dock slots allocated against the actual arrival plan",
"Inbound manifests matched and validated before the truck lands",
"Put-away and pick lists driven off the live order record",
"Cross-dock handovers tracked as one movement, not two",
],
cta: { label: "Talk to our team", href: "/contact" },
photo: {
src: "/images/card6.png",
alt: "Sorting floor of a Doormile fulfilment centre",
pos: "50% 50%",
cover: 1.8,
},
ui: {
live: "DOCK SCHEDULE",
metric: "0",
metricLabel: "Unplanned waits",
rows: [
{ k: "Bay 03 · Inbound", v: "11:40" },
{ k: "Bay 07 · Cross-dock", v: "12:15" },
{ k: "Bay 09 · Outbound", v: "13:05" },
],
stamp: "Shift tasks 82% complete",
},
},
{
id: "control",
title: "Live Operations Control",
blurb:
"One control-room view — the trips at risk find you, not the other way round.",
desc:
"One control room view of every shipment in motion. Exceptions come to you — you are alerted to the three trips at risk, not the fourteen hundred that are fine.",
benefits: [
"Every order, driver and shipment on a single live map",
"Delay risk flagged around 30 minutes before it becomes a breach",
"3x faster response to exceptions than reactive dispatch",
"One source of status for ops, sales and the customer",
],
cta: { label: "See it on your operation", href: "/contact" },
photo: {
src: "/images/first-mile-approach.webp",
alt: "Doormile dispatch area with a live operations wall",
pos: "34% 50%",
cover: 1.45,
},
ui: {
live: "CONTROL ROOM",
metric: "3",
metricLabel: "Trips at risk",
rows: [
{ k: "Corridor E-3", v: "68% risk" },
{ k: "Corridor S-2", v: "34% risk" },
{ k: "Corridor N-1", v: "22% risk" },
],
stamp: "2 alerts acknowledged",
note: "Illustrative console data",
},
},
{
id: "pod",
title: "Proof of Delivery",
blurb:
"OTP, signature and photo at the door, in billing the same second.",
desc:
"Delivery is confirmed at the door with OTP, signature and photo — and that proof reaches billing the same second, not three days later in a paper envelope.",
benefits: [
"OTP-verified handover with digital signature and photo capture",
"Proof attached to the shipment record, permanently auditable",
"Billing triggered on delivery, so invoicing runs same-day",
"Disputes answered with the timestamped record, not recollection",
],
cta: { label: "See digital POD", href: "/contact" },
photo: {
src: "/images/last-mile-approach.webp",
alt: "Doormile driver handing a parcel to a customer at the door",
pos: "50% 50%",
cover: 1.45,
},
ui: {
live: "POD CAPTURE",
metric: "99.2%",
metricLabel: "On-time delivery",
rows: [
{ k: "OTP", v: "Verified" },
{ k: "Signature", v: "Captured" },
{ k: "Pushed to billing", v: "Instant" },
],
stamp: "Delivered 14:22 · signed",
},
},
{
id: "cx",
title: "Customer Experience",
blurb:
"Your customer watches the same live link your control room does.",
desc:
"Your customer watches the same live link your control room is watching. The status call stops happening because the answer is already on their screen.",
benefits: [
"One shared tracking link, from first mile to the door",
"ETAs recalculated live and pushed, not requested",
"Proactive delay notice before the customer notices",
"Branded tracking that stays yours end to end",
],
cta: { label: "Improve your CX", href: "/contact" },
photo: {
src: "/images/workflow4.png",
alt: "Customer following a Doormile delivery on a live tracking link",
pos: "50% 50%",
cover: 2.8,
},
ui: {
live: "CUSTOMER TRACKING",
metric: "14:22",
metricLabel: "Live ETA",
rows: [
{ k: "Link status", v: "Customer viewing" },
{ k: "ETA updates", v: "Automatic" },
{ k: "Status calls", v: "0" },
],
stamp: "★ 4.9 satisfaction",
},
},
{
id: "analytics",
title: "Analytics & Reports",
blurb:
"Cost, SLA and utilisation straight from the records the operation ran on.",
desc:
"Cost per kilometre, SLA performance and utilisation come out of the same records the operation ran on — so the monthly review argues about decisions, not about numbers.",
benefits: [
"Cost per km, per route, per client — from live operational data",
"SLA and on-time performance tracked continuously, not sampled",
"Emissions and EV utilisation ready for sustainability reporting",
"Scheduled exports and API access into your own BI stack",
],
cta: { label: "See the reporting layer", href: "/miletruth" },
photo: {
src: "/images/blog-post-pic-15.webp",
alt: "A line haul fleet staged overnight before dispatch",
pos: "50% 45%",
cover: 1,
},
ui: {
live: "PERFORMANCE",
metric: "40%",
metricLabel: "Manual tasks removed",
bars: [38, 52, 47, 66, 71, 84, 92],
progress: { label: "SLA attainment", value: 96 },
stamp: "Q3 · exported to your BI",
note: "Illustrative console data",
},
},
];
/* ---------------------------------------------------------------
Glass UI overlays
The cards are photographs now, not drawings. Each one carries a small set of
glassmorphic widgets — a live pill, a metric panel, a confirmation line —
composited over the image so the card reads as "software running on a real
operation" rather than as concept art. Every value comes from the slide's
own `ui` block; nothing here is decorative-only.
--------------------------------------------------------------- */
function CapOverlay({ index }: { index: number }) {
const { ui } = CAPS[index];
const bars = ui.bars;
return (
<span className="pcap-ui" aria-hidden="true">
{/* Routing is the one card that earns a drawn element: the chosen line
tracing the corridor in the photograph underneath it. */}
{index === 0 && (
<svg className="pg-route" viewBox="0 0 100 140" preserveAspectRatio="none">
<path className="pg-route-base" d="M12 132 C 34 104, 20 74, 44 56 S 80 32, 92 6" />
<path className="pg-route-live" d="M12 132 C 34 104, 20 74, 44 56 S 80 32, 92 6" />
</svg>
)}
<span className="pg-top">
{index === 0 && <span className="pg-chip pg-chip--ai">AI</span>}
<span className="pg-live">
<i />
{ui.live}
</span>
</span>
<span className="pg-panel">
<span className="pg-metric">
<b>{ui.metric}</b>
<em>{ui.metricLabel}</em>
</span>
{ui.rows && (
<span className="pg-rows">
{ui.rows.map((r) => (
<span className="pg-row" key={r.k}>
<span className="pg-k">{r.k}</span>
<span className="pg-v">{r.v}</span>
</span>
))}
</span>
)}
{bars && (
<span className="pg-bars">
{bars.map((b, i) => (
<i
key={i}
className={i === bars.length - 1 ? "is-hi" : undefined}
style={{ height: `${b}%` }}
/>
))}
</span>
)}
{ui.progress && (
<span className="pg-progwrap">
<span className="pg-progrow">
<span className="pg-k">{ui.progress.label}</span>
<span className="pg-v">{ui.progress.value}%</span>
</span>
<span className="pg-prog">
<i style={{ width: `${ui.progress.value}%` }} />
</span>
</span>
)}
{ui.stamp && (
<span className="pg-stamp">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 6L9 17l-5-5" />
</svg>
{ui.stamp}
</span>
)}
</span>
</span>
);
}
/* ---------------------------------------------------------------
Section — Logico-style multi-card carousel
--------------------------------------------------------------- */
const AUTOPLAY_MS = 5200;
const GAP = 24; // px between cards — keep in sync with --pcap-gap below
/* A card occupies roughly 31vw / 46vw / 92vw at the three breakpoints below.
`cover` scales that up to the width the cropped photo actually needs (see
Capability["photo"]), so each image is fetched sharp without every card
paying for the widest one. */
function coverSizes(cover: number): string {
const at = (vw: number) => Math.round(vw * cover);
return `(max-width: 639px) ${at(92)}vw, (max-width: 1023px) ${at(46)}vw, ${at(31)}vw`;
}
export default function PlatformCapabilities() {
const viewportRef = useRef<HTMLDivElement>(null);
const [perView, setPerView] = useState(3);
const [vpW, setVpW] = useState(0);
const [index, setIndex] = useState(0);
const [drag, setDrag] = useState(0);
const [dragging, setDragging] = useState(false);
const [paused, setPaused] = useState(false);
const [reduce, setReduce] = useState(false);
const startX = useRef(0);
const total = CAPS.length;
const maxIndex = Math.max(0, total - perView);
/* Measure the viewport so card width + slide distance are exact, and pick
how many cards are visible from the real width (3 / 2 / 1). */
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const measure = () => {
const w = el.clientWidth;
setVpW(w);
setPerView(w < 640 ? 1 : w < 1024 ? 2 : 3);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
const m = window.matchMedia("(prefers-reduced-motion: reduce)");
const f = () => setReduce(m.matches);
f();
m.addEventListener?.("change", f);
return () => m.removeEventListener?.("change", f);
}, []);
// Keep the index valid when the visible count changes.
useEffect(() => {
setIndex((i) => Math.min(i, Math.max(0, total - perView)));
}, [perView, total]);
const cardW = vpW > 0 ? (vpW - (perView - 1) * GAP) / perView : 0;
const step = cardW + GAP;
const clampIdx = useCallback(
(n: number) => Math.max(0, Math.min(n, maxIndex)),
[maxIndex]
);
const go = useCallback((n: number) => setIndex(clampIdx(n)), [clampIdx]);
const prev = useCallback(() => setIndex((i) => (i <= 0 ? maxIndex : i - 1)), [maxIndex]);
const next = useCallback(() => setIndex((i) => (i >= maxIndex ? 0 : i + 1)), [maxIndex]);
/* Autoplay — advances one card, wraps at the end. Pauses on hover, focus and
while dragging; off entirely for reduced-motion. */
useEffect(() => {
if (reduce || paused || dragging || maxIndex === 0) return;
const id = window.setInterval(() => {
setIndex((i) => (i >= maxIndex ? 0 : i + 1));
}, AUTOPLAY_MS);
return () => window.clearInterval(id);
}, [reduce, paused, dragging, maxIndex]);
/* Pointer drag / touch swipe — one handler covers mouse and touch. */
const onPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button != null && e.button !== 0) return;
setDragging(true);
setPaused(true);
startX.current = e.clientX;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
}, []);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragging) return;
setDrag(e.clientX - startX.current);
},
[dragging]
);
const endDrag = useCallback(
(e: React.PointerEvent) => {
if (!dragging) return;
const dx = e.clientX - startX.current;
setDragging(false);
setDrag(0);
if (step > 0 && Math.abs(dx) > 8) {
const shift = Math.round(-dx / step) || (dx < 0 ? 1 : -1);
if (Math.abs(dx) > 40 || Math.abs(shift) >= 1) go(index + shift);
}
window.setTimeout(() => setPaused(false), 350);
},
[dragging, step, index, go]
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowRight") { e.preventDefault(); next(); }
if (e.key === "ArrowLeft") { e.preventDefault(); prev(); }
},
[next, prev]
);
// Track offset, with a gentle rubber-band past either end while dragging.
const tx = useMemo(() => {
const minTx = -maxIndex * step;
let t = -index * step + (dragging ? drag : 0);
if (dragging) {
if (t > 0) t = t * 0.35;
else if (t < minTx) t = minTx + (t - minTx) * 0.35;
}
return t;
}, [index, step, dragging, drag, maxIndex]);
const num = String(Math.min(index + 1, total)).padStart(2, "0");
const totalStr = String(total).padStart(2, "0");
return (
<>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<section id="pcap-root" aria-labelledby="pcap-heading">
<div className="pcap-in">
<div className="pcap-head">
<span className="pcap-eyebrow">/ Platform Capabilities /</span>
<h2 className="pcap-h" id="pcap-heading">
EVERYTHING THE PLATFORM<br />
<span className="accent">DOES FOR YOUR OPERATION.</span>
</h2>
<p className="pcap-lede">
The services above are what we move. This is what runs underneath them
eight capabilities working on the same shipment record, from the route
being planned to the report being signed off.
</p>
</div>
<div className="pcap-stage">
<div
className="pcap-viewport"
ref={viewportRef}
role="group"
aria-roledescription="carousel"
aria-label="Platform capabilities"
tabIndex={0}
onKeyDown={onKeyDown}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<ul
className={`pcap-track${dragging ? " is-dragging" : ""}`}
style={{ transform: `translate3d(${tx}px,0,0)`, gap: `${GAP}px` }}
>
{CAPS.map((cap, i) => {
const active = i >= index && i < index + perView;
const n = String(i + 1).padStart(2, "0");
return (
<li
key={cap.id}
className={`pcap-card${active ? " is-active" : ""}`}
style={cardW ? { flex: `0 0 ${cardW}px` } : undefined}
aria-hidden={active ? undefined : true}
>
{/* Full-bleed photo card: a real operational photograph
fills the frame, a grade + scrim keep the glass UI and
the overlaid title readable — the Logico "what we do"
composition, Doormile assets. */}
<Link
href={cap.cta.href}
className="pcap-card-inner"
aria-label={`${cap.title}${cap.cta.label}`}
tabIndex={active ? undefined : -1}
draggable={false}
onClick={(e) => { if (Math.abs(drag) > 6) e.preventDefault(); }}
>
<span className="pcap-card-scene">
{/* next/image, not a bare <img>: the source library
runs to ~2.5MB per PNG and each one renders into a
~470px-wide card, so the resize + avif/webp step is
doing real work here. */}
<Image
className="pcap-photo"
src={cap.photo.src}
alt={cap.photo.alt}
fill
sizes={coverSizes(cap.photo.cover)}
style={cap.photo.pos ? { objectPosition: cap.photo.pos } : undefined}
draggable={false}
/>
<span className="pcap-grade" aria-hidden="true" />
<span className="pcap-tint" aria-hidden="true" />
</span>
<CapOverlay index={i} />
<span className="pcap-scrim" aria-hidden="true" />
<span className="pcap-card-num" aria-hidden="true">{n}</span>
<span className="pcap-card-cap">
<span className="pcap-card-text">
<span className="pcap-card-kicker">Capability {n}</span>
<span className="pcap-card-title">{cap.title}</span>
<span className="pcap-card-blurb">{cap.blurb}</span>
</span>
<span className="pcap-card-arrow" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M7 17L17 7M17 7H8.5M17 7V15.5" />
</svg>
</span>
</span>
</Link>
</li>
);
})}
</ul>
</div>
</div>
<div className="pcap-foot">
<Link href="/miletruth" className="pcap-explore">
<span>Explore more</span>
<span className="pcap-explore-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M7 17L17 7M17 7H8.5M17 7V15.5" /></svg>
</span>
</Link>
<div className="pcap-progress">
<div className="pcap-nav-btns" aria-label="Carousel navigation">
<button type="button" className="pcap-fbtn" onClick={prev} aria-label="Previous capabilities">
<Arrow dir="prev" />
</button>
<span className="pcap-float-div" aria-hidden="true" />
<button type="button" className="pcap-fbtn" onClick={next} aria-label="Next capabilities">
<Arrow dir="next" />
</button>
</div>
<span className="pcap-count" aria-hidden="true">
<b>{num}</b> / {totalStr}
</span>
<div className="pcap-segs" role="tablist" aria-label="Choose a capability">
{CAPS.map((c, i) => (
<button
key={c.id}
type="button"
role="tab"
aria-selected={i === index}
aria-label={c.title}
className={`pcap-seg${i === index ? " is-active" : ""}${i >= index && i < index + perView ? " is-lit" : ""}`}
onClick={() => go(i)}
>
<span
className="pcap-seg-fill"
style={
i === index && !reduce && !paused && maxIndex > 0
? { animationDuration: `${AUTOPLAY_MS}ms` }
: undefined
}
/>
</button>
))}
</div>
</div>
</div>
</div>
</section>
</>
);
}
/* ============================================================
CSS — scoped to #pcap-root. Transparent: it sits on the Miles3
dark card and must not paint its own surface.
============================================================ */
const CSS = `
#pcap-root {
--pc-red: #C01227;
--pc-red-bright: #E2354A;
--pc-line: rgba(255,255,255,0.10);
--pc-dim: rgba(255,255,255,0.62);
--pcap-gap: ${GAP}px;
position: relative;
/* The theme's global "section { padding: 6rem 0 }" (site.css) would add 96px
top/bottom here; this block is spaced by .miles3-continue + the divider
instead, so zero it out — the vertical rhythm must be content-driven. */
padding: 0;
}
#pcap-root, #pcap-root * { font-family: var(--font-manrope), "Manrope", sans-serif; box-sizing: border-box; }
/* The theme kit decorates EVERY list item globally (padding + a fontello
check via ::before). Neutralise it inside this section. */
#pcap-root ul { list-style: none; margin: 0; padding: 0; }
#pcap-root ul li { position: relative; padding: 0; }
#pcap-root ul li::before { content: none !important; display: none !important; }
#pcap-root .pcap-in { position: relative; width: 100%; min-width: 0; max-width: 100%; }
#pcap-root .pcap-stage, #pcap-root .pcap-viewport { max-width: 100%; min-width: 0; }
/* A hairline above the block ties it to the mile cards without splitting the
dark surface into two sections. */
#pcap-root .pcap-in::before {
content: ''; display: block; height: 1px; width: 100%;
background: linear-gradient(90deg, transparent, var(--pc-line) 18%, var(--pc-line) 82%, transparent);
margin-bottom: clamp(14px, 1.8vw, 22px);
}
/* ---------- Header (centered) ---------- */
#pcap-root .pcap-head { max-width: 760px; margin: 0 auto clamp(38px, 4vw, 60px); text-align: center; }
#pcap-root .pcap-eyebrow {
display: inline-block; color: var(--pc-red-bright); font-weight: 800;
text-transform: uppercase; letter-spacing: 0.16em; font-size: 12.5px; margin-bottom: 16px;
}
#pcap-root .pcap-h {
margin: 0; color: #fff; font-size: clamp(28px, 3.4vw, 52px); font-weight: 500;
line-height: 1.1; letter-spacing: -0.03em; text-transform: uppercase;
}
#pcap-root .pcap-h .accent { color: var(--pc-red-bright); }
#pcap-root .pcap-lede { margin: 18px auto 0; max-width: 640px; color: var(--pc-dim); font-size: 16px; line-height: 1.72; }
/* ---------- Stage (viewport + floating nav) ---------- */
#pcap-root .pcap-stage { position: relative; }
/* ---------- Viewport + track ---------- */
#pcap-root .pcap-viewport {
overflow: hidden; outline: none; cursor: grab;
touch-action: pan-y; margin: 0 -2px; padding: 12px 2px;
}
#pcap-root .pcap-viewport:active { cursor: grabbing; }
#pcap-root .pcap-viewport:focus-visible { border-radius: 24px; box-shadow: 0 0 0 2px var(--pc-red-bright); }
#pcap-root .pcap-track {
display: flex; align-items: stretch; will-change: transform;
transition: transform 0.62s cubic-bezier(0.16, 1, 0.3, 1);
}
#pcap-root .pcap-track.is-dragging { transition: none; }
/* Fallback widths before JS measures (and if JS never runs) */
#pcap-root .pcap-card { flex: 0 0 calc((100% - 2 * var(--pcap-gap)) / 3); }
@media (max-width: 1023px) { #pcap-root .pcap-card { flex: 0 0 calc((100% - var(--pcap-gap)) / 2); } }
@media (max-width: 639px) { #pcap-root .pcap-card { flex: 0 0 100%; } }
/* ---------- Card: full-bleed portrait image card ---------- */
#pcap-root .pcap-card {
transition: transform 0.45s cubic-bezier(0.16,1,0.3,1), opacity 0.4s;
}
#pcap-root .pcap-card:not(.is-active) { opacity: 0.42; }
#pcap-root .pcap-card.is-active:hover { transform: translateY(-10px); }
#pcap-root .pcap-card-inner {
position: relative; display: block; aspect-ratio: 63 / 90; overflow: hidden;
border-radius: 26px; text-decoration: none;
background: linear-gradient(160deg, #26262b 0%, #16161a 100%);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 34px 80px -46px rgba(0,0,0,0.92), inset 0 1px 0 rgba(255,255,255,0.05);
transition: border-color 0.45s, box-shadow 0.45s;
-webkit-tap-highlight-color: transparent;
}
#pcap-root .pcap-card.is-active:hover .pcap-card-inner {
border-color: rgba(226,53,74,0.55);
box-shadow: 0 48px 100px -42px rgba(0,0,0,0.96), 0 0 0 1px rgba(226,53,74,0.28), inset 0 1px 0 rgba(255,255,255,0.08);
}
#pcap-root .pcap-card-inner:focus-visible { outline: 2px solid var(--pc-red-bright); outline-offset: 3px; }
/* The photograph — full-bleed, filling the frame, zooming gently on hover. */
#pcap-root .pcap-card-scene {
position: absolute; inset: 0; z-index: 0; overflow: hidden;
background: #101014;
}
#pcap-root .pcap-photo {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block; user-select: none;
/* Shared cinematic grade. Eight different source photographs have to read as
one set, so they are pulled to the same darkness and saturation here rather
than being re-exported. */
filter: saturate(0.94) contrast(1.06) brightness(0.92);
transform: scale(1.005);
transition: transform 0.7s cubic-bezier(0.16,1,0.3,1), filter 0.5s ease;
}
#pcap-root .pcap-card.is-active:hover .pcap-photo {
transform: scale(1.03);
filter: saturate(1.04) contrast(1.07) brightness(1.02);
}
/* Grade + vignette: darkens the top so the live pill and index chip read, and
closes the corners so the photo sits inside the card rather than escaping it. */
#pcap-root .pcap-grade {
position: absolute; inset: 0; z-index: 1; pointer-events: none;
background:
linear-gradient(to bottom, rgba(10,10,13,0.6) 0%, rgba(10,10,13,0.16) 22%, transparent 42%),
radial-gradient(120% 86% at 50% 38%, transparent 44%, rgba(0,0,0,0.5) 100%);
}
/* Brand wash — off at rest, warms the frame on hover. */
#pcap-root .pcap-tint {
position: absolute; inset: 0; z-index: 1; pointer-events: none; opacity: 0;
background: radial-gradient(96% 62% at 50% 4%, rgba(226,53,74,0.36), transparent 70%);
mix-blend-mode: screen;
transition: opacity 0.5s ease;
}
#pcap-root .pcap-card.is-active:hover .pcap-tint { opacity: 1; }
/* ---------- Glass UI overlays ---------- */
#pcap-root .pcap-ui { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
/* Shared glass surface */
#pcap-root .pg-live,
#pcap-root .pg-chip,
#pcap-root .pg-panel {
-webkit-backdrop-filter: blur(16px) saturate(1.4);
backdrop-filter: blur(16px) saturate(1.4);
background: rgba(17,17,21,0.44);
border: 1px solid rgba(255,255,255,0.16);
box-shadow: 0 12px 34px -16px rgba(0,0,0,0.92), inset 0 1px 0 rgba(255,255,255,0.10);
}
/* Live pill + optional AI chip, top-right (the index chip owns top-left) */
#pcap-root .pg-top {
position: absolute; top: 15px; right: 16px;
display: flex; align-items: center; gap: 7px;
}
#pcap-root .pg-live {
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
padding: 5px 10px 5px 8px; border-radius: 999px;
color: rgba(255,255,255,0.92); font-size: 9px; font-weight: 800;
letter-spacing: 0.13em; text-transform: uppercase;
}
#pcap-root .pg-live i {
width: 5px; height: 5px; border-radius: 50%; flex: none;
background: var(--pc-red-bright); box-shadow: 0 0 0 3px rgba(226,53,74,0.22);
animation: pcap-pulse 2.4s ease-in-out infinite;
}
#pcap-root .pg-chip {
padding: 5px 9px; border-radius: 9px; color: #ff93a0;
border-color: rgba(226,53,74,0.5);
font-size: 9px; font-weight: 800; letter-spacing: 0.14em;
}
/* Main metric widget. Deliberately a CARD, not a band: capped in width so the
photograph still reads down the right-hand side, and anchored as a fraction
of card height so it holds its place at every card width and clears the
caption even when the blurb expands on hover. */
#pcap-root .pg-panel {
position: absolute; left: 16px; top: 26%;
width: calc(100% - 32px); max-width: 268px;
border-radius: 16px; padding: 12px 13px 13px;
display: flex; flex-direction: column; gap: 9px;
transition: transform 0.5s cubic-bezier(0.16,1,0.3,1);
}
#pcap-root .pcap-card.is-active:hover .pg-panel { transform: translateY(-6px); }
#pcap-root .pg-metric { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
#pcap-root .pg-metric b {
color: #fff; font-size: clamp(19px, 1.85vw, 26px); font-weight: 700;
letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums;
}
#pcap-root .pg-metric em {
font-style: normal; color: rgba(255,255,255,0.58); font-size: 9.5px; font-weight: 700;
letter-spacing: 0.1em; text-transform: uppercase; line-height: 1.2;
}
#pcap-root .pg-rows { display: flex; flex-direction: column; gap: 6px; }
#pcap-root .pg-row,
#pcap-root .pg-progrow {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
font-size: 10.5px; line-height: 1.3;
}
#pcap-root .pg-row + .pg-row { padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.09); }
#pcap-root .pg-k {
color: rgba(255,255,255,0.56); font-weight: 600;
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#pcap-root .pg-v { color: #fff; font-weight: 700; white-space: nowrap; flex: none; }
#pcap-root .pg-bars { display: flex; align-items: flex-end; gap: 5px; height: 38px; }
#pcap-root .pg-bars i {
flex: 1; min-height: 4px; border-radius: 3px 3px 1px 1px;
background: linear-gradient(to top, rgba(255,255,255,0.14), rgba(255,255,255,0.44));
}
#pcap-root .pg-bars i.is-hi {
background: linear-gradient(to top, var(--pc-red), var(--pc-red-bright));
box-shadow: 0 0 12px rgba(226,53,74,0.55);
}
#pcap-root .pg-progwrap { display: flex; flex-direction: column; gap: 6px; }
#pcap-root .pg-prog {
display: block; height: 4px; border-radius: 999px;
background: rgba(255,255,255,0.14); overflow: hidden;
}
#pcap-root .pg-prog i {
display: block; height: 100%; border-radius: 999px;
background: linear-gradient(90deg, var(--pc-red), var(--pc-red-bright));
}
#pcap-root .pg-stamp {
display: flex; align-items: center; gap: 7px;
padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.09);
color: rgba(255,255,255,0.74); font-size: 9.5px; font-weight: 700; letter-spacing: 0.05em;
}
#pcap-root .pg-stamp svg {
width: 11px; height: 11px; flex: none;
fill: none; stroke: var(--pc-red-bright); stroke-width: 3;
stroke-linecap: round; stroke-linejoin: round;
}
/* Routing card only — the chosen line tracing the corridor in the photo */
#pcap-root .pg-route {
position: absolute; left: 0; top: 0; width: 100%; height: 64%; z-index: 1;
pointer-events: none; overflow: visible;
}
#pcap-root .pg-route-base {
fill: none; stroke: rgba(255,255,255,0.44); stroke-width: 1.5;
stroke-dasharray: 4 5; vector-effect: non-scaling-stroke;
}
/* A long lit segment running the corridor, rather than a short tick that reads
as a stray mark over the photograph. */
#pcap-root .pg-route-live {
fill: none; stroke: var(--pc-red-bright); stroke-width: 2.8; stroke-linecap: round;
vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 7px rgba(226,53,74,0.85));
stroke-dasharray: 62 138; animation: pcap-draw 3.8s linear infinite;
}
@keyframes pcap-draw { from { stroke-dashoffset: 200; } to { stroke-dashoffset: 0; } }
/* Bottom scrim so the overlaid title always reads */
#pcap-root .pcap-scrim {
position: absolute; inset: 0; z-index: 1; pointer-events: none;
background: linear-gradient(to top, rgba(9,9,11,0.92) 6%, rgba(9,9,11,0.6) 34%, rgba(9,9,11,0.05) 62%, transparent 78%);
transition: opacity 0.45s; opacity: 0.9;
}
#pcap-root .pcap-card.is-active:hover .pcap-scrim { opacity: 1; }
/* Small index chip, top-left */
#pcap-root .pcap-card-num {
position: absolute; top: 18px; left: 20px; z-index: 3;
color: rgba(255,255,255,0.9); font-size: 12.5px; font-weight: 800; letter-spacing: 0.14em;
}
#pcap-root .pcap-card-num::before {
content: ''; display: inline-block; width: 20px; height: 2px; margin-right: 8px;
background: var(--pc-red-bright); vertical-align: middle;
}
/* Overlaid caption: title + arrow, bottom row */
#pcap-root .pcap-card-cap {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
display: flex; align-items: flex-end; justify-content: space-between; gap: 14px;
padding: 24px 22px 26px;
}
#pcap-root .pcap-card-text { min-width: 0; }
#pcap-root .pcap-card-kicker {
display: block; color: var(--pc-red-bright); font-size: 10.5px; font-weight: 800;
text-transform: uppercase; letter-spacing: 0.16em; margin-bottom: 9px;
}
#pcap-root .pcap-card-title {
display: block; color: #fff; font-size: clamp(19px, 1.5vw, 23px); font-weight: 700;
letter-spacing: -0.02em; line-height: 1.18;
}
/* Blurb reveals on hover, mirroring the reference's quiet-until-hover cards */
#pcap-root .pcap-card-blurb {
display: block; color: rgba(255,255,255,0.72); font-size: 13.5px; line-height: 1.55;
max-width: 92%;
max-height: 0; opacity: 0; margin-top: 0;
overflow: hidden; transform: translateY(6px);
transition: max-height 0.45s ease, opacity 0.4s ease, margin-top 0.45s ease, transform 0.45s ease;
}
#pcap-root .pcap-card.is-active:hover .pcap-card-blurb {
max-height: 84px; opacity: 1; margin-top: 11px; transform: translateY(0);
}
#pcap-root .pcap-card-arrow {
flex: none; width: 44px; height: 44px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.24); color: #fff;
transition: background 0.35s, border-color 0.35s, transform 0.35s;
}
#pcap-root .pcap-card-arrow svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
#pcap-root .pcap-card.is-active:hover .pcap-card-arrow {
background: var(--pc-red); border-color: var(--pc-red-bright); transform: translate(2px,-2px) rotate(0deg);
}
/* ---------- Carousel navigation pill in footer ---------- */
#pcap-root .pcap-nav-btns {
display: flex; align-items: center; gap: 2px;
width: 92px; height: 42px; border-radius: 999px; flex: none;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.18);
backdrop-filter: blur(8px);
transition: background 0.3s, border-color 0.3s, transform 0.3s;
}
#pcap-root .pcap-nav-btns:hover {
border-color: rgba(226,53,74,0.45);
background: rgba(255,255,255,0.09);
}
#pcap-root .pcap-float-div { width: 1px; height: 20px; background: rgba(255,255,255,0.16); flex: none; }
#pcap-root .pcap-fbtn {
flex: 1; height: 100%; border: 0; background: none; color: #fff; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; border-radius: 999px;
transition: color 0.25s, transform 0.25s;
}
#pcap-root .pcap-fbtn:first-child { padding-right: 2px; }
#pcap-root .pcap-fbtn:last-child { padding-left: 2px; }
#pcap-root .pcap-fbtn svg { width: 18px; height: 18px; transition: transform 0.25s; }
#pcap-root .pcap-fbtn:hover { color: var(--pc-red-bright); }
#pcap-root .pcap-fbtn:first-child:hover svg { transform: translateX(-3px); }
#pcap-root .pcap-fbtn:last-child:hover svg { transform: translateX(3px); }
#pcap-root .pcap-fbtn:focus-visible { outline: 2px solid var(--pc-red-bright); outline-offset: 2px; }
@keyframes pcap-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.35); } }
/* ---------- Footer: Explore (left) + segmented progress (right) ---------- */
#pcap-root .pcap-foot {
display: flex; align-items: center; justify-content: space-between; gap: 24px;
margin-top: clamp(30px, 3.4vw, 48px);
}
/* Explore-more pill */
#pcap-root .pcap-explore {
display: inline-flex; align-items: center; gap: 14px; flex: none;
padding: 13px 15px 13px 26px; border-radius: 999px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.18);
color: #fff; font-size: 13px; font-weight: 800; letter-spacing: 0.05em;
text-transform: uppercase; text-decoration: none;
transition: background 0.3s, border-color 0.3s, transform 0.3s;
}
#pcap-root .pcap-explore-ic {
width: 34px; height: 34px; border-radius: 50%; flex: none;
display: inline-flex; align-items: center; justify-content: center;
background: var(--pc-red); color: #fff; transition: background 0.3s, transform 0.3s;
}
#pcap-root .pcap-explore-ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
#pcap-root .pcap-explore:hover { border-color: rgba(226,53,74,0.45); transform: translateY(-2px); }
#pcap-root .pcap-explore:hover .pcap-explore-ic { background: var(--pc-red-bright); transform: translate(2px,-2px); }
#pcap-root .pcap-explore:focus-visible { outline: 2px solid var(--pc-red-bright); outline-offset: 3px; }
/* Counter + segmented indicator */
#pcap-root .pcap-progress { display: flex; align-items: center; gap: 20px; flex: none; }
#pcap-root .pcap-count { color: rgba(255,255,255,0.55); font-size: 14px; font-weight: 700; letter-spacing: 0.08em; white-space: nowrap; }
#pcap-root .pcap-count b { color: #fff; font-size: 20px; }
#pcap-root .pcap-segs { display: flex; align-items: center; gap: 9px; }
/* 24px tall tap target with a 2px line drawn via ::after */
#pcap-root .pcap-seg {
position: relative; width: 30px; height: 24px; padding: 0; border: 0; background: none;
cursor: pointer; display: flex; align-items: center;
}
#pcap-root .pcap-seg::after {
content: ''; position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%);
height: 2px; border-radius: 2px; background: rgba(255,255,255,0.16); transition: background 0.25s;
}
#pcap-root .pcap-seg.is-lit::after { background: rgba(255,255,255,0.34); }
#pcap-root .pcap-seg:hover::after { background: rgba(255,255,255,0.5); }
#pcap-root .pcap-seg-fill {
position: relative; z-index: 1; display: block;
width: 0; height: 2px; border-radius: 2px; background: var(--pc-red-bright);
}
#pcap-root .pcap-seg.is-active > .pcap-seg-fill { width: 100%; animation: pcap-seg 5200ms linear forwards; }
#pcap-root .pcap-seg:focus-visible { outline: 2px solid var(--pc-red-bright); outline-offset: 2px; border-radius: 4px; }
@keyframes pcap-seg { from { width: 0; } to { width: 100%; } }
/* ---------- Responsive ---------- */
@media (max-width: 1023px) {
#pcap-root .pcap-card-cap { padding: 20px 18px 22px; }
}
@media (max-width: 639px) {
#pcap-root .pcap-foot { flex-direction: column; align-items: stretch; gap: 18px; }
#pcap-root .pcap-explore { justify-content: center; }
#pcap-root .pcap-progress { justify-content: space-between; gap: 12px; }
#pcap-root .pcap-segs { flex: 1; justify-content: space-between; gap: 6px; }
#pcap-root .pcap-seg { flex: 1; width: auto; }
#pcap-root .pcap-count b { font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
#pcap-root .pcap-track { transition: none; }
#pcap-root .pg-live i,
#pcap-root .pg-route-live { animation: none !important; }
#pcap-root .pg-route-live { stroke-dasharray: none; }
#pcap-root .pcap-photo { transition: none; }
#pcap-root .pcap-card.is-active:hover { transform: none; }
#pcap-root .pcap-card.is-active:hover .pcap-photo { transform: scale(1.005); }
#pcap-root .pcap-card.is-active:hover .pg-panel { transform: none; }
#pcap-root .pcap-seg.is-active > .pcap-seg-fill { width: 100%; animation: none; }
}
`;