1122 lines
46 KiB
TypeScript
1122 lines
46 KiB
TypeScript
"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; }
|
||
}
|
||
`;
|