// `|| {}` guards against _ds_bundle.js failing to load in production (a
// real network failure, not reproducible on localhost): destructuring
// straight off `undefined` would throw here and abort the rest of this
// file's top-level code (see Shell.jsx for the full mechanism this fixes).
const { Kicker, Button, Rule, StatBlock, Icon } = window.INKStudioDesignSystem_0136e3 || {};

// ============================================================================
// Portfolio showcase — replaces the empty hero placeholder strip.
// Reuses the real 24-piece PROJECTS data from the Work page (window.PROJECTS,
// defined in Work.jsx). Never read at module-evaluation time — Work.jsx loads
// after Home.jsx, so every read happens inside hooks/render, once React has
// actually mounted and all page scripts have run.
// ============================================================================

const SHOWCASE_FAMILY_ORDER = ["Collab", "Media", "Motion", "Print", "3D"];

// How far (px) BEFORE the showcase reaches its resting position the visual
// transformation is already blending in. This is normal, unavoidable scroll
// distance (the row scrolling into view) — not extra "dead" scroll — so
// starting the transform here means it's already ~90% built by the moment
// the row is actually centered in the viewport.
const SHOWCASE_LEAD = 320;
// Extra pinned scroll distance (px) AFTER the showcase reaches its resting
// position — deliberately tiny. This (plus the sticky box's own height) is
// the only artificial scroll height the wrapper adds; the fix for "activates
// too late" is to shrink this, not grow it.
const SHOWCASE_PIN_RANGE = 24;

// A tiny tiled grayscale noise texture (SVG feTurbulence, no image asset) for
// the gallery atmosphere's grain — computed once at module load, not per
// render.
const SHOWCASE_NOISE_BG = `url("data:image/svg+xml,${encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter><rect width="100%" height="100%" filter="url(#n)"/></svg>'
)}")`;

// Round-robins one piece per family per pass so same-category pieces never sit
// next to each other in the reel.
function interleaveShowcase(projects) {
  const buckets = SHOWCASE_FAMILY_ORDER.map((cat) => projects.filter((p) => p.cat === cat));
  const out = [];
  let more = true;
  while (more) {
    more = false;
    for (const bucket of buckets) {
      if (bucket.length) { out.push(bucket.shift()); more = true; }
    }
  }
  return out;
}

function useReducedMotion() {
  const [reduced, setReduced] = React.useState(() => {
    try { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) { return false; }
  });
  React.useEffect(() => {
    let mq;
    try { mq = window.matchMedia("(prefers-reduced-motion: reduce)"); } catch (e) { return; }
    const onChange = () => setReduced(mq.matches);
    if (mq.addEventListener) mq.addEventListener("change", onChange); else mq.addListener(onChange);
    return () => { if (mq.removeEventListener) mq.removeEventListener("change", onChange); else mq.removeListener(onChange); };
  }, []);
  return reduced;
}

function useIsNarrow(breakpoint = 760) {
  const [narrow, setNarrow] = React.useState(() => typeof window !== "undefined" && window.innerWidth < breakpoint);
  React.useEffect(() => {
    const onResize = () => setNarrow(window.innerWidth < breakpoint);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, [breakpoint]);
  return narrow;
}

// Tracks viewport size so the active card's contain-fit target dimensions
// (computed from it) stay correct across window resizes, not just narrow/wide
// breakpoint crossings.
function useViewportSize() {
  const [size, setSize] = React.useState(() => ({
    w: typeof window !== "undefined" ? window.innerWidth : 1280,
    h: typeof window !== "undefined" ? window.innerHeight : 800,
  }));
  React.useEffect(() => {
    const onResize = () => setSize({ w: window.innerWidth, h: window.innerHeight });
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return size;
}

// The ONE card silhouette every showcase project renders inside — same
// width, height, aspect ratio and corner radius regardless of the source
// asset's own dimensions (a portrait photo never makes a taller card, a
// landscape one never makes a wider one). Matches the reference screenshot's
// centered "Motion" card proportion. Individual projects only ever change
// which crop of their own image shows inside this fixed frame (see
// ShowcaseCard's `fit="cover"` + each item's optional `focal`), never the
// frame itself.
const MASTER_CARD_RATIO = "4 / 3";
const MASTER_CARD_RATIO_NUM = 4 / 3;

// For the handful of projects whose default edge-to-edge cover-crop cuts
// off meaningful composition (see each item's optional `zoom` in
// window.PROJECTS), computes the smaller, centered {width,height}
// percentage box — still the asset's own exact aspect ratio, never
// stretched — that reveals more of it. `zoom` is how much of the default
// full-cover size to keep (1 = normal edge-to-edge cover, no override;
// smaller reveals progressively more, down to the asset's own full
// uncropped `contain` size at zoom = containScale/coverScale). Passed to
// Frame as `mediaBox`; every other project omits `zoom` and renders
// exactly as before.
function mediaZoomBox(itemRatioStr, zoom) {
  const parts = String(itemRatioStr).split("/").map((s) => parseFloat(s));
  const imgR = parts.length === 2 && parts[1] ? parts[0] / parts[1] : 1;
  const boxR = MASTER_CARD_RATIO_NUM;
  // cover crops whichever axis the image "overflows" relative to the box;
  // shrinking the cover-fitted size by `zoom` around the center pulls that
  // overflow in (revealing more of the source there) while the other,
  // already-fully-shown axis just gains a matching margin.
  return imgR >= boxR
    ? { height: `${zoom * 100}%`, width: `${zoom * (imgR / boxR) * 100}%` }
    : { width: `${zoom * 100}%`, height: `${zoom * (boxR / imgR) * 100}%` };
}

// The gallery atmosphere's default/fallback ambient tone (warm INK
// vermilion) — used before an image's own accent has been extracted, for
// video pieces (no cheap way to sample a frame), and if extraction fails.
const SHOWCASE_DEFAULT_ACCENT = { h: 14, s: 0.55, l: 0.42 };

function rgbToHsl(r, g, b) {
  r /= 255; g /= 255; b /= 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  let h = 0, s = 0;
  const l = (max + min) / 2;
  const d = max - min;
  if (d !== 0) {
    s = d / (1 - Math.abs(2 * l - 1));
    if (max === r) h = 60 * (((g - b) / d) % 6);
    else if (max === g) h = 60 * ((b - r) / d + 2);
    else h = 60 * ((r - g) / d + 4);
    if (h < 0) h += 360;
  }
  return { h, s, l };
}

// Samples an image's average color (cheap 16×16 canvas draw) and tames it
// into a restrained ambient tone: the hue is kept (it's what carries "this
// piece feels blue / warm / neutral"), but saturation and lightness are
// clamped into a moderate range so the ambient light never gets neon-vivid
// or washes out, regardless of how the source photo itself is exposed.
function extractAmbientAccent(src, cb) {
  try {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => {
      try {
        const sw = 16, sh = 16;
        const canvas = document.createElement("canvas");
        canvas.width = sw; canvas.height = sh;
        const ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0, sw, sh);
        const data = ctx.getImageData(0, 0, sw, sh).data;
        let r = 0, g = 0, b = 0, count = 0;
        for (let i = 0; i < data.length; i += 4) {
          if (data[i + 3] < 16) continue; // skip transparent pixels
          r += data[i]; g += data[i + 1]; b += data[i + 2]; count++;
        }
        if (!count) { cb(null); return; }
        const { h, s } = rgbToHsl(r / count, g / count, b / count);
        cb({ h, s: Math.min(Math.max(s, 0.22), 0.5), l: 0.42 });
      } catch (e) { cb(null); }
    };
    img.onerror = () => cb(null);
    img.src = src;
  } catch (e) { cb(null); }
}

const hslCss = (c, alpha) => `hsla(${Math.round(c.h)}, ${Math.round(c.s * 100)}%, ${Math.round(c.l * 100)}%, ${alpha})`;

// IntersectionObserver scoped to the showcase's own clipped track (not the
// bare viewport) so cards translated out of the visible strip are correctly
// treated as off-screen and their video never mounts until it's actually near.
function useTrackInView(rootRef) {
  const ref = React.useRef(null);
  const [inView, setInView] = React.useState(false);
  React.useEffect(() => {
    const el = ref.current, root = rootRef.current;
    if (!el || !root) return;
    const obs = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { root, rootMargin: "200px", threshold: 0.01 });
    obs.observe(el);
    return () => obs.disconnect();
  }, []);
  return [ref, inView];
}

// TEMPORARILY OFF for a performance isolation test — user is seeing
// cursor-tracking lag on the homepage specifically since this effect was
// added, and wants it fully out to check whether -webkit-box-reflect is
// the cause. Everything the reflection touched (see ShowcaseCard below)
// is still in place and untouched; flipping this back to true is the
// entire re-enable.
const ENABLE_CARD_REFLECTION = false;

function ShowcaseCard({ item, width, active, targetScale, targetOpacity, targetRotate, targetZ, rootRef, onClick, snap }) {
  const [ref, inView] = useTrackInView(rootRef);
  return (
    <div
      ref={ref}
      onClick={onClick}
      onDragStart={(e) => e.preventDefault()}
      role="button"
      tabIndex={0}
      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } }}
      style={{
        flex: `0 0 ${width}px`,
        scrollSnapAlign: snap ? "start" : undefined,
        borderRadius: "var(--radius-lg)",
        cursor: "pointer",
        userSelect: "none",
        WebkitUserSelect: "none",
        WebkitUserDrag: "none",
        // EXPERIMENTAL reflection — isolated to this one property, safe to
        // delete on its own. -webkit-box-reflect mirrors this element's own
        // already-rendered, already-clipped output (Frame below does the
        // actual rounding + overflow:hidden), so the reflection is never a
        // second video/image and never needs its own border-radius — and
        // since it's generated from THIS box, it rides along with every
        // transform already applied below (scale/rotate/translate) with no
        // separate state to keep in sync. The gradient is a MASK (only its
        // alpha channel matters, not the color): near-opaque right at the
        // card's own bottom edge, gone by ~30% of the card's height down —
        // a short fade, never a full mirrored duplicate. Scaled by the same
        // --immersion (0→1) the room/lighting/shadow around it already use,
        // so it's only present once the dark "polished surface" stage it
        // was designed for is actually the backdrop — invisible back in the
        // plain cream reel.
        WebkitBoxReflect: ENABLE_CARD_REFLECTION
          ? `below 3px linear-gradient(to bottom, rgba(255,255,255,calc(${active ? 0.17 : 0.1} * var(--immersion))) 0%, rgba(255,255,255,calc(${active ? 0.045 : 0.025} * var(--immersion))) 20%, rgba(255,255,255,0) 32%)`
          : undefined,
        // Target values are reached continuously as --immersion (0→1) climbs,
        // rather than snapping the instant focused mode engages. rotateY +
        // translateZ curve the row into a true convex arc — the active
        // piece sits at rotate 0 / z 0 (closest, forward-facing), pieces
        // further away rotate inward-toward-center and recede in depth.
        // `perspective` lives on the TRACK (this card's direct parent, see
        // below) rather than a further-up ancestor, and no element anywhere
        // in the chain uses `transform-style: preserve-3d` — earlier this
        // combination (perspective two levels up + preserve-3d on the
        // track) made Chromium's elementFromPoint miss the transformed card
        // entirely, breaking click-to-center on every card but the active
        // one; perspective directly on the immediate parent gives the same
        // true perspective-projected trapezoid shape (verified) without
        // that hit-testing bug, since preserve-3d — only needed when a
        // transformed element's OWN children must share its 3D space,
        // which none of these cards do — was the actual cause, not
        // rotateY/translateZ themselves.
        "--tgt-scale": targetScale,
        "--tgt-opacity": targetOpacity,
        "--tgt-rot": targetRotate,
        "--tgt-z": targetZ,
        transform: "rotateY(calc(var(--tgt-rot) * 1deg * var(--immersion))) translateZ(calc(var(--tgt-z) * 1px * var(--immersion))) scale(calc(1 + (var(--tgt-scale) - 1) * var(--immersion)))",
        opacity: "calc(1 + (var(--tgt-opacity) - 1) * var(--immersion))",
        // Pure dark depth/contact shadow only — no accent-tinted glow here,
        // so the active card reads as dominant without looking "selected."
        boxShadow: active
          ? "0 34px 80px rgba(0,0,0,calc(0.55 * var(--immersion))), 0 8px 24px rgba(0,0,0,calc(0.4 * var(--immersion)))"
          : "none",
        // flex-basis (the active card's contain-fit width) is intentionally
        // NOT transitioned here: centerCard measures real layout geometry
        // synchronously right after the DOM commits, and a still-animating
        // width at that instant would make it measure a stale box, landing
        // the centered position a few pixels off. Snapping the width
        // immediately keeps centering exact; the position slide and the
        // scale/opacity grow still animate smoothly around it.
        transition: "box-shadow var(--dur-base) var(--ease-standard), transform var(--dur-base) var(--ease-standard), opacity var(--dur-base) var(--ease-standard)",
      }}
    >
      {/* MASTER_CARD_RATIO + fit="cover" (never the item's own ratio, never
          "contain") — every project renders inside the identical card
          silhouette; only the crop of its own artwork shown inside changes:
          `focal` shifts WHERE the cover-crop centers for assets whose
          default center-crop loses the meaningful content, `zoom` (see
          mediaZoomBox) pulls back from the default edge-to-edge crop for
          the few assets where even a well-placed crop still cuts off too
          much of the composition. Most projects set neither and render
          exactly as a plain full-cover fit. */}
      <Frame
        ratio={MASTER_CARD_RATIO}
        label={item.tags[0]}
        src={item.src}
        video={inView ? item.video : undefined}
        fit="cover"
        position={item.focal}
        mediaBox={item.zoom && item.zoom < 1 ? mediaZoomBox(item.ratio, item.zoom) : undefined}
        style={{ width: "100%" }}
      />
    </div>
  );
}

// FULL REBUILD (this round) — per an attached Don't Starve-style hand
// reference: ONE continuous illustrated silhouette per hand (no segmented
// "articulated rod" fingers, which kept reading as pieces separating from
// the palm no matter how much the joints were softened). Every finger is
// now a single curved claw shape, drawn as part of the SAME closed path as
// the palm and thumb, fanning out at dramatically different angles —
// exactly like the reference's expressive, asymmetric claws — instead of a
// knuckle row of near-parallel digits. `claw()` is a small parametric
// generator (angle/length/hook/baseW) so each digit's character is a few
// tunable numbers, not hand-plotted bezier points.
function deg(d) { return (d * Math.PI) / 180; }
function claw({ originX, originY, angle, length, hook, baseW, tipCurl = 0 }) {
  const rad = deg(angle);
  const dx = Math.cos(rad), dy = Math.sin(rad);
  const px = -dy, py = dx;
  const base1 = [originX + (px * baseW) / 2, originY + (py * baseW) / 2];
  const base2 = [originX - (px * baseW) / 2, originY - (py * baseW) / 2];
  const tipBase = [originX + dx * length, originY + dy * length];
  const tip = [tipBase[0] + px * tipCurl, tipBase[1] + py * tipCurl];
  const out1 = [originX + dx * length * 0.32 + px * hook, originY + dy * length * 0.32 + py * hook];
  const out2 = [originX + dx * length * 0.72 + px * hook * 0.85, originY + dy * length * 0.72 + py * hook * 0.85];
  const back1 = [originX + dx * length * 0.8 + px * hook * 0.35, originY + dy * length * 0.8 + py * hook * 0.35];
  const back2 = [originX + dx * length * 0.28, originY + dy * length * 0.28];
  return { base1, base2, tip, tip2: tip, tipRound: tip, out1, out2, back1, back2 };
}

// A second, SEPARATE digit generator used only for the thumb (fingers keep
// using claw() above, completely unchanged). claw()'s tip is always a
// single point by construction (both edges converge there), which reads
// fine on the four long claws but, at the thumb's short length, simply
// widening claw()'s own baseW just made a wider TRIANGLE — still visually
// a spike, just with a bigger angle at the point. A real digit's width
// should taper gradually from a full base to a small (not zero) blunt tip,
// symmetric on both edges — taperedDigit() builds exactly that: a linear
// taper (plus an optional mid-length `bulge`, for a touch more mass right
// where it leaves the palm) sampled at the same t-fractions claw() uses,
// with the tip resolved to two close points (tip/tip2) instead of one.
// Those two points used to be joined by a straight line, which read as a
// flat-cut/chopped end — `tipRound`, a third point pushed slightly further
// out along the digit's own centerline, gives buildHandPath a quadratic
// control point instead, so that seam bulges into a soft dome (the offset
// is tipWidth * 4/3, the standard control-point distance for a quadratic
// curve to approximate a circular arc of that radius).
function taperedDigit({ originX, originY, angle, length, baseW, tipWidth = 0, bulge = 0 }) {
  const rad = deg(angle);
  const dx = Math.cos(rad), dy = Math.sin(rad);
  const px = -dy, py = dx;
  const along = (t) => [originX + dx * length * t, originY + dy * length * t];
  const widthAt = (t) => baseW * (1 - t) + tipWidth * t + bulge * Math.sin(Math.PI * t);
  const edge = (t, sign) => {
    const c = along(t);
    const w = widthAt(t);
    return [c[0] + (sign * px * w) / 2, c[1] + (sign * py * w) / 2];
  };
  const base1 = edge(0, 1), base2 = edge(0, -1);
  const out1 = edge(0.33, 1), out2 = edge(0.72, 1);
  const back1 = edge(0.72, -1), back2 = edge(0.33, -1);
  const tipC = along(1);
  const tip = [tipC[0] + (px * tipWidth) / 2, tipC[1] + (py * tipWidth) / 2];
  const tip2 = [tipC[0] - (px * tipWidth) / 2, tipC[1] - (py * tipWidth) / 2];
  const tipRound = along(1 + ((tipWidth * 4) / 3 / length || 0));
  return { base1, base2, tip, tip2, tipRound, out1, out2, back1, back2 };
}

// Wrist anchor points (where the — unchanged — sleeve overlaps generously
// into this path, see InkHand) and the four fingers, origins clustered
// near the top of a compact palm mass, angles fanning across a WIDE ~90°
// range so each digit is doing something clearly different (some hooking
// up and back, some curling down) rather than a spread of parallel digits.
const INK_WRIST_1 = [72, -22];
const INK_WRIST_2 = [58, 26];
// Raw claw() PARAMETERS (not the resolved curve) for the four fingers, kept
// around separately so a finger's rest shape and its live, animated shape
// (see buildHandPath's fingerAngleOffsets) are always built from the exact
// same numbers — the only thing a flick ever changes is `angle`, recomputed
// fresh each frame from these.
const INK_FINGER_PARAMS = [
  { originX: 4, originY: -28, angle: 208, length: 148, hook: 20, baseW: 30, tipCurl: -8 }, // index: hooks up and back sharply
  { originX: -14, originY: -18, angle: 172, length: 192, hook: 26, baseW: 32, tipCurl: 6 }, // middle: longest, near-straight with a gentle sweep
  { originX: -22, originY: 4, angle: 148, length: 160, hook: 22, baseW: 28, tipCurl: 14 }, // ring: angles down-out, curls at the tip
  { originX: -20, originY: 26, angle: 118, length: 116, hook: 30, baseW: 24, tipCurl: 20 }, // pinky: steeply down, tucked, strong hook
];
const INK_FINGERS = INK_FINGER_PARAMS.map((f) => claw(f));
// Thumb — REPOSITIONED: this is a palm-facing-viewer pose (a person
// standing behind the cards, reaching toward the viewer), so the thumb
// belongs on the UPPER side of the palm, near the wrist, above the whole
// four-finger fan — not low/beside the pinky, where it read as a fifth
// finger or was swallowed entirely by the palm. Short, thick, angled
// steeply up-and-out (278°, clearly diverging from index's own 208° so
// the two don't crowd each other) — see the crease mark on it in InkHand,
// which reads as its two phalange sections instead of one smooth claw.
// THICKENED a couple rounds ago — and switched from claw() to
// taperedDigit() (see above): widening claw()'s baseW alone just produced
// a wider triangle, since claw()'s tip is always a single point at this
// short a length — it still read as a spike, just a fatter one.
// taperedDigit() gives it a real blunt tip and a gentle mid-length bulge
// instead, so the base carries visible mass and narrows naturally rather
// than converging to a point. That thickening overshot (read as oversized
// next to the palm/fingers) and was eased back once already; still read
// as too large, so baseW/tipWidth/bulge come down another ~18% this round
// (shape/proportions preserved, just scaled — same origin/angle/length as
// always, same short length, never made longer). The thumb never takes a
// live angle offset (see buildHandPath) — INK_THUMB_CREASE below is
// positioned from this rest geometry and would visibly desync if it moved.
const INK_THUMB_PARAMS = { originX: 42, originY: -38, angle: 278, length: 56, baseW: 41, tipWidth: 7, bulge: 10 };
const INK_THUMB = taperedDigit(INK_THUMB_PARAMS);

// Assembles the whole hand — wrist, palm back, the thumb and all four
// fingers — into ONE closed SVG path, traced in order: wrist-top -> THUMB
// (upper side) -> index -> middle -> ring -> pinky -> wrist-bottom, so the
// thumb sits above the finger fan exactly where the path visits it. Every
// digit is reached by tracing straight out along its own leading edge and
// back along its trailing edge, so there is never a break in the outline;
// between digits the path just continues via a short direct curve along
// the palm's own edge (the wide angular fan between digits already reads
// as clear separation on its own — an earlier attempt at deep web
// "notches" dipping into the palm interior left a visible internal seam
// even though the fill stayed solid one piece).
//
// `fingerAngleOffsets` (optional, degrees, added to a finger's own rest
// `angle`) lets the live puppet loop re-solve this SAME single closed path
// every frame for the index-led "command flick" (see applyHandFrame): the
// moving finger's base and its neighbours' stitching curves are recomputed
// together from one claw() call, so the outline always stays one
// continuous, gapless silhouette — nothing is ever transformed as a piece
// detached from the palm.
function buildHandPath(fingerAngleOffsets) {
  const off = fingerAngleOffsets || {};
  const fingers = [
    claw({ ...INK_FINGER_PARAMS[0], angle: INK_FINGER_PARAMS[0].angle + (off.index || 0) }),
    claw({ ...INK_FINGER_PARAMS[1], angle: INK_FINGER_PARAMS[1].angle + (off.middle || 0) }),
    claw({ ...INK_FINGER_PARAMS[2], angle: INK_FINGER_PARAMS[2].angle + (off.ring || 0) }),
    claw({ ...INK_FINGER_PARAMS[3], angle: INK_FINGER_PARAMS[3].angle + (off.pinky || 0) }),
  ];
  const p = (pt) => `${pt[0]},${pt[1]}`;
  const digits = [INK_THUMB, ...fingers];
  let d = `M${p(INK_WRIST_1)}`;
  d += ` C${p([56, -34])} ${p([44, -42])} ${p(digits[0].base1)}`;
  digits.forEach((f, i) => {
    d += ` C${p(f.out1)} ${p(f.out2)} ${p(f.tip)}`;
    // Only the thumb (taperedDigit) ever has a tip2 distinct from tip — a
    // rounded cap for its blunt tip, via a quadratic curve through the
    // pushed-out tipRound control point (see taperedDigit) rather than a
    // straight line, so the end reads as a soft dome instead of a flat
    // chop. Every claw()-built finger has tip2 === tip, so this is a no-op
    // for them, unchanged from before.
    if (f.tip2 && (f.tip2[0] !== f.tip[0] || f.tip2[1] !== f.tip[1])) d += ` Q${p(f.tipRound)} ${p(f.tip2)}`;
    d += ` C${p(f.back1)} ${p(f.back2)} ${p(f.base2)}`;
    if (i < digits.length - 1) {
      const nextBase1 = digits[i + 1].base1;
      const mid = [(f.base2[0] + nextBase1[0]) / 2, (f.base2[1] + nextBase1[1]) / 2 + 6];
      d += ` Q${p(mid)} ${p(nextBase1)}`;
    }
  });
  d += ` C${p([-40, 54])} ${p([20, 44])} ${p(INK_WRIST_2)}`;
  d += ` L${p(INK_WRIST_1)} Z`;
  return d;
}
const INK_HAND_PATH = buildHandPath();

// ---- Finger "telekinetic push" — layered ON TOP of the (unchanged) arm
// and wrist motion in applyHandFrame, triggered the same moment as the
// existing nav pulse. NOT a puppet pulling strings, and not a finger flick
// — the hand never touches the card, it pushes through the air near it and
// the card just happens to travel the same way (a Jedi/Force-push read).
// So the whole hand opens and reaches together: gathered at rest, fanning
// open and extending as the card gets underway, strongest reach right as
// it arrives, then one easy follow-through back to rest — never a repeat,
// never a reversal mid-gesture. The "opening" rise is sampled from the
// EXACT SAME cubic-bezier curve the card's own CSS transition uses
// (`transform var(--dur-slow) var(--ease-standard)` in centerCard/
// endDrag) over the exact same duration, so the whole hand moves in
// lockstep with the actual card motion instead of running an unrelated
// timer alongside it — this is what makes the card read as moving BECAUSE
// of the hand. bezierY() inverts a cubic-bezier(x1,y1,x2,y2) the same way
// the CSS engine does (Newton-Raphson on x(t)=x, then reads y(t)) so
// easeStandardY(x) === what var(--ease-standard) would render at
// progress x. ----
function bezierY(x1, y1, x2, y2) {
  const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
  const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
  const sampleX = (t) => ((ax * t + bx) * t + cx) * t;
  const sampleY = (t) => ((ay * t + by) * t + cy) * t;
  return (x) => {
    if (x <= 0) return 0;
    if (x >= 1) return 1;
    let t = x;
    for (let i = 0; i < 8; i++) {
      const dx = sampleX(t) - x;
      if (Math.abs(dx) < 1e-4) break;
      const d = (3 * ax * t + 2 * bx) * t + cx;
      if (Math.abs(d) < 1e-6) break;
      t -= dx / d;
    }
    return sampleY(t);
  };
}
// Matches tokens/effects.css: --ease-standard: cubic-bezier(0.22,1,0.36,1).
const easeStandardY = bezierY(0.22, 1, 0.36, 1);
// Matches tokens/effects.css: --dur-slow: 520ms (the card's own transition
// duration in centerCard/endDrag). Kept as a plain constant, like
// SHOWCASE_LEAD/SHOWCASE_PIN_RANGE above, rather than read from the CSS
// var — it doesn't change at runtime and this keeps the whole curve in one
// pure, synchronous function.
const CARD_TRANSITION_MS = 520;
// After the card arrives, the fingers ease back to rest over this much
// longer, separate stretch — not part of the card's own transition, so it
// can be gentler without slowing the card-tracking portion down.
const FINGER_SETTLE_MS = 360;
const FINGER_SWEEP_MS = CARD_TRANSITION_MS + FINGER_SETTLE_MS;
// The largest per-finger start delay used below (pinky's) — see the note
// at the applyHandFrame cutoff check for why this has to extend it.
const MAX_FINGER_DELAY_MS = 85;
// A VERY small, single dip below neutral right at the tail of the settle
// phase — "the fingers may rise slightly above their resting pose for a
// small natural overshoot" on the way back, never during the push itself.
// Not a bounce: fingerSettleValue is two monotonic segments (1 down
// through 0 to -FINGER_OVERSHOOT, then back up to exactly 0), so there is
// exactly one extra direction change, once, only in the return.
const FINGER_OVERSHOOT = 0.14;
function fingerSettleValue(t) {
  if (t <= 0.7) {
    const u = easeStandardY(t / 0.7);
    return 1 - u * (1 + FINGER_OVERSHOOT);
  }
  const u = easeStandardY((t - 0.7) / 0.3);
  return -FINGER_OVERSHOOT * (1 - u);
}
// 0 -> 1 in lockstep with the card (0..CARD_TRANSITION_MS, --ease-standard
// itself — the push), then fingerSettleValue's dip-and-settle back to 0
// (the return). However many fingers sample this at their own small
// delay/amplitude, none of them can read as oscillating — they're each
// just one push out, one ease back, and one tiny settle dip.
function fingerSweepValue(elapsedMs) {
  if (!(elapsedMs >= 0) || elapsedMs >= FINGER_SWEEP_MS) return 0;
  if (elapsedMs <= CARD_TRANSITION_MS) return easeStandardY(elapsedMs / CARD_TRANSITION_MS);
  return fingerSettleValue((elapsedMs - CARD_TRANSITION_MS) / FINGER_SETTLE_MS);
}

// Subtle dimensional shading INSIDE the palm — clipped to the hand's own
// silhouette so it never spills onto the fingers or background — reading
// as a soft shadow where the fingers' bases meet the palm and through the
// wrist-to-palm transition, so arm -> wrist -> palm -> fingers feel like
// connected but distinct forms instead of one uniformly flat red shape.
// No outlines, no texture — just two soft, low-opacity dark ellipses.
const INK_PALM_SHADE = [
  { cx: -10, cy: 6, rx: 46, ry: 34, opacity: 0.2 },
  { cx: 30, cy: -8, rx: 30, ry: 24, opacity: 0.14 },
];

// Evaluates the cubic bezier base1->out1->out2->tip (a finger's outer/
// leading edge, exactly as rendered) at parameter t, plus its tangent
// direction, so a string's wrap loop can be placed exactly ON the curve
// with the correct orientation instead of guessed coordinates.
function cubicAt(p0, p1, p2, p3, t) {
  const u = 1 - t;
  const x = u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0];
  const y = u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1];
  const dx = 3 * u * u * (p1[0] - p0[0]) + 6 * u * t * (p2[0] - p1[0]) + 3 * t * t * (p3[0] - p2[0]);
  const dy = 3 * u * u * (p1[1] - p0[1]) + 6 * u * t * (p2[1] - p1[1]) + 3 * t * t * (p3[1] - p2[1]);
  return { x, y, angle: (Math.atan2(dy, dx) * 180) / Math.PI };
}
// A point on the finger's centerline at parameter t (midway between its
// outer and inner edges there).
function fingerPointAt(f, t) {
  const out = cubicAt(f.base1, f.out1, f.out2, f.tip, t);
  const inn = cubicAt(f.tip, f.back1, f.back2, f.base2, 1 - t);
  return { x: (out.x + inn.x) / 2, y: (out.y + inn.y) / 2, angle: out.angle };
}

// A gentle crease across the thumb's width, midway along its length — a
// purely decorative stroke (the silhouette itself stays one continuous
// piece) that reads as the boundary between the thumb's two phalange
// sections, so it doesn't look like one smooth uninterrupted claw.
const INK_THUMB_CREASE = (() => {
  const pt = fingerPointAt(INK_THUMB, 0.42);
  const r = 8, rad = (pt.angle * Math.PI) / 180;
  const cx = -Math.sin(rad) * r, cy = Math.cos(rad) * r;
  return { x1: pt.x - cx, y1: pt.y - cy, x2: pt.x + cx, y2: pt.y + cy, midX: pt.x, midY: pt.y + 4 };
})();

// NOTE: the earlier finger-side thread wrap (InkThreadWrap) and the
// full marionette-string system (handStringPath/INK_STRING_ANCHORS/
// INK_STRING_GEOM/InkHandString) that used to run from each hand to its
// neighbouring card were removed per an explicit round of feedback — the
// hand/arm concept alone reads as controlling the carousel without them.
// The hands' own pull-driven motion (translate/rotate/scale/curl, see
// applyHandFrame in PortfolioShowcase) is unrelated to that removed
// system and is unchanged.


// ============================================================================
// InkHand — an original, illustrated graphic hand-and-forearm (not
// photorealistic artwork): a sleeve that fades away rather than terminating
// in a hard edge, a distinct tapered wrist, an organic (non-oval) palm, and
// five independently posed, tapered digits with knuckle creases and rim
// light. No strings — the hand/arm's own posture and motion alone read as
// controlling the carousel. One hand is authored reaching down-and-inward
// from the upper left; the right-side hand is the same artwork mirrored via
// CSS scaleX (not a second drawing) but with a small per-side angle bias on
// every digit so the two poses read as related, not perfectly symmetrical.
//
// Motion is layered, cheapest/most-static first: a slow continuous idle
// sway + breathing scale on the outer wrapper (unchanged — still a plain
// CSS animation, matching the atmosphere's own continuous drift) and each
// finger's own slow, staggered idle flex; then a live puppeteer "pull"
// value driven by a spring simulation in PortfolioShowcase (see
// applyHandFrame/tickPuppet), written imperatively every frame onto
// `pullRef` (translate/rotate/scale, plus --hand-curl for the two
// "controlling" fingers). The spring is fed by both drag (continuously,
// for real inertia while dragging) and by navigation/autoplay (a brief
// pulse toward the dominant side), so idle, drag and nav all move through
// the exact same physical motion rather than separate canned animations.
// ============================================================================
// Small decorative accent marks on the forearm sleeve — an editorial
// flourish (not literal henna) that echoes the reference's textured
// sleeve while staying in INK's own abstract/geometric visual language.
const INK_SLEEVE_ACCENTS = [
  { cx: 140, cy: -80, r: 5.4 }, { cx: 158, cy: -118, r: 3.8 }, { cx: 122, cy: -140, r: 3.8 },
  { cx: 160, cy: -172, r: 5.0 }, { cx: 128, cy: -196, r: 3.2 },
  { cx: 148, cy: -234, r: 4.2 }, { cx: 114, cy: -262, r: 3.0 }, { cx: 152, cy: -294, r: 3.6 },
];

// Composition: a horizontal PULLING gesture, not a downward frame — the
// left hand hangs from the upper-left corner and reaches out sideways
// toward the LEFT, the right hand (this same artwork mirrored) reaches
// toward the RIGHT. Each hand visually owns its own direction of
// carousel control (see handInsetPx in PortfolioShowcase for positioning).
function InkHand({ side, role, reduced, inset, pullRef, pathRef, topOffset = -144 }) {
  const flip = side === "right" ? -1 : 1; // artwork is authored for the left hand; mirrored for the right
  // A few degrees of per-side bias on every digit so the right hand isn't a
  // perfect mirror-image pose of the left — "related but alive."
  const angleBias = side === "right" ? -3 : 2;
  return (
    <div
      aria-hidden="true"
      data-role={role}
      style={{
        position: "absolute",
        // Lowered a modest 26px (was -170) so more of the forearm's red,
        // its dark shading and the small warm glow accents along it sit
        // inside the visible stage — still well clear of the center card,
        // see the gap check in the scratchpad screenshots for this round.
        // `topOffset` defaults to that exact desktop-tuned value; the
        // mobile cinema (a completely different, much shorter stage) passes
        // its own value so the hand's fixed 420px-tall box lands correctly
        // against ITS container instead of reusing a number tuned for a
        // different layout.
        top: topOffset,
        [side]: inset,
        width: 310,
        height: 420,
        pointerEvents: "none",
        transformOrigin: "30% 16%",
        "--hand-dir": side === "left" ? 1 : -1,
        // Right hand runs a slightly different duration (not just a phase
        // offset) so the two hands' idle cycles slowly drift apart instead
        // of holding a fixed relationship — reads as less of a perfect
        // robotic loop over time.
        animation: reduced ? "none" : `ink-hand-idle ${side === "right" ? 6.7 : 7.2}s ease-in-out infinite`,
        animationDelay: side === "right" ? "-3.1s" : "0s",
      }}
    >
      {/* Persistent (never remounted) — the puppet spring writes its
          translate/rotate/scale straight here every frame. Layered inside
          the idle-sway wrapper above, so idle drift and live pull motion
          add together rather than fighting over one transform. */}
      <div
        ref={pullRef}
        style={{ width: "100%", height: "100%", transformOrigin: "30% 16%", filter: "drop-shadow(0 10px 18px rgba(0,0,0,0.45))" }}
      >
        <svg
          viewBox="-230 -320 350 480"
          width="100%"
          height="100%"
          style={{ display: "block", overflow: "visible", transform: `scaleX(${flip})` }}
        >
          <defs>
            {/* Richer 4-stop gradient (warm highlight -> vermilion ->
                vermilion-600 -> a deep crimson shadow) for real volume —
                the flat single-tone fill read as a sticker; this reads as
                shaded, illustrated form. */}
            <linearGradient id="ink-hand-fill" x1="0.08" y1="0" x2="0.62" y2="1">
              <stop offset="0%" stopColor="var(--saffron-500)" stopOpacity="0.72" />
              <stop offset="26%" stopColor="var(--vermilion-500)" />
              <stop offset="64%" stopColor="var(--vermilion-600)" />
              <stop offset="100%" stopColor="var(--crimson-900, #5c1414)" />
            </linearGradient>
            {/* Fades to fully transparent well before the top of its own
                path, so whatever the carousel's own overflow crops away is
                already dissolving — never a hard rectangular termination —
                and what remains reads as a large arm continuing up out of
                frame, not a short cuff. Purely vertical (x1===x2) so its
                iso-opacity lines stay flat horizontal bands regardless of
                the sleeve path's own (tall, narrow) bounding-box aspect
                ratio. Opaque only by 55% (with a generous 20%-55% fade
                zone) so a good stretch of the LONG sleeve below genuinely
                dissolves into shadow rather than reading as plainly lit
                the way a too-early opaque cutoff did. */}
            <linearGradient id="ink-sleeve-fill" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="var(--ink-800)" stopOpacity="0" />
              <stop offset="20%" stopColor="var(--ink-800)" stopOpacity="0" />
              <stop offset="55%" stopColor="var(--crimson-900, #5c1414)" stopOpacity="1" />
              <stop offset="100%" stopColor="var(--vermilion-600)" stopOpacity="1" />
            </linearGradient>
            {/* The dark void the forearm emerges from — a soft radial
                shadow, using the room's own near-black tone, sitting
                BEHIND the sleeve near the top of frame so the arm reads
                as partially swallowed by darkness rather than plainly
                exposed against the lit background. */}
            <radialGradient id="ink-upper-void" cx="0.5" cy="0.15" r="0.75">
              <stop offset="0%" stopColor="var(--crimson-900, #5c1414)" stopOpacity="0.55" />
              <stop offset="55%" stopColor="var(--ink-900, #0a0808)" stopOpacity="0.55" />
              <stop offset="100%" stopColor="var(--ink-900, #0a0808)" stopOpacity="0" />
            </radialGradient>
            {/* Clips the palm shading (INK_PALM_SHADE) to the hand's own
                silhouette so the soft shadow never spills onto the
                fingers or the background. */}
            <clipPath id="ink-hand-clip">
              <path d={INK_HAND_PATH} />
            </clipPath>
          </defs>
          <g>
            {/* upper dark masking — restored, and now with enough
                contrast to actually READ against the sticky section's own
                dark background (a pure near-black void was invisible
                against an already-dark room): a soft shadowed pool with a
                touch of warm crimson at its core, behind the sleeve's
                entry point, so the puppeteer reads as hidden above the
                stage rather than the arm being plainly visible. */}
            <ellipse cx="60" cy="-140" rx="200" ry="230" fill="url(#ink-upper-void)" />
            {/* forearm sleeve — REFINED this round: a real diagonal lean
                (the top sits further inward/right, the bottom — near the
                hand — further outward/left, for the LEFT hand) instead of
                a near-vertical drop, so the whole arm reads as one gentle
                anatomical curve continuing into the hand rather than a
                vertical tube that turns 90° at the wrist. Dissolves via
                the fade well before it would ever meet a hard crop line. */}
            <path
              d="M20,10 C10,-70 6,-160 30,-250 C46,-308 92,-344 150,-350 C176,-348 190,-320 182,-296 C150,-220 128,-130 118,-40 C114,-4 96,18 66,26 C46,30 26,24 20,10 Z"
              fill="url(#ink-sleeve-fill)"
            />
            {/* small gold/saffron accent particles near the upper arm —
                restored, and given a soft outer glow ring so they read
                clearly as small lit points rather than disappearing into
                the sleeve's own dark fade. */}
            {INK_SLEEVE_ACCENTS.map((a, i) => (
              <React.Fragment key={i}>
                <circle cx={a.cx} cy={a.cy} r={a.r * 2.2} fill="var(--saffron-500)" opacity="0.14" />
                <circle cx={a.cx} cy={a.cy} r={a.r} fill="var(--saffron-500)" opacity="0.55" />
              </React.Fragment>
            ))}
            {/* The whole hand — wrist, palm, all four fingers and the
                thumb — as ONE continuous illustrated silhouette (see
                buildHandPath/INK_HAND_PATH), styled after the Don't
                Starve-style reference: long curved claw fingers fanning at
                dramatically different angles, flowing directly out of a
                compact palm which itself flows directly out of the wrist
                (overlapping generously into the sleeve's own bottom above
                — forearm, wrist, palm and fingers read as one continuous
                arm, never a separate piece "welded" onto a tube). A
                second, independently-timed sway (ink-wrist-drift) is
                layered on just this sub-tree, so the hand reads as
                drifting slightly at the wrist relative to the fixed
                forearm above — organic secondary motion without ever
                detaching or independently moving a single finger. */}
            <g style={{ transformOrigin: "40px 0px", animation: reduced ? "none" : `ink-wrist-drift ${side === "right" ? 5.4 : 6.1}s ease-in-out infinite`, animationDelay: side === "right" ? "-2.4s" : "0s" }}>
              <g style={{ transform: "rotate(var(--hand-curl, 0deg))", transformOrigin: "40px 0px" }}>
                <path ref={pathRef} d={INK_HAND_PATH} fill="url(#ink-hand-fill)" stroke="var(--vermilion-700, var(--vermilion-600))" strokeWidth="2" strokeLinejoin="round" />
                {/* Subtle palm shading — see INK_PALM_SHADE. Clipped to the
                    hand's own silhouette (no black outlines, no texture,
                    just soft tonal variation) so arm -> wrist -> palm ->
                    fingers read as connected but distinct forms instead
                    of one flat red shape. */}
                <g clipPath="url(#ink-hand-clip)">
                  {INK_PALM_SHADE.map((s, i) => (
                    <ellipse key={i} cx={s.cx} cy={s.cy} rx={s.rx} ry={s.ry} fill="var(--crimson-900, #5c1414)" opacity={s.opacity} />
                  ))}
                </g>
                {/* Thumb joint crease — see INK_THUMB_CREASE. */}
                <path
                  d={`M${INK_THUMB_CREASE.x1},${INK_THUMB_CREASE.y1} Q${INK_THUMB_CREASE.midX},${INK_THUMB_CREASE.midY} ${INK_THUMB_CREASE.x2},${INK_THUMB_CREASE.y2}`}
                  fill="none"
                  stroke="var(--vermilion-700, var(--vermilion-600))"
                  strokeWidth="1.6"
                  strokeOpacity="0.4"
                  strokeLinecap="round"
                />
              </g>
            </g>
          </g>
        </svg>
      </div>
    </div>
  );
}

// A self-contained mobile equivalent of the desktop immersive showcase
// below — deliberately its OWN small component rather than threading a
// "mobile expanded" flag through PortfolioShowcase's much larger
// scroll-pinned/arc/spring machinery, which is built entirely around a
// sticky scroll region that has no equivalent on a phone. Reuses the same
// ShowcaseCard/InkHand pieces (identical artwork, identical card
// silhouette) so it reads as the same experience, just entered by a tap
// instead of a scroll, staged full-screen instead of pinned mid-page, and
// driven by a plain drag-to-nearest-card swipe instead of the desktop
// spring/arc system a phone has no room (or scroll runway) for.
// The mobile Home-page teaser: a continuously-cycling
// [peek][MAIN][peek] strip, auto-advancing on its own — the same overall
// motion language as the desktop scroll-driven showcase (a dominant
// centered piece with neighbors partially visible at each edge), just
// timer-driven instead of scroll- or drag-driven, since a static single
// card read as too static for what's meant to be a living preview of the
// full cinematic showcase one tap away.
function MobileWorkStrip({ list, activeIndex, setActiveIndex, ar, reduced, paused, onExplore }) {
  const n = list.length;
  const tripled = React.useMemo(() => [...list, ...list, ...list], [list]);
  const [trackIndex, setTrackIndex] = React.useState(n + activeIndex);
  const trackRef = React.useRef(null);
  const wrapRef = React.useRef(null);
  const [vw, setVw] = React.useState(() => window.innerWidth);

  React.useEffect(() => {
    const onResize = () => setVw(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  const cardW = Math.round(Math.min(300, vw * 0.72));
  const neighborW = Math.round(cardW * 0.16);
  const gap = 14;

  const currentTrackX = () => {
    const t = trackRef.current;
    if (!t) return 0;
    const cs = getComputedStyle(t).transform;
    if (cs === "none") return 0;
    return new DOMMatrixReadOnly(cs).m41;
  };

  // In Arabic (`dir="rtl"` on <main>), this track — wider than its wrap via
  // `width:max-content` — right-aligns itself in normal flow instead of
  // left-aligning (the whole reason RTL boxes overflow to the left instead
  // of the right), so its untransformed left edge sits at a large NEGATIVE
  // offset, not 0. The old `wrap.clientWidth/2 - card.offsetLeft` formula
  // assumed a zero baseline, which is only true in LTR — in Arabic it threw
  // every card thousands of pixels off-screen, which is why the whole strip
  // was reported as "missing" there. Reading the track's actual on-screen
  // rect and subtracting whatever transform is already applied recovers
  // the real (direction-agnostic) baseline, same fix already used in
  // MobileShowcaseCinema's animateTrackTo for the identical reason.
  const centerCard = (idx, animate) => {
    const track = trackRef.current, wrap = wrapRef.current;
    const card = track && track.children[idx];
    if (!track || !wrap || !card) return;
    const fromX = currentTrackX();
    const cardCenterInTrack = card.offsetLeft + card.offsetWidth / 2;
    const trackRect = track.getBoundingClientRect();
    const trackLeftUntransformed = trackRect.left - fromX;
    const cardCenterUntransformedX = trackLeftUntransformed + cardCenterInTrack;
    const wrapRect = wrap.getBoundingClientRect();
    const targetX = wrapRect.left + wrapRect.width / 2 - cardCenterUntransformedX;
    track.style.transition = animate ? "transform 620ms cubic-bezier(0.22,1,0.36,1)" : "none";
    track.style.transform = `translateX(${targetX}px)`;
  };
  React.useLayoutEffect(() => { centerCard(trackIndex, true); }, [trackIndex]);
  // Toggling language does NOT remount this component (App.jsx keys the
  // page by route, not by lang), so switching en<->ar flips the ancestor's
  // `dir` attribute — and therefore this RTL-sensitive centering math's
  // whole baseline — without `trackIndex` changing. Without this, the
  // transform computed while still in English stayed applied verbatim
  // until the next autoplay tick (up to ~2.2s later) recalculated it,
  // which is exactly the window where the strip appeared to "vanish" right
  // after switching to Arabic. Re-snapping instantly (no transition) here
  // closes that window instead of just narrowing it.
  React.useLayoutEffect(() => { centerCard(trackIndex, false); }, [ar]);
  // trackIndex (an index into the tripled array, so it can keep climbing
  // past either real edge without ever hitting one) is this component's
  // own source of truth; the plain 0..n-1 index the parent needs (to seed
  // the cinema overlay at the right spot) just follows it.
  React.useEffect(() => { setActiveIndex(((trackIndex % n) + n) % n); }, [trackIndex, n, setActiveIndex]);

  const recenter = (idx) => {
    if (idx < Math.floor(n / 2)) return idx + n;
    if (idx >= n + Math.ceil(n / 2)) return idx - n;
    return idx;
  };

  // Auto-advance — a plain setInterval (not rAF: this only ever needs to
  // fire once every few seconds, an rAF loop would just burn a frame
  // budget for nothing) — paused while reduced motion is requested, while
  // the cinema overlay is open above it, or while a card is mid-press.
  const [holding, setHolding] = React.useState(false);
  React.useEffect(() => {
    if (reduced || paused || holding) return;
    const id = setInterval(() => setTrackIndex((i) => recenter(i + 1)), 2200);
    return () => clearInterval(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [reduced, paused, holding, n]);

  const goTo = (idx) => setTrackIndex(recenter(idx));

  const activeItem = list[((trackIndex % n) + n) % n];

  return (
    <section style={{ padding: "0 0 var(--space-8)" }}>
      <div
        ref={wrapRef}
        onPointerDown={() => setHolding(true)}
        onPointerUp={() => setHolding(false)}
        onPointerCancel={() => setHolding(false)}
        style={{ position: "relative", overflow: "hidden" }}
      >
        <div ref={trackRef} style={{ display: "flex", alignItems: "center", gap: `${gap}px`, width: "max-content", willChange: "transform" }}>
          {tripled.map((item, i) => {
            const diff = Math.abs(i - trackIndex);
            const isActive = diff === 0;
            const width = isActive ? cardW : neighborW;
            return (
              <div
                key={i}
                role="button"
                tabIndex={0}
                onClick={(e) => (isActive ? onExplore(e.currentTarget.getBoundingClientRect()) : goTo(i))}
                onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); isActive ? onExplore(e.currentTarget.getBoundingClientRect()) : goTo(i); } }}
                style={{
                  flex: `0 0 ${width}px`, position: "relative", cursor: "pointer",
                  opacity: isActive ? 1 : 0.5,
                  transform: `scale(${isActive ? 1 : 0.92})`,
                  transition: "opacity 500ms ease, transform 500ms cubic-bezier(0.22,1,0.36,1)",
                }}
              >
                {/* video only for cards actually on/near screen (diff<=1 —
                    this component always shows exactly 3 at once) and
                    never under reduced motion, so the other ~69 offscreen
                    copies in the tripled track never mount a <video> at
                    all. Frame itself already renders it autoplay/muted/
                    loop/playsInline (see its own definition in Shell.jsx),
                    which is what actually gets motion projects (previously
                    missing here entirely — they have no `src`, only
                    `video`, so they rendered as blank) showing and moving. */}
                <Frame ratio={MASTER_CARD_RATIO} label={item.tags[0]} src={item.src} video={!reduced && diff <= 1 ? item.video : undefined} fit="cover" position={item.focal} mediaBox={item.zoom && item.zoom < 1 ? mediaZoomBox(item.ratio, item.zoom) : undefined} style={{ width: "100%" }} />
                {isActive ? (
                  <div aria-hidden="true" style={{ position: "absolute", insetInlineEnd: 14, bottom: 14, display: "flex", alignItems: "center", gap: 6, background: "rgba(10,8,8,0.62)", color: "var(--paper-000)", borderRadius: "var(--radius-pill)", padding: "8px 14px", backdropFilter: "blur(4px)" }}>
                    <span className="ink-label">{ar ? "استكشف الأعمال" : "Explore work"}</span>
                    <Icon name="arrow-up-right" size={14} />
                  </div>
                ) : null}
              </div>
            );
          })}
        </div>
      </div>
      <div style={{ marginTop: "var(--space-3)", padding: "0 var(--page-margin)", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
        <div style={{ fontFamily: ar ? "var(--font-arabic)" : "var(--font-body)", fontSize: "var(--size-body-sm)", color: "var(--text-muted)" }}>{activeItem.title}</div>
        <div style={{ flexShrink: 0, display: "inline-flex", alignItems: "center", gap: 4, color: "var(--text-muted)" }}>
          <span className="ink-label">{ar ? "تصفّح" : "Swipe to browse"}</span>
          <Icon name="chevron-right" size={14} style={{ transform: ar ? "scaleX(-1)" : undefined }} />
        </div>
      </div>
    </section>
  );
}

function MobileShowcaseCinema({ list, initialIndex, onClose, ar, reduced, setPage, originRect }) {
  const n = list.length;
  const tripled = React.useMemo(() => [...list, ...list, ...list], [list]);
  const [activeIndex, setActiveIndex] = React.useState(n + initialIndex);
  const [vw, setVw] = React.useState(() => window.innerWidth);
  const wrapRef = React.useRef(null);
  const trackRef = React.useRef(null);
  const dragRef = React.useRef(null);
  const leftPullRef = React.useRef(null);
  const rightPullRef = React.useRef(null);
  const leftHandPathRef = React.useRef(null);
  const rightHandPathRef = React.useRef(null);
  // Outer wrapper for each hand's whole entrance (opacity + descend), and
  // for Zone C's title/counter — distinct from the pull refs above, which
  // only ever carry the live puppet-spring transform. See the entrance
  // effect below for why these get written to directly instead of only
  // through the `entered` state that also drives their JSX style.
  const leftHandZoneRef = React.useRef(null);
  const rightHandZoneRef = React.useRef(null);
  const chromeZoneRef = React.useRef(null);
  // A fixed shared duration the track's own rAF animation and the hand
  // puppet's nav-pulse release both read, so the card settling and the
  // hands relaxing back to idle always land in the same frame instead of
  // drifting apart (one was a CSS-transition on the browser's own clock,
  // the other a physics spring on requestAnimationFrame — close but never
  // exactly co-timed, which is what could read as the hands "snapping"
  // slightly after the card had already stopped).
  const TRACK_ANIM_MS = 320;

  // The exact same puppet-spring rig PortfolioShowcase's desktop cinema
  // uses (applyHandFrame/ensurePuppetLoop/setPuppetTarget/triggerNavPulse,
  // and the same buildHandPath/fingerSweepValue/CARD_TRANSITION_MS module
  // constants it reads from) — ported rather than reinvented, per this
  // round's explicit instruction. Previously this component had its own
  // much simpler direct setHandPull(side, v) with no settle-back on a tap
  // (only drag ever moved it), which is exactly why tapping a neighboring
  // card left the hands completely frozen while the carousel changed
  // underneath them. The spring model fixes that for all three navigation
  // paths (drag, tap-right-neighbor, tap-left-neighbor) at once, since all
  // three now funnel through the same goTo() -> triggerNavPulse() call.
  const puppetRef = React.useRef({
    left: { pull: 0, vel: 0, target: 0, fingerPulseStart: null, fingerPulseSign: 0, fingerSettled: true },
    right: { pull: 0, vel: 0, target: 0, fingerPulseStart: null, fingerPulseSign: 0, fingerSettled: true },
    raf: null,
    lastT: 0,
  });
  const navPulseTimeoutRef = React.useRef(null);

  const applyHandFrame = (side, tMs) => {
    const st = puppetRef.current[side];
    const wrap = (side === "left" ? leftPullRef : rightPullRef).current;
    if (!wrap) return;
    const dir = side === "left" ? 1 : -1;
    const p = st.pull;
    wrap.style.transform = `translate(${(dir * p * 20).toFixed(2)}px, ${(-Math.abs(p) * 9).toFixed(2)}px) rotate(${(dir * p * -8).toFixed(2)}deg) scale(${(1 + p * 0.05).toFixed(3)})`;
    wrap.style.setProperty("--hand-curl", `${(p * -24).toFixed(2)}deg`);
    const pathEl = (side === "left" ? leftHandPathRef : rightHandPathRef).current;
    if (pathEl) {
      const elapsed = st.fingerPulseStart == null ? Infinity : tMs - st.fingerPulseStart;
      if (elapsed <= FINGER_SWEEP_MS + MAX_FINGER_DELAY_MS) {
        const sign = st.fingerPulseSign;
        const index = sign * fingerSweepValue(elapsed) * 26;
        const middle = sign * fingerSweepValue(elapsed - 25) * 19;
        const ring = sign * fingerSweepValue(elapsed - 55) * 13;
        const pinky = sign * fingerSweepValue(elapsed - MAX_FINGER_DELAY_MS) * 8;
        pathEl.setAttribute("d", buildHandPath({ index, middle, ring, pinky }));
        st.fingerSettled = false;
      } else if (!st.fingerSettled) {
        pathEl.setAttribute("d", INK_HAND_PATH);
        st.fingerSettled = true;
      }
    }
  };
  const ensurePuppetLoop = () => {
    if (reduced) return;
    const st = puppetRef.current;
    if (st.raf) return;
    const SPRING_K = 170, SPRING_DAMPING = 15;
    st.lastT = performance.now();
    const tick = (t) => {
      const dt = Math.min(0.05, (t - st.lastT) / 1000);
      st.lastT = t;
      ["left", "right"].forEach((side) => {
        const s = st[side];
        const accel = -SPRING_K * (s.pull - s.target) - SPRING_DAMPING * s.vel;
        s.vel += accel * dt;
        s.pull += s.vel * dt;
        applyHandFrame(side, t);
      });
      st.raf = requestAnimationFrame(tick);
    };
    st.raf = requestAnimationFrame(tick);
  };
  const setPuppetTarget = (side, target) => {
    puppetRef.current[side].target = target;
    ensurePuppetLoop();
  };
  const triggerNavPulse = (dominantSide) => {
    if (reduced) return;
    const counterSide = dominantSide === "left" ? "right" : "left";
    setPuppetTarget(dominantSide, 1);
    setPuppetTarget(counterSide, -0.32);
    const fst = puppetRef.current[dominantSide];
    fst.fingerPulseStart = performance.now();
    fst.fingerPulseSign = -1;
    if (navPulseTimeoutRef.current) window.clearTimeout(navPulseTimeoutRef.current);
    navPulseTimeoutRef.current = window.setTimeout(() => {
      setPuppetTarget(dominantSide, 0);
      setPuppetTarget(counterSide, 0);
      navPulseTimeoutRef.current = null;
    }, TRACK_ANIM_MS);
  };
  React.useEffect(() => { ensurePuppetLoop(); }, [reduced]);
  React.useEffect(() => {
    return () => {
      if (puppetRef.current.raf) cancelAnimationFrame(puppetRef.current.raf);
      if (navPulseTimeoutRef.current) window.clearTimeout(navPulseTimeoutRef.current);
    };
  }, []);

  React.useEffect(() => {
    const onResize = () => setVw(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  // Lock page scroll while the cinema is open — this is a full-screen
  // takeover, not content sitting inline in the normal page flow.
  React.useEffect(() => {
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.body.style.overflow = prev; };
  }, []);
  const cardW = Math.round(Math.min(300, vw * 0.66));
  const neighborW = Math.round(cardW * 0.62);
  const gap = 18;
  const step = cardW + gap;

  const currentTrackX = () => {
    if (!trackRef.current) return 0;
    const t = getComputedStyle(trackRef.current).transform;
    if (t === "none") return 0;
    return new DOMMatrixReadOnly(t).m41;
  };

  // ---- rAF-driven track settle, replacing the old CSS-transition-based
  // centerCard -----------------------------------------------------------
  // All of this animation's geometry reads (offsetLeft/getBoundingClientRect,
  // still needed for the same RTL-safety reason the old centerCard's own
  // comment explained below) happen exactly ONCE, right when a new
  // animation starts — never per frame, and never during a drag — so the
  // frame loop itself is pure writes (transform only). Driving this on the
  // SAME requestAnimationFrame clock as the hand puppet spring above (both
  // now use performance.now()-based elapsed time against TRACK_ANIM_MS)
  // is what keeps "the card settles" and "the hands finish relaxing"
  // landing on the same frame, addressed by this task's "keep the hands
  // and carousel synchronized to ONE transition progress value."
  const trackAnimRef = React.useRef(null);
  const stopTrackAnim = () => {
    if (trackAnimRef.current) { cancelAnimationFrame(trackAnimRef.current); trackAnimRef.current = null; }
  };
  const animateTrackTo = (idx, animate) => {
    const track = trackRef.current, wrap = wrapRef.current;
    const card = track && track.children[idx];
    if (!track || !wrap || !card) return;
    stopTrackAnim();
    // offsetLeft is always a physical (LTR) measurement even under
    // dir="rtl" (this whole cinema renders inside <main dir="rtl"> in
    // Arabic), so it has to be combined with the track's own current
    // ON-SCREEN left edge (its rect minus whatever translateX is already
    // applied) rather than assumed to start flush with the wrap — the same
    // absolute-coordinate approach the desktop showcase's centerCard uses
    // for the identical reason. Using wrap.clientWidth/2 directly (ignoring
    // the track's real screen position) landed thousands of pixels off in
    // RTL specifically, since a plain block child's rendered position
    // relative to its container isn't guaranteed to be the untransformed
    // left edge once cross-browser RTL box alignment is in play.
    const fromX = currentTrackX();
    const cardCenterInTrack = card.offsetLeft + card.offsetWidth / 2;
    const trackRect = track.getBoundingClientRect();
    const trackLeftUntransformed = trackRect.left - fromX;
    const cardCenterUntransformedX = trackLeftUntransformed + cardCenterInTrack;
    const wrapRect = wrap.getBoundingClientRect();
    const toX = wrapRect.left + wrapRect.width / 2 - cardCenterUntransformedX;
    track.style.transition = "none";
    if (!animate || reduced || Math.abs(toX - fromX) < 0.5) {
      track.style.transform = `translateX(${toX}px)`;
      return;
    }
    // Plain cubic ease-out (decelerate only, no overshoot) — a fixed-
    // duration bounce-free curve is what actually reads as "fast and
    // responsive" rather than "abrupt snap" once it settles.
    const start = performance.now();
    const ease = (t) => 1 - Math.pow(1 - t, 3);
    const tick = (now) => {
      const t = Math.min(1, (now - start) / TRACK_ANIM_MS);
      track.style.transform = `translateX(${fromX + (toX - fromX) * ease(t)}px)`;
      trackAnimRef.current = t < 1 ? requestAnimationFrame(tick) : null;
    };
    trackAnimRef.current = requestAnimationFrame(tick);
  };
  // Snaps to position with no animation on the very first mount (the
  // cinematic ENTER transition below owns that motion instead, via the
  // active card's own FLIP transform — animating the whole track's
  // translateX at the same time would fight it); every real navigation
  // afterward animates normally.
  const mountedTrackRef = React.useRef(false);
  React.useLayoutEffect(() => {
    animateTrackTo(activeIndex, mountedTrackRef.current);
    mountedTrackRef.current = true;
  }, [activeIndex]);
  React.useEffect(() => () => stopTrackAnim(), []);

  const recenter = (idx) => {
    if (idx < Math.floor(n / 2)) return idx + n;
    if (idx >= n + Math.ceil(n / 2)) return idx - n;
    return idx;
  };
  // Mirrors PortfolioShowcase's own desktop goTo exactly: figure out which
  // physical screen direction the card is actually leaving toward (flipped
  // under RTL, same as desktop) and fire the nav pulse BEFORE the index
  // changes, so the same hand response fires whether this came from a
  // drag release, a tap on the right neighbor, or a tap on the left one —
  // all three call this one function.
  const goTo = (idx) => {
    const logicalSign = Math.sign(idx - activeIndex);
    if (logicalSign !== 0) {
      const dir = ar ? -logicalSign : logicalSign;
      triggerNavPulse(dir === 1 ? "right" : "left");
    }
    setActiveIndex(recenter(idx));
  };

  const DRAG_THRESHOLD_PX = 6;
  // Pointermove can fire faster than the display actually refreshes on
  // some devices; writing straight to the DOM on every single event (the
  // previous implementation) means the main thread can be asked to lay
  // out/paint more often than it can actually show anything new. Only the
  // LATEST dx before the next paint matters, so a pending rAF just gets
  // its target dx replaced rather than a fresh write firing per event.
  const dragFrameRef = React.useRef(null);
  const onPointerDown = (e) => {
    stopTrackAnim();
    dragRef.current = { startX: e.clientX, dx: 0, baseX: currentTrackX(), dragging: false, pointerId: e.pointerId };
  };
  const applyDragFrame = () => {
    dragFrameRef.current = null;
    const d = dragRef.current;
    if (!d || !d.dragging || !trackRef.current) return;
    trackRef.current.style.transition = "none";
    trackRef.current.style.transform = `translateX(${d.baseX + d.dx}px)`;
    const dragIntent = ar ? d.dx : -d.dx;
    const t = Math.max(-1, Math.min(1, dragIntent / 160));
    setPuppetTarget("right", t >= 0 ? Math.abs(t) : -Math.abs(t) * 0.32);
    setPuppetTarget("left", t >= 0 ? -Math.abs(t) * 0.32 : Math.abs(t));
  };
  const onPointerMove = (e) => {
    const d = dragRef.current;
    if (!d) return;
    d.dx = e.clientX - d.startX;
    if (!d.dragging) {
      if (Math.abs(d.dx) < DRAG_THRESHOLD_PX) return;
      d.dragging = true;
      if (wrapRef.current) wrapRef.current.setPointerCapture && wrapRef.current.setPointerCapture(d.pointerId);
    }
    if (dragFrameRef.current == null) dragFrameRef.current = requestAnimationFrame(applyDragFrame);
  };
  const endDrag = () => {
    if (dragFrameRef.current != null) { cancelAnimationFrame(dragFrameRef.current); dragFrameRef.current = null; }
    if (!reduced) { setPuppetTarget("left", 0); setPuppetTarget("right", 0); }
    const d = dragRef.current;
    dragRef.current = null;
    if (!d) return;
    if (!d.dragging) return;
    const rawSteps = (ar ? d.dx : -d.dx) / step;
    const stepDelta = Math.round(rawSteps);
    if (stepDelta !== 0) goTo(activeIndex + stepDelta);
    else animateTrackTo(activeIndex, true);
  };

  const openWork = () => setPage("Work");
  const active = list[((activeIndex % n) + n) % n];

  // ---- Cinematic ENTER / EXIT choreography -----------------------------
  // `entered` starts true when there's nothing to animate FROM (reduced
  // motion, or this cinema opened without a captured origin card) — the
  // dialog just appears with its existing plain fade, exactly as before.
  // Otherwise it flips true once the origin-card FLIP transform (below)
  // has been armed, which is what reveals the hands/title stagger.
  const [entered, setEntered] = React.useState(!originRect || reduced);
  const [closing, setClosing] = React.useState(false);
  const closeTimeoutRef = React.useRef(null);

  React.useLayoutEffect(() => {
    if (reduced || !originRect) return;
    const card = trackRef.current && trackRef.current.children[activeIndex];
    if (!card) { setEntered(true); return; }
    // By this point the mount-time animateTrackTo(activeIndex, false) two
    // effects up has already snapped the track to its resting, centered
    // position (effects fire in declaration order within one commit), so
    // this card's rect IS its true final on-screen position — the FLIP
    // "first/last" measurement needs nothing more than this one read.
    const finalRect = card.getBoundingClientRect();
    const dx = (originRect.left + originRect.width / 2) - (finalRect.left + finalRect.width / 2);
    const dy = (originRect.top + originRect.height / 2) - (finalRect.top + finalRect.height / 2);
    const scale = Math.max(0.12, Math.min(1, originRect.width / Math.max(1, finalRect.width)));
    card.style.transition = "none";
    card.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
    card.style.opacity = "0.7";
    // Reading layout back here forces the browser to commit the origin
    // pose as its own frame before the transition-to-rest is requested —
    // without it, both style writes can coalesce into a single frame and
    // the animation never plays at all.
    void card.getBoundingClientRect();
    requestAnimationFrame(() => {
      card.style.transition = "transform 620ms cubic-bezier(0.16,1,0.3,1), opacity 420ms ease";
      card.style.transform = "translate(0px, 0px) scale(1)";
      card.style.opacity = "1";
      // Write the hands'/title's own "shown" styles straight to the DOM
      // here too, in the SAME frame as the card's transition-to-rest,
      // rather than relying solely on the `setEntered(true)` below. That
      // state update's DOM effect only lands once React re-renders and
      // commits — and on a first mount this heavy (a freshly-mounted
      // dialog with its ~80-item tripled carousel track) competing with
      // the transition just kicked off above, that commit could lag a
      // frame or more behind. That lag is what read as the scene
      // freezing and the hands then suddenly popping in rather than
      // descending smoothly: the card would already be mid-flight while
      // the hands were still sitting at their pre-entrance (hidden)
      // style, waiting on a render that hadn't landed yet. Setting the
      // same target styles directly means the hands start moving in this
      // exact frame regardless of how long React then takes to catch up
      // — its later re-render (once `entered` flips) just reaffirms the
      // identical values, so there's nothing for it to visibly change.
      const handTransitionNow = "opacity 380ms ease 160ms, transform 420ms cubic-bezier(0.22,1,0.36,1) 160ms";
      const chromeTransitionNow = "opacity 380ms ease 260ms, transform 380ms cubic-bezier(0.22,1,0.36,1) 260ms";
      if (leftHandZoneRef.current) {
        leftHandZoneRef.current.style.transition = handTransitionNow;
        leftHandZoneRef.current.style.opacity = "1";
        leftHandZoneRef.current.style.transform = "translateY(0px) scale(0.72) rotate(-13deg)";
      }
      if (rightHandZoneRef.current) {
        rightHandZoneRef.current.style.transition = handTransitionNow;
        rightHandZoneRef.current.style.opacity = "1";
        rightHandZoneRef.current.style.transform = "translateY(0px) scale(0.72) rotate(13deg)";
      }
      if (chromeZoneRef.current) {
        chromeZoneRef.current.style.transition = chromeTransitionNow;
        chromeZoneRef.current.style.opacity = "1";
        chromeZoneRef.current.style.transform = "translateY(0px)";
      }
      setEntered(true);
    });
    // Deliberately mount-only: this is a one-shot entrance, never re-armed
    // by a later activeIndex change.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Closing plays the reverse beat (hands retreat, title fades, the active
  // card eases back down) before actually telling the parent to unmount —
  // so scroll-lock cleanup (the effect above) still only ever fires once
  // the dialog is truly gone, never mid-animation.
  const requestClose = () => {
    if (reduced) { onClose(); return; }
    setClosing(true);
    closeTimeoutRef.current = window.setTimeout(onClose, 520);
  };
  React.useEffect(() => () => { if (closeTimeoutRef.current) window.clearTimeout(closeTimeoutRef.current); }, []);
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") requestClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const showChrome = entered && !closing;
  const handTransition = reduced
    ? "none"
    : closing
    ? "opacity 280ms ease, transform 320ms cubic-bezier(0.3,0,0.6,1)"
    : "opacity 380ms ease 160ms, transform 420ms cubic-bezier(0.22,1,0.36,1) 160ms";
  const chromeTransition = reduced
    ? "none"
    : closing
    ? "opacity 240ms ease, transform 280ms ease"
    : "opacity 380ms ease 260ms, transform 380ms cubic-bezier(0.22,1,0.36,1) 260ms";

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-label={ar ? "استعراض الأعمال" : "Work showcase"}
      style={{
        position: "fixed", inset: 0, zIndex: 70,
        background: "linear-gradient(160deg, #171313 0%, #100d0d 48%, #0a0808 100%)",
        overflow: "hidden",
        opacity: closing ? 0 : 1,
        transition: closing ? "opacity 480ms ease" : undefined,
        animation: reduced ? "ink-cinema-fade 200ms ease" : "ink-cinema-fade 420ms ease",
      }}
    >
      <style>{`
        /* Plain opacity-only backdrop fade — the "page gradually darkens"
           beat. The card's own FLIP transform (see the entrance effect
           above) is what actually carries the "selected project grows
           into place" motion now, so this no longer also scales the whole
           dialog the way it used to: layering two independent scale
           animations on top of each other (one on the dialog, one on the
           card) read as busier than either alone. */
        @keyframes ink-cinema-fade { from { opacity: 0; } to { opacity: 1; } }
        /* InkHand's own idle-sway keyframes — this cinema uses the real
           component (see the comment where it's rendered below), and since
           it renders instead of PortfolioShowcase's desktop JSX rather than
           alongside it, that JSX's own copy of these two keyframes never
           reaches the page on mobile. Duplicating them here (verbatim) is
           what actually gets InkHand's built-in animations running. */
        @keyframes ink-hand-idle {
          0%, 100% { transform: translateY(0) rotate(0deg) scale(1); }
          50% { transform: translateY(-18px) rotate(calc(var(--hand-dir, 1) * -6deg)) scale(1.025); }
        }
        @keyframes ink-wrist-drift {
          0%, 100% { transform: translate(0, 0) rotate(0deg); }
          33% { transform: translate(-2px, 3px) rotate(-2.4deg); }
          68% { transform: translate(1.5px, -1px) rotate(1.6deg); }
        }
      `}</style>
      {/* Ambient glow, transform-only: the desktop/section version of this
          same `.ink-blob` also morphs its own border-radius, but doing
          that on a large blur()'d layer forces a full repaint of the
          blurred result every frame — directly competing with this
          dialog's own entrance transition (card FLIP + hands) for frame
          budget right when it matters most, which is exactly the kind of
          "large blur/filter animation" iOS Safari drops frames on. Kept
          only the drift (a plain translate, compositor-friendly and
          cheap even under a blur filter since the shape itself never
          changes) — still reads as alive, costs nothing extra to paint. */}
      <div aria-hidden="true" style={{ position: "absolute", width: "70vw", height: "60%", left: "-20vw", top: "-10%", borderRadius: "var(--radius-blob)", background: "radial-gradient(circle, rgba(112,26,58,0.34) 0%, rgba(112,26,58,0) 68%)", filter: "blur(50px)", animation: reduced ? "none" : "ink-drift 50s var(--ease-standard) infinite" }} />
      <div aria-hidden="true" style={{ position: "absolute", width: "70vw", height: "55%", right: "-20vw", bottom: "-10%", borderRadius: "var(--radius-blob)", background: "radial-gradient(circle, rgba(52,58,132,0.26) 0%, rgba(52,58,132,0) 70%)", filter: "blur(56px)", animation: reduced ? "none" : "ink-drift 56s var(--ease-standard) infinite -14s" }} />

      <button
        data-cursor
        onClick={requestClose}
        aria-label={ar ? "إغلاق" : "Close"}
        style={{ position: "absolute", top: "max(16px, env(safe-area-inset-top))", insetInlineEnd: 16, zIndex: 3, width: 40, height: 40, borderRadius: "var(--radius-pill)", border: "var(--rule-weight-strong) solid rgba(255,255,255,0.5)", background: "rgba(0,0,0,0.35)", color: "var(--paper-000)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}
      >
        <Icon name="x" size={18} />
      </button>

      {/* Three explicit vertical zones filling the WHOLE dialog, not just a
          center-justified card with incidental space above/below it —
          Zone A (hands, in the large empty dark area above the images),
          Zone B (the card carousel itself), Zone C (counter/title/swipe,
          using the remaining lower space rather than sitting in a huge
          accidental dead zone under it). A/C share the leftover space via
          flex:1 each — the same total space the old single
          justify-content:center gave the card, just split into two
          zones with content of their own instead of one bare gap. */}
      <div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column" }}>
        {/* Zone A — large empty dark area above the images. The hands live
            here, bottom-anchored (see InkHand's topOffset={-420} below,
            which lands the art's own fingertips exactly at THIS zone's own
            bottom edge regardless of how tall the zone ends up being on a
            given phone) so they read as reaching DOWN out of the darkness
            toward the carousel, never overlapping or sitting level with
            it. overflow:hidden crops the sleeve's own soft upward fade
            cleanly if a short viewport gives this zone less room. */}
        {/* InkHand's own root positions itself via `[side]: inset` — for
            side="left" that's a literal CSS `left`, which extends the
            310px-wide box INWARD (rightward) from wherever this wrapper
            anchors it. A wide desktop stage swallows that inward reach with
            room to spare; on a ~390px phone it was walking the two hands
            almost all the way across the screen and fusing them into one
            shape in the middle. A negative inset pulls each hand's anchor
            back out toward its own edge first, so the same inward reach
            lands with the fingers meeting (not fully overlapping) near
            center instead. */}
        <div aria-hidden="true" style={{ position: "relative", flex: "1 1 0%", overflow: "hidden", zIndex: 2, pointerEvents: "none" }}>
          {/* Entrance: hidden and shifted up (translateY) until `showChrome`
              flips true — with a short delay baked into handTransition so
              they only start descending once the card's own FLIP-grow is
              already under way, per this task's "only after that
              transition has started" ordering. Exit reverses the same
              opacity/position with no delay, reading as a quick retreat. */}
          <div ref={leftHandZoneRef} style={{ position: "absolute", bottom: 0, left: 0, opacity: showChrome ? 1 : 0, transform: `translateY(${showChrome ? 0 : -70}px) scale(0.72) rotate(-13deg)`, transition: handTransition }}>
            <InkHand side="left" role="idle" reduced={reduced} inset={-34} topOffset={-420} pullRef={leftPullRef} pathRef={leftHandPathRef} />
          </div>
          <div ref={rightHandZoneRef} style={{ position: "absolute", bottom: 0, right: 0, opacity: showChrome ? 1 : 0, transform: `translateY(${showChrome ? 0 : -70}px) scale(0.72) rotate(13deg)`, transition: handTransition }}>
            <InkHand side="right" role="idle" reduced={reduced} inset={-34} topOffset={-420} pullRef={rightPullRef} pathRef={rightHandPathRef} />
          </div>
        </div>

        {/* Zone B — the project carousel, unchanged layout/behavior. */}
        <div
          ref={wrapRef}
          style={{ position: "relative", zIndex: 1, flex: "0 0 auto", width: "100%", overflow: "hidden", touchAction: "pan-y", cursor: "grab" }}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={endDrag}
          onPointerCancel={endDrag}
        >
          <div
            ref={trackRef}
            style={{ display: "flex", alignItems: "center", gap: `${gap}px`, width: "max-content", willChange: "transform" }}
          >
            {tripled.map((item, i) => {
              const diff = Math.abs(i - activeIndex);
              const isActive = i === activeIndex;
              const width = diff === 0 ? cardW : neighborW;
              // The active card's own closing beat — "the selected card
              // visually returns toward the page" — approximated as a
              // quick shrink+fade rather than a full reverse-FLIP back to
              // the exact origin rect (which may no longer even be on
              // screen if the page scrolled while the cinema was open).
              const closingActive = isActive && closing;
              return (
                <div
                  key={i}
                  onClick={() => (isActive ? openWork() : goTo(i))}
                  role="button"
                  tabIndex={0}
                  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); isActive ? openWork() : goTo(i); } }}
                  style={{
                    flex: `0 0 ${width}px`,
                    borderRadius: "var(--radius-lg)",
                    cursor: "pointer",
                    opacity: closingActive ? 0 : (isActive ? 1 : 0.55),
                    transform: `scale(${closingActive ? 0.9 : (isActive ? 1 : 0.92)})`,
                    transition: closingActive ? "opacity 420ms ease, transform 420ms ease" : "opacity 360ms ease, transform 360ms cubic-bezier(0.22,1,0.36,1)",
                  }}
                >
                  <Frame ratio={MASTER_CARD_RATIO} label={item.tags[0]} src={item.src} video={!reduced && diff <= 1 ? item.video : undefined} fit="cover" position={item.focal} mediaBox={item.zoom && item.zoom < 1 ? mediaZoomBox(item.ratio, item.zoom) : undefined} style={{ width: "100%" }} />
                </div>
              );
            })}
          </div>
        </div>

        {/* Zone C — counter/title/swipe cue, centered in the remaining
            lower space (rather than glued to Zone B's bottom edge with
            everything below it left as dead air) so the composition reads
            as intentionally filling the full phone viewport. */}
        <div ref={chromeZoneRef} style={{ position: "relative", zIndex: 1, flex: "1 1 0%", display: "flex", flexDirection: "column", justifyContent: "center", textAlign: "center", padding: "var(--space-6) var(--page-margin) 0", opacity: showChrome ? 1 : 0, transform: `translateY(${showChrome ? 0 : 10}px)`, transition: chromeTransition }}>
          <div dir="ltr" className="ink-label" style={{ color: "rgba(255,255,255,0.55)" }}>{`${((activeIndex % n) + n) % n + 1} / ${n}`}</div>
          <div style={{ marginTop: "var(--space-2)", color: "var(--paper-000)", fontFamily: ar ? "var(--font-arabic)" : "var(--font-display)", fontWeight: ar ? 700 : "var(--weight-title)", fontSize: "var(--size-body-lg)" }}>{active.title}</div>
          <div style={{ marginTop: "var(--space-4)", display: "flex", justifyContent: "center", gap: "var(--space-2)", color: "rgba(255,255,255,0.5)", alignItems: "center" }}>
            <Icon name={ar ? "chevron-right" : "chevron-left"} size={14} />
            <span className="ink-label">{ar ? "اسحب للتصفح" : "Swipe to browse"}</span>
            <Icon name={ar ? "chevron-left" : "chevron-right"} size={14} />
          </div>
        </div>
      </div>
    </div>
  );
}

function PortfolioShowcase({ ar, setPage }) {
  const reduced = useReducedMotion();
  const narrow = useIsNarrow();
  const viewport = useViewportSize();
  const list = React.useMemo(() => interleaveShowcase((window.PROJECTS || []).slice()), []);
  const n = list.length;
  const tripled = React.useMemo(() => [...list, ...list, ...list], [list]);
  const [mobileExpanded, setMobileExpanded] = React.useState(false);
  const [mobileTeaserIndex, setMobileTeaserIndex] = React.useState(0);
  // The tapped card's own on-screen rect at the moment of the tap — captured
  // synchronously in the click handler (before anything else changes) so the
  // cinema's entrance can grow FROM that exact spot rather than just
  // fading in over an unrelated position. Cleared on close isn't necessary:
  // the next open always overwrites it before the cinema reads it.
  const [mobileExploreOrigin, setMobileExploreOrigin] = React.useState(null);

  const scrollWrapRef = React.useRef(null);
  const stickyRef = React.useRef(null);
  const trackRef = React.useRef(null);
  const dragWrapRef = React.useRef(null);
  const startedAtRef = React.useRef(Date.now());
  const dragRef = React.useRef(null);
  const justDraggedRef = React.useRef(false);
  // Puppet spring: each hand's live "pull" (how hard it's gripping/pulling
  // its strings taut, roughly -0.35..1.2 with spring overshoot) is a plain
  // mutable ref, NOT React state — it's written every rAF frame straight
  // onto the DOM (pullRef's transform/--hand-curl, each string's d/opacity),
  // exactly like the reel's own --immersion and drift math elsewhere in this
  // component. `target` is set from drag position (continuously) or from a
  // brief nav/autoplay pulse (see triggerNavPulse); the spring itself
  // supplies the anticipation/overshoot/settle, so callers only ever set
  // where the hand should be heading, never how it gets there.
  const leftPullRef = React.useRef(null);
  const rightPullRef = React.useRef(null);
  const leftHandPathRef = React.useRef(null);
  const rightHandPathRef = React.useRef(null);
  const puppetRef = React.useRef({
    left: { pull: 0, vel: 0, target: 0, fingerPulseStart: null, fingerPulseSign: 0, fingerSettled: true },
    right: { pull: 0, vel: 0, target: 0, fingerPulseStart: null, fingerPulseSign: 0, fingerSettled: true },
    raf: null,
    lastT: 0,
  });
  const navPulseTimeoutRef = React.useRef(null);

  const applyHandFrame = (side, tMs) => {
    const st = puppetRef.current[side];
    const wrap = (side === "left" ? leftPullRef : rightPullRef).current;
    if (!wrap) return;
    const dir = side === "left" ? 1 : -1;
    const p = st.pull;
    wrap.style.transform = `translate(${(dir * p * 20).toFixed(2)}px, ${(-Math.abs(p) * 9).toFixed(2)}px) rotate(${(dir * p * -8).toFixed(2)}deg) scale(${(1 + p * 0.05).toFixed(3)})`;
    wrap.style.setProperty("--hand-curl", `${(p * -24).toFixed(2)}deg`);

    // Finger "telekinetic push" — layered on top of the arm/wrist motion
    // just above, unchanged. See fingerSweepValue for the curve: ONE push
    // DOWN (in lockstep with the card's own transition — gathered ->
    // opening -> strongest downward reach right as the card arrives), then
    // ONE ease back up through rest with a very small overshoot above it
    // (see fingerSettleValue), then settle — never reversing more than
    // that. ALL FOUR fingers participate — index leads at full amplitude,
    // middle close behind, ring and pinky progressively less but still
    // clearly moving — so the whole hand reads as one fanning-open gesture,
    // not two fingers twitching. The small per-finger delays are what make
    // the fan open progressively (index first) rather than all four
    // snapping together. Sign is always -1 here — see triggerNavPulse for
    // exactly why that's "down" for either hand (unlike the arm's own
    // per-side `dir` above). Only touches the DOM while a sweep is
    // actually in flight; once it settles back to rest we write the exact
    // rest path once and then stop, so an idle hand costs nothing
    // extra.
    const pathEl = (side === "left" ? leftHandPathRef : rightHandPathRef).current;
    if (pathEl) {
      const elapsed = st.fingerPulseStart == null ? Infinity : tMs - st.fingerPulseStart;
      // Pinky's delay (see MAX_FINGER_DELAY_MS) shifts its own copy of the
      // curve later, so its full ease-back finishes MAX_FINGER_DELAY_MS
      // after the un-shifted FINGER_SWEEP_MS — the outer cutoff has to
      // wait for that too, or the last-delayed fingers would snap straight
      // to rest instead of easing out.
      if (elapsed <= FINGER_SWEEP_MS + MAX_FINGER_DELAY_MS) {
        const sign = st.fingerPulseSign;
        const index = sign * fingerSweepValue(elapsed) * 26;
        const middle = sign * fingerSweepValue(elapsed - 25) * 19;
        const ring = sign * fingerSweepValue(elapsed - 55) * 13;
        const pinky = sign * fingerSweepValue(elapsed - MAX_FINGER_DELAY_MS) * 8;
        pathEl.setAttribute("d", buildHandPath({ index, middle, ring, pinky }));
        st.fingerSettled = false;
      } else if (!st.fingerSettled) {
        pathEl.setAttribute("d", INK_HAND_PATH);
        st.fingerSettled = true;
      }
    }
  };

  // Runs continuously (not just while the spring is mid-motion) — unchanged
  // from before the string system's removal, so drag/nav/idle motion timing
  // stays exactly as tuned. Reduced-motion never starts it at all.
  const ensurePuppetLoop = () => {
    if (reduced) return;
    const st = puppetRef.current;
    if (st.raf) return;
    const SPRING_K = 170, SPRING_DAMPING = 15;
    st.lastT = performance.now();
    const tick = (t) => {
      const dt = Math.min(0.05, (t - st.lastT) / 1000);
      st.lastT = t;
      ["left", "right"].forEach((side) => {
        const s = st[side];
        const accel = -SPRING_K * (s.pull - s.target) - SPRING_DAMPING * s.vel;
        s.vel += accel * dt;
        s.pull += s.vel * dt;
        applyHandFrame(side, t);
      });
      st.raf = requestAnimationFrame(tick);
    };
    st.raf = requestAnimationFrame(tick);
  };

  const setPuppetTarget = (side, target) => {
    puppetRef.current[side].target = target;
    ensurePuppetLoop();
  };

  // Dominant hand gets a brief pull toward 1 (curl/tighten), the other a
  // gentle release toward slack, then both relax back to neutral — the
  // "pull -> follow-through -> settle" beat for a nav step or autoplay
  // advance. Re-triggering (rapid direction changes) simply re-targets the
  // spring mid-flight rather than restarting an animation, so it always
  // reads as one continuous motion changing direction, never a hard cut.
  const triggerNavPulse = (dominantSide) => {
    if (reduced) return;
    const counterSide = dominantSide === "left" ? "right" : "left";
    setPuppetTarget(dominantSide, 1);
    setPuppetTarget(counterSide, -0.32);
    // The four-finger telekinetic push (see fingerSweepValue / applyHandFrame)
    // is a separate, precisely-timed beat layered on top of the arm's own
    // spring above — only the hand actually sending the card gestures; the
    // other hand's fingers stay at rest. fingerPulseSign is -1: each
    // finger's rest `angle` (index 208°, middle 172°, ring 148°, pinky
    // 118°) already points up-and-out, and SUBTRACTING from it (not
    // adding) is what sweeps every fingertip toward larger, more-positive
    // y — i.e. DOWN on screen, since SVG y grows downward — confirmed by
    // computing each finger's actual tip position at rest vs. at its
    // rotated angle. Adding (the old +1) swung them up instead, which read
    // as fighting the hand's own downward arm motion; that's the reversed
    // motion this round fixes. Unlike the arm's own translate (a plain CSS
    // transform on an ancestor OUTSIDE the hand's own mirrored SVG, which
    // needs the `side === "left" ? 1 : -1` flip to read the same on both
    // sides), the finger's angle offset is baked straight into the path
    // geometry INSIDE that mirrored SVG, and mirroring (scaleX) never
    // touches the y axis — so this same -1 reads as "down" for EITHER
    // hand, no per-side flip needed.
    const fst = puppetRef.current[dominantSide];
    fst.fingerPulseStart = performance.now();
    fst.fingerPulseSign = -1;
    if (navPulseTimeoutRef.current) window.clearTimeout(navPulseTimeoutRef.current);
    navPulseTimeoutRef.current = window.setTimeout(() => {
      setPuppetTarget(dominantSide, 0);
      setPuppetTarget(counterSide, 0);
      navPulseTimeoutRef.current = null;
    }, 260);
  };

  // Starts the continuous puppet loop on mount (rather than only when a
  // target first changes) so the hands are ready to react from the very
  // first frame, not just after the first drag/nav.
  React.useEffect(() => {
    ensurePuppetLoop();
  }, [reduced]);

  React.useEffect(() => {
    return () => {
      if (puppetRef.current.raf) cancelAnimationFrame(puppetRef.current.raf);
      if (navPulseTimeoutRef.current) window.clearTimeout(navPulseTimeoutRef.current);
    };
  }, []);

  const [mode, setMode] = React.useState("reel");
  const [activeIndex, setActiveIndex] = React.useState(n); // index into `tripled`, starts at the first card of the middle copy
  const modeRef = React.useRef("reel");
  React.useEffect(() => { modeRef.current = mode; }, [mode]);

  // ---- Hand choreography trigger ------------------------------------------
  // `navDir` is the physical (screen) direction the just-departed card left
  // toward: +1 means it exited left / the new one arrived from the right
  // (a "next" step in LTR, mirrored in RTL since screen direction is what
  // the hands react to, not logical direction). `navPulse` increments on
  // every centering change; giving the hands wrapper `key={navPulse}` below
  // remounts it each time, which is what restarts its CSS choreography
  // animation cleanly rather than trying to reset mid-flight.
  const [navDir, setNavDir] = React.useState(0);
  const [navPulse, setNavPulse] = React.useState(0);
  const lastInteractionRef = React.useRef(Date.now());
  const markInteraction = () => { lastInteractionRef.current = Date.now(); };

  // ---- Ambient light that follows the active piece ------------------------
  // Two glow layers ping-ponging which one is visible: whenever the active
  // item's accent resolves, it's written into the currently-HIDDEN layer,
  // which then fades in as the other fades out — a smooth crossfade between
  // arbitrary colors using plain opacity transitions (no browser support
  // dependency, unlike animating gradient colors directly).
  const accentCacheRef = React.useRef(new Map());
  const [glow, setGlow] = React.useState({ a: SHOWCASE_DEFAULT_ACCENT, b: SHOWCASE_DEFAULT_ACCENT, show: "a" });
  React.useEffect(() => {
    if (narrow) return;
    const item = tripled[activeIndex];
    if (!item) return;
    let cancelled = false;
    const applyAccent = (accent) => {
      if (cancelled) return;
      setGlow((prev) => ({ ...prev, [prev.show === "a" ? "b" : "a"]: accent, show: prev.show === "a" ? "b" : "a" }));
    };
    if (item.video || !item.src) { applyAccent(SHOWCASE_DEFAULT_ACCENT); return; }
    const cache = accentCacheRef.current;
    if (cache.has(item.src)) { applyAccent(cache.get(item.src)); return; }
    extractAmbientAccent(item.src, (accent) => {
      const resolved = accent || SHOWCASE_DEFAULT_ACCENT;
      cache.set(item.src, resolved);
      applyAccent(resolved);
    });
    return () => { cancelled = true; };
  }, [activeIndex, narrow, tripled]);

  const cardW = narrow ? 208 : 280;
  const gap = narrow ? 16 : 24;
  const step = cardW + gap;
  const cycleW = n * step;
  const durationSec = 90;

  // Focused-mode spacing is wider than the passive reel's compact gap — the
  // active card scales up well past its flow width, so it needs a bigger
  // center-to-center distance from its neighbors or it visually overlaps them.
  const activeScale = 1.42;
  const neighborScale = 0.88;
  const farScale = 0.76;
  const breathing = narrow ? 0 : 44;

  // Arc/depth: pieces away from the active center rotate inward (toward
  // center and the viewer) and recede in Z, continuously with distance
  // (capped), so the row reads as a true convex ring curving away on both
  // sides — the active piece alone sits at rotate 0 / z 0, forward-facing
  // and closest, the apex of the curve.
  const ARC_ROTATE_STEP = 20;
  const ARC_ROTATE_MAX = 46;
  const ARC_Z_STEP = 110;
  const ARC_Z_MAX = 280;

  // The gallery moment should fill essentially the whole viewport — not
  // leave visible white strips above/below it — so the sticky box now
  // reaches almost full viewport height, clearing only the fixed nav bar
  // above and a small breathing margin below.
  const NAV_CLEARANCE = 88;
  const BOTTOM_MARGIN = 20;
  const stickyHpx = Math.min(viewport.h * 0.94, viewport.h - NAV_CLEARANCE - BOTTOM_MARGIN, 900);
  // Centers the sticky box vertically in the viewport when there's room for
  // that (short/normal viewports fill almost edge to edge already, so this
  // mostly just clamps to clear the nav) — the showcase reaches its
  // resting, pinned position exactly when it's vertically centered, instead
  // of pinning near the top and leaving the user to keep scrolling after it
  // already looks centered.
  const stickyTopPx = Math.max(NAV_CLEARANCE, viewport.h / 2 - stickyHpx / 2);
  // At rest (--immersion: 0, freshly loaded, no scroll yet) the row sits
  // vertically centered in a sticky box that's nearly the full viewport
  // tall, which reads as "stuck at the bottom, barely visible" when the
  // Hero content above it doesn't fill the screen. Lifting the row upward
  // by this many px — fading back to 0 as --immersion climbs toward the
  // fully centered immersive layout it's already tuned for — pulls it into
  // that unused lower-Hero space without touching the sticky/pin geometry
  // itself (still needed unchanged for the scroll-driven immersion math)
  // or the Hero content above it.
  const reelLiftPx = narrow ? 0 : Math.min(stickyHpx * 0.3, 190);

  // ONE unified card silhouette (MASTER_CARD_RATIO, 4:3) for EVERY card in
  // focused mode — active, near neighbor and far cards all share the exact
  // same shape; only the maximum box each role is allowed to reach differs,
  // so the active piece still clearly dominates. These are the FINAL
  // on-screen WIDTHS each role reaches at full immersion (height always
  // just follows from width via the fixed master ratio, never set
  // independently); base (pre-scale) width is derived so that base ×
  // role-scale lands exactly on that role's target once the continuous
  // scale-up completes. Width is capped more conservatively than height
  // would allow on its own (the sticky box is now tall) — a too-wide
  // active card pushes the near neighbor far enough toward the edge to
  // collide with the arrow buttons' hit region there — and ALSO capped by
  // the sticky box's own vertical budget (÷ master ratio) so a very
  // short/wide viewport can never clip a card's top or bottom.
  const MASTER_RATIO_NUM = 4 / 3; // matches MASTER_CARD_RATIO
  const activeFinalMaxW = Math.min(Math.min(viewport.w * 0.36, 480), stickyHpx * 0.86 * MASTER_RATIO_NUM);
  const nearFinalMaxW = Math.min(Math.min(viewport.w * 0.24, 300), stickyHpx * 0.62 * MASTER_RATIO_NUM);
  const farFinalMaxW = Math.min(Math.min(viewport.w * 0.15, 200), stickyHpx * 0.48 * MASTER_RATIO_NUM);
  const roleFor = (diff) => (
    diff === 0 ? { maxW: activeFinalMaxW, scale: activeScale }
    : diff === 1 ? { maxW: nearFinalMaxW, scale: neighborScale }
    : { maxW: farFinalMaxW, scale: farScale }
  );
  const baseWidthFor = (diff) => roleFor(diff).maxW / roleFor(diff).scale;

  // Sized for the widest the active and near pieces can ever get, so spacing
  // stays generous for every asset shape — narrower (portrait) pieces just
  // end up with a little extra breathing room either side.
  const focusedGap = narrow ? gap : Math.max(gap, Math.round((activeFinalMaxW + nearFinalMaxW) / 2 - cardW) + breathing);
  const focusedStep = cardW + focusedGap;

  // Where the hands sit horizontally: a "middle ground" — not hugging the
  // far viewport edges (read as controlling from off in the corners), and
  // not so far inward the two hands crowd together above the center card
  // (read as one merged mass). Each hand is anchored to sit roughly above
  // the GAP between the active (center) card and its own near-side
  // neighbour — literally between them, so the composition reads as
  // LEFT CARD · LEFT HAND-space · CENTER CARD · RIGHT HAND-space · RIGHT
  // CARD, with each hand framing the center card from its own side.
  // handPalmReachPx: the fixed local distance (px, at this div's own
  // 310/350 viewBox scale) from the hand div's own outer edge to the
  // palm's rough visual center — measured from the actual hand geometry
  // (INK_HAND_PATH), not guessed, so the target below lands the palm
  // itself over the card gap, not just the div's much wider bounding box.
  const handPalmReachPx = 180;
  const activeCardEdge = activeFinalMaxW / 2;
  const nearCardEdge = focusedStep - nearFinalMaxW / 2;
  const cardGapMidpoint = (activeCardEdge + nearCardEdge) / 2;
  const handInsetPx = Math.max(4, viewport.w / 2 - cardGapMidpoint - handPalmReachPx);

  if (!n) return null;

  const openWork = () => setPage("Work");

  // ---- Focused-mode navigation (arrows, drag/swipe, wrap) ----
  const recenter = (idx) => {
    // Keep the active index inside the middle copy so there's always room to
    // navigate further in either direction without ever hitting a real edge.
    if (idx < Math.floor(n / 2)) return idx + n;
    if (idx >= n + Math.ceil(n / 2)) return idx - n;
    return idx;
  };
  const goTo = (idx) => {
    const logicalSign = Math.sign(idx - activeIndex);
    if (logicalSign !== 0) {
      // Same screen-direction convention used for the arc's own rotation
      // sign elsewhere in this component: array order mirrors physically in
      // RTL, so the sign flips there too — the hands react to which side of
      // the screen a card actually leaves/arrives from, not array order.
      const dir = ar ? -logicalSign : logicalSign;
      setNavDir(dir);
      setNavPulse((p) => p + 1);
      triggerNavPulse(dir === 1 ? "right" : "left");
    }
    markInteraction();
    setActiveIndex(recenter(idx));
  };
  const goNext = () => goTo(activeIndex + 1);
  const goPrev = () => goTo(activeIndex - 1);

  // ---- Passive reel: freeze the CSS-driven drift into a focused index, and
  // resume it later from wherever the user left off — one continuous position,
  // just handed between automatic and manual control. ----
  const freezeToFocused = () => {
    const elapsed = (Date.now() - startedAtRef.current) % (durationSec * 1000);
    const posInCycle = (elapsed / (durationSec * 1000)) * n;
    setActiveIndex(n + Math.round(posInCycle) % n);
    setMode("focused");
  };
  const resumeReel = () => {
    const offsetInCycle = ((activeIndex - n) % n + n) % n;
    const fraction = offsetInCycle / n;
    // A CSS animation restarts its own timeline the moment it newly applies to
    // an element. Set a matching negative delay first so it visually resumes
    // from exactly where manual navigation left off, instead of jumping back
    // to the start of the loop.
    if (trackRef.current) trackRef.current.style.animationDelay = `-${(fraction * durationSec).toFixed(3)}s`;
    startedAtRef.current = Date.now() - fraction * durationSec * 1000;
    setMode("reel");
  };

  // ---- Scroll-driven immersion progress (rAF-batched, direct style writes —
  // no per-frame React state) ----
  React.useEffect(() => {
    if (narrow) return;
    let raf = null;
    // Progress is measured from how close the showcase is to its resting,
    // vertically-centered position (`approach`, in px — positive while still
    // scrolling in, 0 exactly at rest) rather than from how far it's been
    // pinned. It starts climbing SHOWCASE_LEAD px before rest (ordinary
    // scroll that's happening anyway as the row comes into view — not extra
    // distance), and only needs SHOWCASE_PIN_RANGE px more past rest to
    // finish, so the gallery is already ~90% built by the time the row is
    // actually centered, and finishes almost immediately after.
    const SUM = SHOWCASE_LEAD + SHOWCASE_PIN_RANGE;
    const enter = 0.85, exit = 0.65;
    // --immersion drives the whole dark-stage scene (room, hands, card
    // scale/gap, glow) as one coherent crossfade, and used to track
    // `progress` 1:1 — a straight line across the whole approach distance.
    // That meant scrolling back out toward the hero, the room started
    // visibly washing toward white almost as soon as you left the fully-
    // pinned moment. Reshape it so the scene HOLDS essentially full
    // immersion for the majority of that distance and only visibly fades
    // over the remaining stretch closest to the hero — same endpoints (0
    // and 1) and the same underlying linear scroll distance driving
    // reel-freeze/resume below (raw `progress`, untouched), only the value
    // actually written to --immersion is reshaped.
    const IMMERSION_HOLD = 0.6;
    const shapeImmersion = (p) => {
      if (p >= IMMERSION_HOLD) return 1;
      const t = p / IMMERSION_HOLD;
      return t * t * (3 - 2 * t);
    };
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => {
        raf = null;
        const wrap = scrollWrapRef.current;
        if (!wrap || !stickyRef.current) return;
        const rect = wrap.getBoundingClientRect();
        const approach = rect.top - stickyTopPx;
        const x = Math.min(Math.max(SHOWCASE_LEAD - approach, 0), SUM);
        const progress = x / SUM;
        stickyRef.current.style.setProperty("--immersion", String(shapeImmersion(progress)));
        if (modeRef.current === "reel" && progress >= enter) freezeToFocused();
        else if (modeRef.current === "focused" && progress < exit) resumeReel();
      });
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    onScroll();
    return () => { window.removeEventListener("scroll", onScroll); if (raf) cancelAnimationFrame(raf); };
  }, [narrow, activeIndex, stickyTopPx]);

  // Reads the track's current translateX in px, or 0 if none is set.
  const currentTrackX = () => {
    if (!trackRef.current) return 0;
    const t = getComputedStyle(trackRef.current).transform;
    if (t === "none") return 0;
    return new DOMMatrixReadOnly(t).m41;
  };

  // Centers a given card using LAYOUT geometry (offsetLeft/offsetWidth)
  // rather than getBoundingClientRect on the card itself. Two reasons that
  // matters: (1) a `dir="rtl"` flex row lays items out physically mirrored,
  // and offsetLeft/offsetWidth are real browser-computed layout values that
  // already account for that correctly, with no arithmetic of our own to
  // get wrong; (2) the card's own `transform` (scale/translateY, used for
  // the arc effect) can still be mid-transition at the exact synchronous
  // instant this runs, which would make a getBoundingClientRect read
  // reflect a stale, not-yet-settled shape — offsetLeft/offsetWidth reflect
  // flex-basis layout only (applied instantly, no transition), and since
  // the active role's own translateY target is always exactly 0, and scale
  // is applied around the card's own center (which doesn't move it), this
  // untransformed layout center is exactly where the settled, centered
  // card ends up.
  // Note: the track itself keeps an imperative `transform` set from the
  // previous centering/drag call, which makes IT the offsetParent for its
  // own children (any element with a non-none transform qualifies) — so
  // offsetLeft here is relative to the track's own (untransformed) layout
  // box, and that box's real current position has to be recovered by
  // subtracting the track's live translateX back out of its measured rect.
  //
  // `perspective-origin` (also set here) is resolved against the SAME
  // untransformed track box, and defaults to 50% of it — but the track's
  // own box spans the entire 72-card tripled row, not just the visible
  // viewport, so a bare 50% would put the vanishing point at the row's
  // midpoint, not at wherever the active card actually is on screen (which
  // varies with activeIndex). Pointing it at this same active card's own
  // local center keeps the vanishing point exactly where the card visually
  // sits — i.e. the viewport center, since that's what targetX just solved
  // for — so the convex arc always curves symmetrically around it.
  const centerCard = (idx) => {
    const trackEl = trackRef.current, wrapEl = stickyRef.current;
    const card = trackEl && trackEl.children[idx];
    if (!trackEl || !wrapEl || !card) return;
    const cardCenterInTrack = card.offsetLeft + card.offsetWidth / 2;
    const trackRect = trackEl.getBoundingClientRect();
    const trackLeftUntransformed = trackRect.left - currentTrackX();
    const cardCenterUntransformedX = trackLeftUntransformed + cardCenterInTrack;
    const wrapRect = wrapEl.getBoundingClientRect();
    const targetX = wrapRect.left + wrapRect.width / 2 - cardCenterUntransformedX;
    trackEl.style.transition = "transform var(--dur-slow) var(--ease-standard)";
    trackEl.style.transform = `translateX(${targetX}px)`;
    trackEl.style.perspectiveOrigin = `${cardCenterInTrack}px 50%`;
  };

  // Re-center whenever the focused index (or entry into/out of focused mode)
  // changes, before the browser paints — keeps the active card exactly
  // centered without a visible flash of the old position. `gap` is itself
  // driven by `--immersion` (see the track's style below); that value is
  // (re)computed by the separate scroll-progress effect below, which also
  // re-runs on every activeIndex change and does its own recompute a frame
  // or two later via its own requestAnimationFrame — so the very first
  // synchronous measurement here can land a frame before that recompute
  // lands, catching a gap that's about to change out from under it. Rather
  // than trying to detect exactly when that settles, just keep re-measuring
  // and nudging the same transition's target for a short fixed run of
  // frames — cheap and idempotent when nothing's actually changed, and it
  // reads as one smooth motion rather than a visible correction.
  React.useLayoutEffect(() => {
    if (narrow || mode !== "focused") return;
    centerCard(activeIndex);
    let raf = null;
    let framesLeft = 8;
    const settle = () => {
      raf = null;
      centerCard(activeIndex);
      if (--framesLeft > 0) raf = requestAnimationFrame(settle);
    };
    raf = requestAnimationFrame(settle);
    return () => { if (raf) cancelAnimationFrame(raf); };
  }, [narrow, mode, activeIndex]);

  // ---- Idle autoplay, focused/immersive mode only -------------------------
  // Advances exactly one card after IDLE_MS of true inactivity, and only
  // while genuinely in the immersive state (never in the passive reel,
  // which already drifts continuously on its own). Any real interaction —
  // pointer movement over the row, a press, a drag, an arrow click, a card
  // click — touches `lastInteractionRef` (see markInteraction and the
  // pointer handlers below) and this timer never advances mid-drag.
  const IDLE_MS = 5500;
  React.useEffect(() => {
    if (narrow || mode !== "focused" || reduced) return;
    markInteraction();
    const id = setInterval(() => {
      if (dragRef.current) return; // never fight an active drag
      if (document.visibilityState === "hidden") return;
      if (Date.now() - lastInteractionRef.current >= IDLE_MS) {
        markInteraction();
        goNext();
      }
    }, 400);
    return () => clearInterval(id);
  }, [narrow, mode, reduced, activeIndex]);

  // ---- Drag / swipe / click ---------------------------------------------
  // A pointer-down starts as neither — it only becomes a "drag" once the
  // pointer has moved past DRAG_THRESHOLD_PX. Below that, releasing is a
  // plain click/tap and the browser's own click event (handled by each
  // card's onClick) fires completely undisturbed. This is what lets a real
  // drag/swipe and a normal click coexist reliably instead of one
  // accidentally cancelling the other.
  const DRAG_THRESHOLD_PX = 6;

  const onPointerDown = (e) => {
    markInteraction();
    if (mode !== "focused" || narrow) return;
    dragRef.current = { startX: e.clientX, dx: 0, baseX: currentTrackX(), dragging: false, pointerId: e.pointerId };
    // Deliberately NOT capturing the pointer here. Capturing immediately on
    // pointerdown redirects the eventual click's target to whatever element
    // holds the capture (Chromium does this for the compatibility mouse
    // events too, not just pointer events) — which silently breaks a plain
    // click on a card, since it never reaches the card's own click handler.
    // Capture is acquired only once movement actually confirms a drag,
    // below.
  };
  const onPointerMove = (e) => {
    markInteraction();
    const d = dragRef.current;
    if (!d) return;
    d.dx = e.clientX - d.startX;
    if (!d.dragging) {
      if (Math.abs(d.dx) < DRAG_THRESHOLD_PX) return; // still just a click/tap so far
      d.dragging = true;
      if (dragWrapRef.current) {
        dragWrapRef.current.style.cursor = "grabbing";
        dragWrapRef.current.setPointerCapture && dragWrapRef.current.setPointerCapture(d.pointerId);
      }
    }
    if (trackRef.current) {
      trackRef.current.style.transition = "none";
      trackRef.current.style.transform = `translateX(${d.baseX + d.dx}px)`;
    }
    if (d.dragging && !reduced) {
      // The hand matching where this drag is currently heading pulls harder
      // (spring target toward 1); the other gives a subtler counter-release
      // — the same dominant/counter relationship as the post-release nav
      // pulse, just fed continuously while the drag is still in progress so
      // the spring's own inertia does the rest. Relaxed back to 0 the moment
      // the drag ends (see endDrag).
      const dragIntent = ar ? d.dx : -d.dx; // >0: heading toward "next" -> right hand leads
      const t = Math.max(-1, Math.min(1, dragIntent / 200));
      setPuppetTarget("right", t >= 0 ? Math.abs(t) : -Math.abs(t) * 0.32);
      setPuppetTarget("left", t >= 0 ? -Math.abs(t) * 0.32 : Math.abs(t));
    }
  };
  const endDrag = () => {
    markInteraction();
    if (!reduced) { setPuppetTarget("left", 0); setPuppetTarget("right", 0); }
    const d = dragRef.current;
    dragRef.current = null;
    if (!d) return;
    if (dragWrapRef.current) dragWrapRef.current.style.cursor = "";
    if (!d.dragging) return; // never crossed the threshold — a plain click, let it fire
    // A real drag just ended. The browser is about to fire a click on
    // whatever card ends up under the pointer; suppress exactly that one
    // click (see onClickCapture below) so a swipe can never also register
    // as an accidental card-open.
    justDraggedRef.current = true;
    window.setTimeout(() => { justDraggedRef.current = false; }, 300);
    // Round to the nearest card instead of a single fixed-distance
    // threshold, so release always settles cleanly onto a piece — including
    // jumping more than one card on a long drag — rather than stopping
    // awkwardly between two.
    const rawSteps = (ar ? d.dx : -d.dx) / focusedStep;
    const stepDelta = Math.round(rawSteps);
    if (stepDelta !== 0) {
      goTo(activeIndex + stepDelta);
      // activeIndex changes -> the layout effect above re-centers smoothly
    } else if (trackRef.current) {
      // Rounded back to the card we started on — snap back to it.
      trackRef.current.style.transition = "transform var(--dur-slow) var(--ease-standard)";
      trackRef.current.style.transform = `translateX(${d.baseX}px)`;
    }
  };
  // Capture-phase: runs before the click reaches whichever card is under the
  // pointer, so it can swallow the one click that would otherwise follow a
  // real drag release.
  const onClickCapture = (e) => {
    if (justDraggedRef.current) {
      e.stopPropagation();
      e.preventDefault();
      justDraggedRef.current = false;
    }
  };

  // Mobile: a continuously-cycling strip — a dominant centered card with the
  // previous/next projects peeking at each edge, auto-advancing on its own —
  // entering the full-screen InkHand cinema (MobileShowcaseCinema above) on
  // interaction, instead of the desktop scroll-pinned/arc showcase below —
  // see MobileShowcaseCinema's own comment for why that's a separate
  // component rather than a threaded-through mobile mode of this one.
  if (narrow) {
    return (
      <>
        <MobileWorkStrip
          list={list}
          activeIndex={mobileTeaserIndex}
          setActiveIndex={setMobileTeaserIndex}
          ar={ar}
          reduced={reduced}
          paused={mobileExpanded}
          onExplore={(originRect) => { setMobileExploreOrigin(originRect || null); setMobileExpanded(true); }}
        />
        {mobileExpanded ? (
          <MobileShowcaseCinema
            list={list}
            initialIndex={mobileTeaserIndex}
            onClose={() => setMobileExpanded(false)}
            ar={ar}
            reduced={reduced}
            setPage={setPage}
            originRect={mobileExploreOrigin}
          />
        ) : null}
      </>
    );
  }

  return (
    <>
      <style>{`
        @keyframes ink-reel-drift-ltr { from { transform: translateX(calc(-1 * var(--cycle-w))); } to { transform: translateX(calc(-2 * var(--cycle-w))); } }
        @keyframes ink-reel-drift-rtl { from { transform: translateX(calc(2 * var(--cycle-w))); } to { transform: translateX(var(--cycle-w)); } }
        .ink-reel-track[data-mode="reel"] { animation: ink-reel-drift-ltr ${durationSec}s linear infinite; }
        [dir="rtl"] .ink-reel-track[data-mode="reel"] { animation-name: ink-reel-drift-rtl; }
        .ink-showcase-sticky { --immersion: 0; }
        .ink-showcase-arrow { opacity: var(--immersion); }
        .ink-showcase-drag { user-select: none; -webkit-user-select: none; -webkit-user-drag: none; }
        @media (prefers-reduced-motion: reduce) {
          .ink-reel-track[data-mode="reel"] { animation: none !important; transform: translateX(calc(-1.5 * var(--cycle-w))) !important; }
        }
        /* Hands: a slow continuous idle sway + breathing scale on the whole
           arm+hand (matching the atmosphere's own continuous ink-blob drift
           in this same immersive environment), plus a second, smaller,
           independently-timed sway (ink-wrist-drift) on just the hand
           sub-tree — the wrist drifting slightly relative to the fixed
           forearm above, for organic secondary motion without ever
           detaching or independently moving a single finger (the whole
           hand is one rigid illustrated silhouette — see INK_HAND_PATH).
           --hand-dir is +1 for the left hand, -1 for the right, so one
           shared keyframe reads correctly as "toward/away from center" on
           both sides without two mirrored copies. The live puppeteer pull
           (pull/curl/string tension) is layered on top of this, driven every
           frame by the spring loop in PortfolioShowcase — see
           applyHandFrame/ensurePuppetLoop — not by CSS keyframes. */
        @keyframes ink-hand-idle {
          0%, 100% { transform: translateY(0) rotate(0deg) scale(1); }
          50% { transform: translateY(-18px) rotate(calc(var(--hand-dir, 1) * -6deg)) scale(1.025); }
        }
        @keyframes ink-wrist-drift {
          0%, 100% { transform: translate(0, 0) rotate(0deg); }
          33% { transform: translate(-2px, 3px) rotate(-2.4deg); }
          68% { transform: translate(1.5px, -1px) rotate(1.6deg); }
        }
      `}</style>
      {/* A `position: sticky` child releases once the wrap's remaining height
          (below the sticky element, i.e. wrapperHeight - stickyHeight) runs
          out — the sticky offset itself (stickyTopPx) doesn't add to that
          budget. So the post-engagement pinned range is exactly
          wrapperHeight - stickyHeight; setting wrapperHeight to
          stickyHpx + SHOWCASE_PIN_RANGE makes that range exactly
          SHOWCASE_PIN_RANGE, regardless of how large stickyTopPx is. */}
      <div
        ref={scrollWrapRef}
        style={{
          position: "relative",
          height: narrow || reduced ? "auto" : `${stickyHpx + SHOWCASE_PIN_RANGE}px`,
          // The English hero's headline is three hard-broken lines while the
          // Arabic headline wraps to fewer, so the English hero is taller —
          // this whole showcase (which simply follows the hero in normal
          // flow) starts noticeably lower on first load, leaving less of the
          // carousel visible at scroll 0. Pull it up to close that gap, but
          // only as far as the hero's own bottom padding allows without the
          // cards ever covering the hero's CTA buttons (verified across the
          // full responsive range — see scratch notes for this round).
          // Arabic already sits correctly and is untouched (marginTop 0).
          marginTop: !ar && !narrow ? "calc(-1 * (var(--space-9) - var(--space-5)))" : undefined,
        }}
      >
        <div
          ref={stickyRef}
          className="ink-showcase-sticky"
          style={narrow || reduced ? { position: "relative", overflow: "hidden" } : { position: "sticky", top: stickyTopPx, height: stickyHpx, overflow: "hidden", display: "flex", alignItems: "center" }}
        >
          {!narrow && (
            // A small, lightweight designed environment for the gallery
            // moment: a warm graphite foundation with soft directional
            // light, two large soft depth "planes" (blurred radial fields,
            // not small glowing orbs) drifting very slowly, a faint diagonal
            // sheen for dimensionality, a soft glow behind the always-
            // centered active piece, and a whisper of grain — masked so the
            // WHOLE composite fades to transparent at the top/bottom edges
            // together (no layer can poke out with a hard boundary).
            // Transform/opacity only (GPU-composited); fades in/out
            // continuously with --immersion.
            <div
              aria-hidden="true"
              style={{
                position: "absolute",
                inset: 0,
                overflow: "hidden",
                opacity: "var(--immersion)",
                transition: "opacity var(--dur-base) linear",
                pointerEvents: "none",
                zIndex: 0,
                maskImage: "linear-gradient(180deg, transparent 0%, black 2.5%, black 97.5%, transparent 100%)",
                WebkitMaskImage: "linear-gradient(180deg, transparent 0%, black 2.5%, black 97.5%, transparent 100%)",
              }}
            >
              <div
                style={{
                  position: "absolute",
                  inset: 0,
                  // Near-black graphite foundation — deliberately desaturated
                  // (not itself red/brown) so the room's color comes from the
                  // distinct wine and indigo light fields layered on top of
                  // it, not from one tinted wash covering everything.
                  background: "linear-gradient(160deg, #171313 0%, #100d0d 48%, #0a0808 100%)",
                }}
              />
              {/* Two soft, colored light fields anchored in different
                  corners — a deep wine/burgundy pool and a cool indigo/
                  violet one — coexisting rather than blending into a single
                  hue, plus a smaller, more restrained warm ember low on the
                  opposite side. Together they read as several dark tones
                  and colored light coexisting (per the reference), while
                  the foundation underneath keeps the room's identity dark
                  and graphite rather than one flat color. */}
              <div className="ink-blob" style={{ position: "absolute", width: "56vw", height: "130%", left: "-18vw", top: "-14%", background: "radial-gradient(circle, rgba(112,26,58,0.34) 0%, rgba(112,26,58,0) 68%)", filter: "blur(70px)", animation: reduced ? "none" : "ink-blob-morph 44s var(--ease-standard) infinite, ink-drift 50s var(--ease-standard) infinite" }} />
              <div className="ink-blob" style={{ position: "absolute", width: "50vw", height: "122%", right: "-14vw", bottom: "-16%", background: "radial-gradient(circle, rgba(52,58,132,0.26) 0%, rgba(52,58,132,0) 70%)", filter: "blur(76px)", animation: reduced ? "none" : "ink-blob-morph 44s var(--ease-standard) infinite -20s, ink-drift 56s var(--ease-standard) infinite -14s" }} />
              <div className="ink-blob" style={{ position: "absolute", width: "34vw", height: "80%", left: "-4vw", bottom: "-22%", background: "radial-gradient(circle, rgba(168,64,32,0.13) 0%, rgba(168,64,32,0) 72%)", filter: "blur(64px)", animation: reduced ? "none" : "ink-blob-morph 50s var(--ease-standard) infinite -9s, ink-drift 60s var(--ease-standard) infinite -30s" }} />
              {/* a faint diagonal sheen for soft, smoky dimensionality — cool
                  and neutral rather than warm, so it reads as smoke/haze
                  catching light rather than another color of its own */}
              <div style={{ position: "absolute", inset: 0, background: "linear-gradient(115deg, transparent 30%, rgba(210,214,226,0.03) 50%, rgba(180,188,206,0.045) 54%, transparent 72%)" }} />
              {/* Ambient light behind the always-centered active piece,
                  crossfading toward that piece's own restrained accent color
                  whenever it changes (see the glow state/effect above). Kept
                  lower-alpha than the corner light fields on purpose — the
                  center stays dark and controlled for readability behind
                  the artwork, while the wine/indigo pools further out carry
                  most of the room's color. */}
              <div style={{ position: "absolute", left: "50%", top: "50%", width: "56vw", height: "78%", transform: "translate(-50%, -50%)", background: `radial-gradient(ellipse 52% 58% at 50% 50%, ${hslCss(glow.a, 0.16)}, ${hslCss(glow.a, 0)} 72%)`, filter: "blur(4px)", opacity: glow.show === "a" ? 1 : 0, transition: reduced ? "none" : "opacity 900ms ease" }} />
              <div style={{ position: "absolute", left: "50%", top: "50%", width: "56vw", height: "78%", transform: "translate(-50%, -50%)", background: `radial-gradient(ellipse 52% 58% at 50% 50%, ${hslCss(glow.b, 0.16)}, ${hslCss(glow.b, 0)} 72%)`, filter: "blur(4px)", opacity: glow.show === "b" ? 1 : 0, transition: reduced ? "none" : "opacity 900ms ease" }} />
              <div style={{ position: "absolute", inset: 0, opacity: 0.045, backgroundImage: SHOWCASE_NOISE_BG, backgroundRepeat: "repeat", mixBlendMode: "overlay" }} />
            </div>
          )}
          <div
            ref={dragWrapRef}
            className="ink-showcase-drag"
            style={{
              position: "relative",
              zIndex: 1,
              width: "100%",
              marginInline: narrow ? undefined : "calc(50% - 50vw)",
              paddingInline: narrow ? "var(--page-margin)" : "calc(50vw - 50% + var(--page-margin))",
              // No overflow set here at all (deliberately — see the note
              // on the track wrapper below for why): CSS forces a
              // "visible" axis to compute as "auto" whenever the OTHER
              // axis on the same box isn't visible, and "auto" still
              // clips. That silently ate the puppet hands' sleeve/void/
              // gold-accent geometry above the hand's own box for
              // multiple rounds even though this element's intent was
              // "clip X, leave Y open" — the two can't coexist on one box.
              // The horizontal clip now lives on a dedicated inner wrapper
              // around just the card track, so this box (which also hosts
              // the hand overlay) stays genuinely overflow:visible on
              // both axes; the immersive room's own overflow:hidden
              // further up the tree still bounds the hands safely within
              // the dark sticky box.
              touchAction: narrow ? undefined : "pan-y",
              cursor: !narrow && mode === "focused" ? "grab" : "default",
              // See reelLiftPx above — pulls the row up at rest, eases back
              // to its unchanged, already-centered immersive position as
              // --immersion climbs. Transform-only, so it never touches the
              // sticky/pin scroll math or the horizontal centering geometry
              // (centerCard reads X only).
              transform: reelLiftPx ? "translateY(calc((1 - var(--immersion, 0)) * -1 * " + reelLiftPx + "px))" : undefined,
            }}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={endDrag}
            onPointerCancel={endDrag}
            onClickCapture={onClickCapture}
          >
            {/* Horizontal-clip-only wrapper around JUST the card track —
                deliberately its own box (see the note on ink-showcase-drag
                above) so this element can pair overflowX:"hidden" with a
                left-as-default (visible/auto) Y axis without that pairing
                also silently clipping the puppet hands, which live as a
                SIBLING of this wrapper, not inside it.
                THE SAME CSS RULE THE COMMENT ABOVE DESCRIBES ALSO APPLIES
                RIGHT HERE, ONE LEVEL DOWN: this wrapper's own overflowX:
                "hidden" forces its un-set overflowY to compute as "auto"
                too (never truly "visible") — and "auto" still clips
                whenever content exceeds the box, same as "hidden" does. The
                box's height, left to size itself from the track's own
                natural (pre-transform-scale) row height, is exactly that:
                the active card's flex-basis is its SMALL pre-scale size
                (see baseWidthFor) which only reaches its real, larger final
                size via a `transform: scale()` on top — and CSS transforms
                never affect layout/flow sizing, so the wrapper's box never
                actually grows to match. Every activeScale > 1 pixel of the
                centered card's real height therefore silently overflowed
                this box's auto-Y and got clipped top/bottom — invisible to
                any geometry check on the card's OWN rect (its rect is
                correct; only what an ancestor lets through wasn't). Giving
                the wrapper an explicit height as tall as the sticky stage
                itself (guaranteed >= the active role's tallest possible
                rendered height, see activeFinalMaxW's own stickyHpx-based
                cap) means content can never exceed it, so the coerced
                auto-Y never has anything to clip — while flex+centering
                keeps the (now much shorter, unscaled) track sitting
                centered inside that taller box exactly as before. */}
            <div style={{ width: "100%", height: narrow ? undefined : stickyHpx, display: narrow ? undefined : "flex", alignItems: narrow ? undefined : "center", overflowX: "hidden" }}>
            <div
              ref={trackRef}
              className="ink-reel-track"
              data-mode={narrow ? "scroll" : mode}
              style={{
                display: "flex",
                alignItems: "center",
                // Spacing widens continuously with --immersion too (not a
                // snap at the reel/focused switch), matching the cards'
                // own continuous scale-up.
                gap: narrow ? `${gap}px` : `calc(${gap}px + ${focusedGap - gap}px * var(--immersion))`,
                willChange: "transform",
                // Perspective lives here, directly on the cards' own parent
                // (not further up the tree, and with no `preserve-3d`
                // anywhere) — see the note in ShowcaseCard for why that
                // specific placement is what keeps click hit-testing exact
                // under the cards' real rotateY/translateZ.
                // perspectiveOrigin is intentionally NOT set here (same
                // reasoning as `transform` below) — it's driven imperatively
                // by centerCard, and setting a value in this style object
                // would make every re-render stomp that imperative value
                // back to a static default.
                perspective: narrow ? undefined : 1400,
                ...(narrow
                  ? { width: "100%", maxWidth: "100%", overflowX: "auto", scrollSnapType: "x proximity", WebkitOverflowScrolling: "touch" }
                  // `transform` is intentionally left unset here — it's driven
                  // imperatively (see centerCard/the layout effect above) so
                  // it can measure real post-layout geometry, which is the
                  // only reliable way to center correctly in both LTR and a
                  // `dir="rtl"` mirrored flex row.
                  : { width: "max-content", "--cycle-w": `${cycleW}px` }),
              }}
            >
              {tripled.map((item, i) => {
                const signedDiff = i - activeIndex;
                const diff = Math.abs(signedDiff);
                const focusedMode = !narrow && mode === "focused";
                const isActive = focusedMode && i === activeIndex;
                // Which SCREEN side a card should rotate toward — the flex
                // row's array order mirrors physically in RTL, so the sign
                // flips there too, keeping the arc itself (cards left of
                // center rotate one way, right of center the other)
                // consistent on screen regardless of language direction.
                const physicalSign = signedDiff === 0 ? 0 : (ar ? -Math.sign(signedDiff) : Math.sign(signedDiff));
                return (
                  <ShowcaseCard
                    key={i}
                    item={item}
                    // Every card — in focused mode AND the passive reel —
                    // shares the exact same MASTER_CARD_RATIO silhouette
                    // (see ShowcaseCard/Frame); only the WIDTH differs by
                    // role (active/near/far), scaled to how prominent that
                    // role is, with height always following from width via
                    // the fixed ratio. The passive reel keeps the uniform
                    // cardW box, unchanged.
                    width={focusedMode ? baseWidthFor(diff) : cardW}
                    snap={narrow}
                    rootRef={stickyRef}
                    active={isActive}
                    targetScale={narrow ? 1 : focusedMode ? (diff === 0 ? activeScale : diff === 1 ? neighborScale : farScale) : 1}
                    targetOpacity={narrow ? 1 : focusedMode ? (diff === 0 ? 1 : diff === 1 ? 0.94 : 0.82) : 1}
                    targetRotate={narrow || !focusedMode ? 0 : -physicalSign * Math.min(diff * ARC_ROTATE_STEP, ARC_ROTATE_MAX)}
                    targetZ={narrow || !focusedMode ? 0 : -Math.min(diff * ARC_Z_STEP, ARC_Z_MAX)}
                    onClick={focusedMode && !isActive ? () => goTo(i) : openWork}
                  />
                );
              })}
            </div>
            </div>
            {!narrow && (
              // Sits above the track's own cards (so it never disappears
              // behind a near/far card) but only ever touches the active
              // card's outer edge (see handInsetPx) — never its content.
              // Fades in with --immersion exactly like the arrows, so it's
              // invisible during the passive reel and only appears once
              // there's a genuine single active/center card to frame.
              <div
                style={{
                  position: "absolute", inset: 0, zIndex: 2,
                  opacity: "var(--immersion, 0)",
                  pointerEvents: "none",
                }}
              >
                <InkHand side="left" role={navDir === 1 ? "outgoing" : navDir === -1 ? "incoming" : "idle"} reduced={reduced} inset={handInsetPx} pullRef={leftPullRef} pathRef={leftHandPathRef} />
                <InkHand side="right" role={navDir === 1 ? "incoming" : navDir === -1 ? "outgoing" : "idle"} reduced={reduced} inset={handInsetPx} pullRef={rightPullRef} pathRef={rightHandPathRef} />
              </div>
            )}
          </div>

          {!narrow && (
            <>
              <button
                aria-label={ar ? "السابق" : "Previous"}
                onClick={goPrev}
                className="ink-showcase-arrow"
                // Hugs the true viewport edge (rather than the more generous
                // --space-6) and is a little smaller — the arc's near/far
                // cards can extend fairly close to the edge, so the arrow's
                // own footprint has to stay minimal to avoid ever
                // overlapping (and, via its higher z-index, silently
                // stealing clicks from) a card sitting near that edge.
                style={{ position: "absolute", zIndex: 2, insetInlineStart: 10, top: "50%", transform: "translateY(-50%)", width: 40, height: 40, borderRadius: "var(--radius-pill)", border: "var(--rule-weight) solid var(--border-strong)", background: "var(--paper-000)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", pointerEvents: mode === "focused" ? "auto" : "none", transition: "opacity var(--dur-base) var(--ease-standard)" }}
              >
                <Icon name={ar ? "chevron-right" : "chevron-left"} size={18} />
              </button>
              <button
                aria-label={ar ? "التالي" : "Next"}
                onClick={goNext}
                className="ink-showcase-arrow"
                style={{ position: "absolute", zIndex: 2, insetInlineEnd: 10, top: "50%", transform: "translateY(-50%)", width: 40, height: 40, borderRadius: "var(--radius-pill)", border: "var(--rule-weight) solid var(--border-strong)", background: "var(--paper-000)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", pointerEvents: mode === "focused" ? "auto" : "none", transition: "opacity var(--dur-base) var(--ease-standard)" }}
              >
                <Icon name={ar ? "chevron-left" : "chevron-right"} size={20} />
              </button>
            </>
          )}
        </div>
      </div>
    </>
  );
}

// ============================================================================
// "What We Make" — four service cards. Hovering, focusing, or (on touch)
// tapping one grows it by redistributing real flex-basis width across the
// row — not an overlapping scale() — while the other three compress, and
// reveals a small service-specific line-art scene. Isolated to this
// section only: nothing here touches the portfolio showcase above it,
// Selected Work below, or any other part of the page.
// ============================================================================

const SERVICE_EXPANDED_PCT = 43;
const SERVICE_COLLAPSED_PCT = (100 - SERVICE_EXPANDED_PCT) / 3;

// Each service gets its own restrained mood built from EXISTING INK press-
// ink tokens (cobalt / vermilion / saffron / pine) rather than new colors —
// distinct from each other, but still clearly one family.
const SERVICE_MOODS = {
  media: { fg: "var(--paper-050)", fgMuted: "rgba(246,243,237,0.72)", accent: "var(--cobalt-600)", bg: "linear-gradient(160deg, #171b23 0%, #0f1116 100%)" },
  motion: { fg: "var(--paper-050)", fgMuted: "rgba(246,243,237,0.72)", accent: "var(--vermilion-500)", bg: "linear-gradient(160deg, #1c1414 0%, #130f0f 100%)" },
  print: { fg: "var(--ink-900)", fgMuted: "var(--ink-500)", accent: "var(--saffron-600)", bg: "linear-gradient(160deg, #FBF6EB 0%, #F2E7D2 100%)" },
  three: { fg: "var(--paper-050)", fgMuted: "rgba(246,243,237,0.72)", accent: "var(--pine-500)", bg: "linear-gradient(160deg, #10181a 0%, #0a1112 100%)" },
};

function ServiceCorner({ corner, color, active }) {
  const base = { position: "absolute", width: 16, height: 16, opacity: active ? 0.85 : 0.22, transform: active ? "scale(1)" : "scale(0.8)", transition: "opacity 500ms ease, transform 500ms ease" };
  const byCorner = {
    tl: { insetInlineStart: 14, top: 14, borderTop: `1.5px solid ${color}`, borderInlineStart: `1.5px solid ${color}` },
    tr: { insetInlineEnd: 14, top: 14, borderTop: `1.5px solid ${color}`, borderInlineEnd: `1.5px solid ${color}` },
    bl: { insetInlineStart: 14, bottom: 14, borderBottom: `1.5px solid ${color}`, borderInlineStart: `1.5px solid ${color}` },
    br: { insetInlineEnd: 14, bottom: 14, borderBottom: `1.5px solid ${color}`, borderInlineEnd: `1.5px solid ${color}` },
  };
  return <span aria-hidden="true" style={{ ...base, ...byCorner[corner] }} />;
}

// Card 1 — a restrained studio/viewfinder scene: corner focus brackets, a
// soft sweeping light, and a complete camera HUD (timecode, REC, HD,
// battery) around a central focus frame proportioned like the Services
// page's own QuadSceneMedia box. Reads as "camera", never a literal
// camera photograph.
function SceneMedia({ active, reduced, accent, narrow }) {
  return (
    <>
      <ServiceCorner corner="tl" color={accent} active={active} />
      <ServiceCorner corner="tr" color={accent} active={active} />
      <ServiceCorner corner="bl" color={accent} active={active} />
      <ServiceCorner corner="br" color={accent} active={active} />
      {/* Central focus frame — proportioned like the Services page's own
          QuadSceneMedia box (100x76, a ~4:3 rectangle rather than a wide
          flat one) and placed at the true visual center of the card's
          open space, not tucked underneath the copy. */}
      <span aria-hidden="true" style={{
        position: "absolute", top: "55%", left: "50%",
        width: active ? "34%" : "22%", height: active ? "24%" : "16%",
        transform: "translate(-50%, -50%)",
        border: `1.5px solid ${accent}`, borderRadius: 3,
        opacity: active ? 0.55 : 0,
        boxShadow: active ? `0 0 26px -8px ${accent}` : "none",
        transition: "width 650ms cubic-bezier(0.22,1,0.36,1), height 650ms cubic-bezier(0.22,1,0.36,1), opacity 500ms ease 80ms, box-shadow 500ms ease 80ms",
      }}>
        <span style={{
          position: "absolute", top: "50%", left: "50%", width: 4, height: 4, borderRadius: "50%",
          background: accent, transform: "translate(-50%, -50%)", opacity: active ? 0.95 : 0,
          animation: active && !reduced ? "ink-svc-focus-pulse 2.6s ease-in-out infinite" : "none",
          transition: "opacity 400ms ease 200ms",
        }} />
      </span>
      <span aria-hidden="true" style={{ position: "absolute", inset: 0, overflow: "hidden", opacity: active ? 1 : 0, transition: "opacity 400ms ease" }}>
        <span style={{ position: "absolute", inset: "-20% -10%", background: "linear-gradient(100deg, transparent 42%, rgba(120,150,255,0.16) 50%, transparent 58%)", animation: active && !reduced ? "ink-svc-sweep 3.4s ease-in-out infinite" : "none" }} />
      </span>
      {/* Camera HUD — timecode top-left, REC + blinking dot top-right, HD
          bottom-left, battery bottom-right — nested just inside the four
          corner brackets above, completing the viewfinder read. */}
      {/* Sits to the right of the card's own icon (top-left, ~24-48px)
          rather than under it, so the two never overlap. */}
      <span aria-hidden="true" style={{ position: "absolute", insetInlineStart: 58, top: 27, opacity: active ? 0.8 : 0, transition: "opacity 400ms ease 140ms", fontSize: 10, letterSpacing: "0.06em", fontVariantNumeric: "tabular-nums", color: "rgba(246,243,237,0.7)" }}>00:00:00</span>
      <span aria-hidden="true" style={{ position: "absolute", insetInlineEnd: 26, top: 22, display: "flex", alignItems: "center", gap: 6, opacity: active ? 0.85 : 0, transition: "opacity 400ms ease 140ms" }}>
        <span style={{ fontSize: 11, letterSpacing: "0.12em", color: "rgba(246,243,237,0.75)" }}>REC</span>
        <span style={{ width: 7, height: 7, borderRadius: "50%", background: "var(--vermilion-500)", animation: active && !reduced ? "ink-svc-rec-blink 1.6s ease-in-out infinite" : "none" }} />
      </span>
      {/* bottom offsets shrink on narrow — the card's own decorative box
          now stops SVC_CARD_CTA_CLEARANCE above the button (see
          ServiceCard), a much shallower box than desktop's, so the same
          22-24px-from-edge offsets that read fine in a tall desktop card
          land right against the description text just above this box on
          mobile; sitting closer to the (now much closer) bottom edge
          instead keeps them clear of it. */}
      <span aria-hidden="true" style={{ position: "absolute", insetInlineStart: 26, bottom: narrow ? 6 : 22, opacity: active ? 0.8 : 0, transition: "opacity 400ms ease 140ms", fontSize: 11, fontWeight: 600, letterSpacing: "0.05em", color: "rgba(246,243,237,0.75)" }}>HD</span>
      <svg aria-hidden="true" viewBox="0 0 28 14" width="26" height="13" style={{ position: "absolute", insetInlineEnd: 26, bottom: narrow ? 8 : 24, opacity: active ? 0.8 : 0, transition: "opacity 400ms ease 140ms" }}>
        <rect x="1" y="1" width="22" height="12" rx="2" fill="none" stroke={accent} strokeWidth="1.4" />
        <rect x="24" y="4.5" width="2.4" height="5" rx="1" fill={accent} />
        <rect x="3.2" y="3.2" width="15.5" height="7.6" rx="1" fill={accent} opacity="0.7" />
      </svg>
    </>
  );
}

// Card 2 — a bezier path drawing itself in, onion-skin echoes behind it,
// circle/square/triangle keyframe markers at the curve's own start/crest/
// end (the same motif as the Services page's QuadSceneMotion), and a small
// dot travelling the curve: motion being made, not generic floating shapes.
function SceneMotion({ active, reduced, accent }) {
  const d = "M20,112 C58,112 54,42 98,42 C138,42 128,90 172,68";
  return (
    <svg aria-hidden="true" viewBox="0 0 190 140" preserveAspectRatio="xMidYMid slice" style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}>
      {[0, 1, 2].map((i) => (
        <path key={i} d={d} fill="none" stroke={accent} strokeWidth="1" opacity={active ? 0.08 + i * 0.045 : 0} transform={`translate(${i * 4},${-i * 3})`} style={{ transition: `opacity 450ms ease ${i * 80}ms` }} />
      ))}
      <path d={d} fill="none" stroke={accent} strokeWidth="1.6" pathLength="1" strokeDasharray="1" strokeDashoffset={active ? 0 : 1} opacity={active ? 0.92 : 0.18} style={{ transition: "stroke-dashoffset 900ms cubic-bezier(0.22,1,0.36,1), opacity 500ms ease" }} />
      {/* Three keyframe markers reusing the Services page's own
          circle/square/triangle vocabulary (QuadSceneMotion), scaled down
          and anchored to this same curve's own points rather than placed
          randomly: a circle where the motion begins, a square at the
          curve's middle crest, a triangle toward its end. Each shape's
          settle-in transition lives on the inner element; the slow idle
          rotate/scale drift lives on its own outer <g> so the two
          transforms never compete for the same property. */}
      <g style={{ transformBox: "fill-box", transformOrigin: "center", animation: active && !reduced ? "ink-svc-travel-a 5200ms ease-in-out infinite" : "none", filter: active ? `drop-shadow(0 0 2.5px ${accent})` : "none" }}>
        <circle cx="20" cy="112" r={active ? 3.2 : 1.6} fill={accent} opacity={active ? 0.95 : 0.18} style={{ transition: "opacity 400ms ease 300ms, r 500ms cubic-bezier(0.22,1,0.36,1) 300ms" }} />
      </g>
      <g style={{ transformBox: "fill-box", transformOrigin: "center", animation: active && !reduced ? "ink-svc-travel-b 6200ms ease-in-out infinite" : "none", filter: active ? `drop-shadow(0 0 2.5px ${accent})` : "none" }}>
        <rect x="93.5" y="37.5" width="9" height="9" fill="none" stroke={accent} strokeWidth="1.6" opacity={active ? 0.95 : 0.18}
          style={{ transform: active ? "scale(1) rotate(0deg)" : "scale(0) rotate(-25deg)", transformBox: "fill-box", transformOrigin: "center", transition: "transform 550ms cubic-bezier(0.22,1,0.36,1) 410ms, opacity 400ms ease 410ms" }} />
      </g>
      <g style={{ transformBox: "fill-box", transformOrigin: "center", animation: active && !reduced ? "ink-svc-travel-c 5800ms ease-in-out infinite" : "none", filter: active ? `drop-shadow(0 0 2.5px ${accent})` : "none" }}>
        <path d="M172,62 L178,74 L166,74 Z" fill="none" stroke={accent} strokeWidth="1.6" opacity={active ? 0.95 : 0.18}
          style={{ transform: active ? "scale(1)" : "scale(0)", transformBox: "fill-box", transformOrigin: "center", transition: "transform 550ms cubic-bezier(0.22,1,0.36,1) 520ms, opacity 400ms ease 520ms" }} />
      </g>
      {active && (
        <circle r="2.6" fill={accent} opacity={0.95}>
          <animateMotion dur="3.6s" repeatCount={reduced ? 0 : "indefinite"} path={d} />
        </circle>
      )}
    </svg>
  );
}

// Card 3 — the same object vocabulary as the Services page's
// QuadScenePrint (a business card, printed sheets, a sticker with its own
// dot cluster, a tag, a tote bag), but composed across the card's own
// negative space — the ~130px-deep open band below the description, plus
// the empty upper-right above it — as two loose, art-directed clusters
// rather than pinned to the four outer corners. Physical, tactile,
// paper-first, never a completely different concept from the richer
// Services version.
function ScenePrint({ active, reduced, accent, narrow }) {
  return (
    <>
      {[{ x: "insetInlineStart", xv: 14, y: "top", yv: 14 }, { x: "insetInlineEnd", xv: 14, y: "bottom", yv: 14 }].map((p, i) => (
        <span key={i} aria-hidden="true" style={{ position: "absolute", [p.x]: p.xv, [p.y]: p.yv, width: 12, height: 12, opacity: active ? 0.55 : 0.16, transition: "opacity 400ms ease" }}>
          <span style={{ position: "absolute", top: "50%", insetInlineStart: 0, insetInlineEnd: 0, height: 1, background: accent }} />
          <span style={{ position: "absolute", insetInlineStart: "50%", top: 0, bottom: 0, width: 1, background: accent }} />
        </span>
      ))}

      {/* Upper-right cluster: a much larger tote bag — one of the card's
          recognizable visual anchors, moved in from the edge rather than
          hugging it — with the tag below-left of it. Above the
          description row, never touching the (much narrower) title. */}
      <span aria-hidden="true" style={{ position: "absolute", insetInlineEnd: 140, top: 12, width: 50, height: 58, zIndex: 3, animation: active && !reduced ? "ink-svc-float-a 6600ms ease-in-out infinite" : "none" }}>
        <svg viewBox="-26 -36 52 66" width="50" height="58" style={{ display: "block", opacity: active ? 0.85 : 0, transform: active ? "translateY(0) rotate(-6deg) scale(1)" : "translateY(-26px) rotate(-6deg) scale(0.7)", transition: "opacity 420ms ease 260ms, transform 620ms cubic-bezier(0.22,1,0.36,1) 260ms" }}>
          <path d="M-15,-9 L-15,20 Q-15,26 -9,26 L9,26 Q15,26 15,20 L15,-9 Z" fill="none" stroke={accent} strokeWidth="2" />
          <path d="M-8,-9 V-19 Q-8,-28 0,-28 Q8,-28 8,-19 V-9" fill="none" stroke={accent} strokeWidth="2" />
        </svg>
      </span>
      <span aria-hidden="true" style={{ position: "absolute", insetInlineEnd: 60, top: 58, width: 32, height: 24, zIndex: 3, animation: active && !reduced ? "ink-svc-float-b 7400ms ease-in-out infinite" : "none" }}>
        <svg viewBox="-16 -11 32 22" width="32" height="24" style={{ display: "block", opacity: active ? 0.85 : 0, transform: active ? "translateX(0) rotate(10deg) scale(1)" : "translateX(30px) rotate(10deg) scale(0.7)", transition: "opacity 420ms ease 340ms, transform 620ms cubic-bezier(0.22,1,0.36,1) 340ms" }}>
          <path d="M-14,-8 L6,-8 L14,0 L6,8 L-14,8 Z" fill="none" stroke={accent} strokeWidth="1.8" />
          <circle cx="-6" cy="0" r="2.2" fill="none" stroke={accent} strokeWidth="1.4" />
        </svg>
      </span>

      {/* Lower band: a business card (left, directly below the
          description), a notebook (lower-middle, replacing the old
          plain dotted square with a recognizable stationery object),
          and the printed-sheet stack (lower-right) — spread across the
          card's full width, all sized up to actually use the space. */}
      <span aria-hidden="true" style={{ position: "absolute", insetInlineStart: 36, bottom: narrow ? 6 : 26, width: 58, height: 36, zIndex: 3, animation: active && !reduced ? "ink-svc-float-c 7000ms ease-in-out infinite" : "none" }}>
        <svg viewBox="-25 -16 50 32" width="58" height="36" style={{ display: "block", opacity: active ? 0.85 : 0, transform: active ? "translateX(0) rotate(-7deg) scale(1)" : "translateX(-34px) rotate(-7deg) scale(0.7)", transition: "opacity 420ms ease 220ms, transform 620ms cubic-bezier(0.22,1,0.36,1) 220ms" }}>
          <rect x="-22" y="-13" width="44" height="26" rx="3" fill="none" stroke={accent} strokeWidth="1.8" />
          <line x1="-13" y1="2" x2="10" y2="2" stroke={accent} strokeWidth="1.2" />
        </svg>
      </span>
      {/* Notebook — a spiral-bound sheet with ruled lines, in the same
          yellow line-art language as the rest of this scene, sized to be
          a real visual anchor rather than a small icon. */}
      <span aria-hidden="true" style={{ position: "absolute", insetInlineStart: 195, bottom: 8, width: 46, height: 56, zIndex: 3, animation: active && !reduced ? "ink-svc-float-b 8200ms ease-in-out infinite" : "none" }}>
        <svg viewBox="-22 -27 44 54" width="46" height="56" style={{ display: "block", opacity: active ? 0.9 : 0, transform: active ? "translateY(0) rotate(6deg) scale(1)" : "translateY(30px) rotate(6deg) scale(0.6)", transition: "opacity 420ms ease 300ms, transform 620ms cubic-bezier(0.22,1,0.36,1) 300ms" }}>
          <rect x="-18" y="-24" width="36" height="48" rx="3" fill="none" stroke={accent} strokeWidth="2" />
          {[-16, -6, 4, 14].map((y) => <circle key={y} cx="-18" cy={y} r="1.6" fill="none" stroke={accent} strokeWidth="1.2" />)}
          <line x1="-9" y1="-10" x2="12" y2="-10" stroke={accent} strokeWidth="1.2" />
          <line x1="-9" y1="0" x2="12" y2="0" stroke={accent} strokeWidth="1.2" />
          <line x1="-9" y1="10" x2="12" y2="10" stroke={accent} strokeWidth="1.2" />
        </svg>
      </span>
      {[0, 1, 2].map((i) => (
        <span
          key={i}
          aria-hidden="true"
          style={{
            position: "absolute", insetInlineEnd: 20 + i * 12, bottom: (narrow ? 2 : 8) + i * (narrow ? 3 : 7), width: 82, height: 102, borderRadius: 6, zIndex: i,
            background: i === 2 ? "var(--paper-000)" : "var(--sand-200)",
            border: "1px solid var(--ink-200)",
            opacity: active ? 1 : 0,
            transform: active ? `rotate(${(i - 1) * 7}deg) translate(${(i - 1) * 10}px, ${-i * 4}px)` : "rotate(0deg) translate(0,0)",
            boxShadow: active ? "0 10px 22px rgba(30,20,8,0.14)" : "none",
            transition: `transform 550ms cubic-bezier(0.22,1,0.36,1) ${i * 60}ms, opacity 400ms ease ${i * 60}ms, box-shadow 400ms ease`,
          }}
        />
      ))}
    </>
  );
}

// Card 4 — horizontal contour lines building upward (additive-manufacturing
// layers), then a wireframe isometric solid drawing itself in with a slow,
// restrained sway rather than an aggressive spin.
function Scene3D({ active, reduced, accent }) {
  const top = "M95,28 L134,49 L95,70 L56,49 Z";
  const left = "M56,49 L95,70 L95,112 L56,91 Z";
  const right = "M134,49 L95,70 L95,112 L134,91 Z";
  return (
    <svg aria-hidden="true" viewBox="0 0 190 140" preserveAspectRatio="xMidYMid slice" style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}>
      {[0, 1, 2, 3, 4].map((i) => (
        <line
          key={i} x1={68} x2={122} y1={122 - i * 11} y2={122 - i * 11}
          stroke={accent} strokeWidth="1" opacity={active ? 0.34 : 0}
          style={{ transition: `opacity 350ms ease ${i * 70}ms, transform 550ms cubic-bezier(0.22,1,0.36,1) ${i * 70}ms`, transform: active ? "translateY(0)" : "translateY(10px)" }}
        />
      ))}
      <g style={{ transformBox: "fill-box", transformOrigin: "center", animation: active && !reduced ? "ink-svc-sway 7s ease-in-out infinite" : "none" }}>
        {[top, left, right].map((d, i) => (
          <path key={i} d={d} fill="none" stroke={accent} strokeWidth="1.4" pathLength="1" strokeDasharray="1" strokeDashoffset={active ? 0 : 1} opacity={active ? 0.9 : 0.16} style={{ transition: `stroke-dashoffset 700ms ease ${350 + i * 120}ms, opacity 500ms ease` }} />
        ))}
      </g>
    </svg>
  );
}

const SERVICE_SCENES = { media: SceneMedia, motion: SceneMotion, print: ScenePrint, three: Scene3D };

// Home's own service card ids ("media"/"motion"/"print"/"three") predate
// the stable cross-page service keys used by the Services quadrant grid
// and Contact's selection state (media/motion/print/custom3d) — renaming
// them here would touch SERVICE_MOODS/SERVICE_SCENES for no reason, so
// this just maps the one id that differs when navigating onward.
const HOME_SERVICE_KEY = { media: "media", motion: "motion", print: "print", three: "custom3d" };

// Measured directly against the rendered mobile card (card bottom edge to
// the "Start a project" button's own top edge, size="sm" + its
// paddingTop wrapper) rather than guessed — an over-generous reservation
// here doesn't just waste space, it pushes each Scene's own bottom-
// anchored decoration (already tuned to sit close to the card's edge) up
// into the description text above it instead of just clearing the button.
const SVC_CARD_CTA_CLEARANCE = 58;

function ServiceCard({ svc, ar, active, otherActive, narrow, reduced, onEnter, onFocus, onBlur, onOpen, onToggle, onStartProject }) {
  const mood = SERVICE_MOODS[svc.id];
  const Scene = SERVICE_SCENES[svc.id];
  const restBg = svc.tint ? "var(--surface-tint)" : "var(--surface-card)";
  const restFg = "var(--ink-900)";
  const restMuted = "var(--text-muted)";
  // On mobile there's no hover to preview a service before committing to a
  // page change, so a tap expands the card in place instead of navigating
  // immediately — same interaction language as the Packages cards just
  // above this section. Desktop keeps its existing hover-to-preview,
  // tap/click-to-open-Services behavior exactly as approved.
  return (
    <div
      className="ink-svc-card"
      role={narrow ? "button" : "link"}
      tabIndex={0}
      aria-expanded={active}
      aria-label={svc.title}
      onMouseEnter={!narrow ? onEnter : undefined}
      onFocus={!narrow ? onFocus : undefined}
      onBlur={!narrow ? onBlur : undefined}
      onClick={narrow ? onToggle : onOpen}
      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); narrow ? onToggle() : onOpen(); } }}
      style={{
        position: "relative",
        overflow: "hidden",
        cursor: "pointer",
        borderRadius: "var(--radius-xl)",
        border: "var(--rule-weight-strong) solid var(--border-strong)",
        width: "100%",
        minHeight: narrow ? undefined : 252,
        background: active ? mood.bg : restBg,
        transform: active && !narrow ? "translateY(-4px)" : "translateY(0)",
        boxShadow: active ? "0 22px 44px rgba(15,12,10,0.22)" : "none",
        opacity: !narrow && otherActive && !active ? 0.86 : 1,
        transition: "background 520ms ease, transform 480ms cubic-bezier(0.22,1,0.36,1), box-shadow 480ms ease, opacity 420ms ease",
      }}
    >
      {/* Each Scene positions its own artwork against whatever box this
          wrapper gives it — percentages, viewBox "slice" scaling, and
          bottom-anchored px offsets alike. On mobile, once the card
          expands to add the "Start a project" CTA below the description,
          all four Scenes' own inset:0 wrapper used to keep covering that
          newly taller box too — pushing content that was tuned to sit low
          in a shorter desktop card (the focus frame, the print cluster's
          bottom-anchored notebook/cards) down into where the CTA now
          renders. Reserving that same CTA-sized strip here, once, keeps
          every Scene's own math unchanged while giving all four a shared,
          consistent safe zone above the button — never a per-scene fix. */}
      <span aria-hidden="true" style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: narrow && active ? SVC_CARD_CTA_CLEARANCE : 0, zIndex: 0 }}>
        <Scene active={active} reduced={reduced} accent={mood.accent} narrow={narrow} />
      </span>
      <div style={{ position: "relative", zIndex: 1, display: "flex", flexDirection: "column", gap: "var(--space-4)", height: "100%", padding: "var(--space-6)" }}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
          <Icon name={svc.icon} size={24} color={active ? mood.accent : "var(--ink-900)"} style={{ transition: "background 400ms ease, transform 400ms ease", transform: active ? "translateY(-2px) scale(1.06)" : "none" }} />
          {narrow ? (
            <Icon name="chevron-down" size={16} color={active ? mood.fgMuted : "var(--ink-400)"} style={{ transition: "transform 400ms ease, background 400ms ease", transform: active ? "rotate(180deg)" : "rotate(0deg)" }} />
          ) : (
            <Icon name="arrow-up-right" size={16} color={active ? mood.fgMuted : "var(--ink-400)"} style={{ transition: "transform 400ms ease, background 400ms ease", transform: active ? "translate(2px,-2px)" : "none" }} />
          )}
        </div>
        <div style={{ fontFamily: ar ? "var(--font-arabic)" : "var(--font-display)", fontWeight: ar ? 700 : "var(--weight-title)", fontStretch: ar ? "normal" : "var(--stretch-title)", fontSize: "var(--size-h3)", lineHeight: 1.2, color: active ? mood.fg : restFg, transition: "color 480ms ease" }}>{svc.title}</div>
        <p style={{ fontSize: "var(--size-body-sm)", color: active ? mood.fgMuted : restMuted, fontFamily: ar ? "var(--font-arabic)" : "var(--font-body)", transition: "color 480ms ease", marginTop: "auto" }}>{svc.desc}</p>
        {narrow ? (
          <div style={{ display: "grid", gridTemplateRows: active ? "1fr" : "0fr", transition: "grid-template-rows 480ms cubic-bezier(0.22,1,0.36,1)" }}>
            <div style={{ overflow: "hidden" }}>
              <div style={{ paddingTop: "var(--space-4)" }}>
                <Button
                  variant={active ? "accent" : "secondary"}
                  size="sm"
                  fullWidth
                  iconRight="arrow-up-right"
                  onClick={(e) => { e.stopPropagation(); onStartProject(); }}
                >
                  {ar ? "ابدأ مشروع" : "Start a project"}
                </Button>
              </div>
            </div>
          </div>
        ) : null}
      </div>
    </div>
  );
}

// Mobile-only background decoration for sections that read as too visually
// empty on a narrow viewport (What We Make, Packages) — reuses the Work
// page's own decorative vocabulary verbatim (its outlined production/design
// icon set, BG_SHAPES/BG_COLOR, and the sitewide .ink-blob pastel-form
// technique) rather than inventing a new one, per the task's explicit
// instruction, at roughly Work-page density (small `dx` insets keep every
// icon in the page-margin gutter that runs the full height of the card
// stack — a continuously VISIBLE strip beside full-width mobile cards,
// rather than a couple of icons mostly hidden under opaque card
// backgrounds). Confined to the section's own outer edges — see Section's
// `decor` layer in Shell.jsx — so it never sits under a card's text/CTA,
// which always paints above it (decor is zIndex 0, content is 1).
function HomeMobileDecor({ variant, reduced }) {
  const BG_SHAPES = window.BG_SHAPES || {};
  const BG_COLOR = window.BG_COLOR || {};
  const blobs = variant === "make"
    ? [
        { size: 150, left: "-9%", top: "1%", color: "var(--cobalt-600)", op: 0.05 },
        { size: 110, right: "-8%", top: "24%", color: "var(--vermilion-500)", op: 0.045 },
        { size: 130, left: "-8%", top: "45%", color: "var(--saffron-500)", op: 0.045 },
        { size: 120, right: "-9%", top: "68%", color: "var(--pine-500)", op: 0.045 },
        { size: 140, left: "-9%", top: "92%", color: "var(--cobalt-600)", op: 0.04 },
      ]
    : [
        { size: 140, right: "-9%", top: "2%", color: "var(--saffron-500)", op: 0.05 },
        { size: 115, left: "-8%", top: "26%", color: "var(--cobalt-600)", op: 0.045 },
        { size: 125, right: "-8%", top: "48%", color: "var(--vermilion-500)", op: 0.045 },
        { size: 115, left: "-9%", top: "70%", color: "var(--pine-500)", op: 0.045 },
        { size: 135, right: "-9%", top: "93%", color: "var(--saffron-500)", op: 0.04 },
      ];
  const icons = variant === "make"
    ? [
        { type: "camera", top: "1.5%", side: "right", dx: 6, size: 32 },
        { type: "tag", top: "9%", side: "left", dx: 4, size: 24 },
        { type: "cube", top: "17%", side: "right", dx: 4, size: 30 },
        { type: "mCircle", top: "26%", side: "left", dx: 6, size: 22 },
        { type: "sticker", top: "34%", side: "right", dx: 6, size: 26 },
        { type: "printer", top: "42%", side: "left", dx: 4, size: 28 },
        { type: "notebook", top: "50%", side: "right", dx: 6, size: 26 },
        { type: "mSquare", top: "58%", side: "left", dx: 8, size: 22 },
        { type: "tote", top: "66%", side: "right", dx: 4, size: 30 },
        { type: "filmFrame", top: "74%", side: "left", dx: 6, size: 26 },
        { type: "mic", top: "82%", side: "right", dx: 8, size: 24 },
        { type: "geo", top: "90%", side: "left", dx: 6, size: 24 },
        { type: "printer", top: "97%", side: "right", dx: 8, size: 26 },
      ]
    : [
        { type: "tote", top: "1.5%", side: "left", dx: 6, size: 32 },
        { type: "box", top: "9%", side: "right", dx: 4, size: 28 },
        { type: "medal", top: "17%", side: "left", dx: 6, size: 24 },
        { type: "brochure", top: "25%", side: "right", dx: 6, size: 26 },
        { type: "trophy", top: "33%", side: "left", dx: 4, size: 26 },
        { type: "light", top: "41%", side: "right", dx: 8, size: 24 },
        { type: "card", top: "49%", side: "left", dx: 6, size: 26 },
        { type: "shieldAward", top: "57%", side: "right", dx: 4, size: 24 },
        { type: "camera", top: "65%", side: "left", dx: 8, size: 28 },
        { type: "tripod", top: "73%", side: "right", dx: 6, size: 26 },
        { type: "badge", top: "81%", side: "left", dx: 8, size: 22 },
        { type: "cube", top: "89%", side: "right", dx: 4, size: 28 },
        { type: "light", top: "96%", side: "left", dx: 8, size: 24 },
      ];
  return (
    <>
      {blobs.map((b, i) => (
        <div
          key={`b${i}`}
          className="ink-blob"
          style={{ position: "absolute", width: b.size, height: b.size, left: b.left, right: b.right, top: b.top, background: b.color, opacity: b.op, animation: reduced ? "none" : `ink-blob-morph 9s var(--ease-standard) infinite, ink-drift ${14 + i * 3}s var(--ease-standard) infinite ${-i * 4}s` }}
        />
      ))}
      {icons.map((m, i) => {
        const Shape = BG_SHAPES[m.type];
        if (!Shape) return null;
        return (
          <div key={`i${i}`} style={{ position: "absolute", width: m.size, height: m.size, top: m.top, [m.side]: m.dx, opacity: 0.16 }}>
            <Shape color={BG_COLOR[m.type] || "var(--ink-400)"} />
          </div>
        );
      })}
    </>
  );
}

function WhatWeMake({ ar, setPage }) {
  const narrow = useIsNarrow();
  const reduced = useReducedMotion();
  const [activeId, setActiveId] = React.useState(null);
  const SERVICES = [
    { id: "media", icon: "camera", tint: true, title: ar ? "إنتاج إعلامي" : "Media production", desc: ar ? "تصوير فوتوغرافي وفيديو وسينمائي، مونتاج، تغطية الفعاليات" : "Photography, videography, cinematography, editing, event coverage" },
    { id: "motion", icon: "clapperboard", title: ar ? "موشن وأنيميشن" : "Motion & animation", desc: ar ? "موشن جرافيك، أنيميشن الشعار، محتوى متحرك، مؤثرات بصرية" : "Motion graphics, logo animation, animated content, visual effects" },
    { id: "print", icon: "printer", title: ar ? "طباعة ومنتجات" : "Print & merchandise", desc: ar ? "ملصقات، بطاقات، قرطاسية، حقائب قماشية، طباعة شاشة حريرية، هدايا الفعاليات" : "Stickers, cards, stationery, tote bags, screen printing, event giveaways" },
    { id: "three", icon: "box", title: ar ? "طباعة ٣د وتصنيع مخصص" : "3D printing & custom objects", desc: ar ? "دروع، ميداليات، دبابيس، مجسمات مخصصة، حوامل عرض، وقطع مطبوعة ثلاثية الأبعاد حسب الطلب" : "Awards, medals, pins, custom models, stands, displays, and made-to-order 3D printed objects" },
  ];
  return (
    <Section index="01" title={ar ? "ما نقدمه" : "What we make"} decor={narrow ? <HomeMobileDecor variant="make" reduced={reduced} /> : null}>
      <style>{`
        @keyframes ink-svc-sweep { 0% { transform: translateX(-24%); } 50% { transform: translateX(24%); } 100% { transform: translateX(-24%); } }
        @keyframes ink-svc-sway { 0%, 100% { transform: rotate(-2deg) scaleX(0.985); } 50% { transform: rotate(2deg) scaleX(1.015); } }
        @keyframes ink-svc-focus-pulse { 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.9; } 50% { transform: translate(-50%, -50%) scale(1.8); opacity: 0.45; } }
        @keyframes ink-svc-rec-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
        @keyframes ink-svc-travel-a { 0%, 100% { transform: translate(0, 0) rotate(0deg); } 50% { transform: translate(1px, -3px) rotate(-8deg); } }
        @keyframes ink-svc-travel-b { 0%, 100% { transform: translate(0, 0) rotate(0deg) scale(1); } 50% { transform: translate(-2px, 2px) rotate(6deg) scale(1.06); } }
        @keyframes ink-svc-travel-c { 0%, 100% { transform: translate(0, 0) rotate(0deg); } 50% { transform: translate(2px, -2px) rotate(-5deg); } }
        @keyframes ink-svc-float-a { 0%, 100% { transform: translateY(0) rotate(-6deg); } 50% { transform: translateY(-5px) rotate(-3deg); } }
        @keyframes ink-svc-float-b { 0%, 100% { transform: translateY(0) rotate(10deg); } 50% { transform: translateY(4px) rotate(13deg); } }
        @keyframes ink-svc-float-c { 0%, 100% { transform: translateY(0) rotate(-7deg); } 50% { transform: translateY(-4px) rotate(-4deg); } }
        .ink-svc-card:focus { outline: none; }
        .ink-svc-card:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
      `}</style>
      <div
        onMouseLeave={!narrow ? () => setActiveId(null) : undefined}
        style={{ display: "flex", flexDirection: narrow ? "column" : "row", gap: "var(--space-5)", alignItems: "stretch", width: "100%" }}
      >
        {SERVICES.map((svc, i) => {
          const pct = activeId === svc.id ? SERVICE_EXPANDED_PCT : activeId ? SERVICE_COLLAPSED_PCT : 25;
          return (
            // This plain div (not Reveal) is the actual flex item that owns
            // flex-basis and its own width transition — Reveal's hardcoded
            // opacity/transform transition is for the one-time scroll-in
            // fade only, so the hover/tap width animation needs a separate
            // element to animate on rather than fighting over the same
            // `transition` string.
            <div key={svc.id} style={narrow ? { width: "100%" } : { flex: `0 1 ${pct}%`, minWidth: 0, transition: reduced ? "none" : "flex-basis 620ms cubic-bezier(0.22,1,0.36,1)" }}>
              <Reveal delay={i * 90}>
                <ServiceCard
                  svc={svc}
                  ar={ar}
                  narrow={narrow}
                  reduced={reduced}
                  active={activeId === svc.id}
                  otherActive={!!activeId}
                  onEnter={() => setActiveId(svc.id)}
                  onFocus={() => setActiveId(svc.id)}
                  onBlur={() => setActiveId((cur) => (cur === svc.id ? null : cur))}
                  onOpen={() => setPage("Services", { service: HOME_SERVICE_KEY[svc.id] })}
                  onToggle={() => setActiveId((cur) => (cur === svc.id ? null : svc.id))}
                  onStartProject={() => setPage("Contact", { service: HOME_SERVICE_KEY[svc.id] })}
                />
              </Reveal>
            </div>
          );
        })}
      </div>
    </Section>
  );
}

// ============================================================================
// "Packages" — four progressive package tiers replacing the homepage
// Selected Work grid (the portfolio showcase above already carries that
// job). Same expand-on-hover/tap layout quality as "What We Make", but a
// distinct visual identity: each tier reads as more valuable than the
// last (basic → enhanced → premium → flagship), and the whole section —
// not just the active card — subtly focuses around whichever tier is
// active. Isolated to this section only: it does not touch the Services
// page's own Event Packages, the portfolio/gallery, What We Make, or How
// We Work.
// ============================================================================

const PACKAGE_EXPANDED_PCT = 40;
const PACKAGE_COLLAPSED_PCT = (100 - PACKAGE_EXPANDED_PCT) / 3;

// Content lives here as plain data — names, taglines, "best for" lines,
// included items and pricing can all change later without touching any of
// the visual/animation code below.
// "Starting from" public prices — the same four SAR values everywhere a
// package appears (Home, Services). Not fixed prices and not a minimum
// order: the final quote still depends on quantities, specs, media and
// fabrication requirements, and overall project scope.
function packagesData(ar) {
  return [
    {
      id: "starter", tier: 1, icon: "package", price: 1000,
      name: ar ? "حزمة البداية" : "Starter Pack",
      tagline: ar ? "حزمة تفعيل بسيطة وأنيقة تضع علامتك بين أيدي الناس." : "A clean, simple activation kit that puts your brand in people's hands.",
      bestFor: ar ? "الأنسب لـ: فعاليات صغيرة، إطلاقات سريعة، هدايا فقط" : "Best for: small events, quick launches, giveaway-only needs",
      includes: ar ? ["ملصقات", "بطاقات / كتيبات", "تجهيز طباعة أساسي"] : ["Sticker sheets", "Cards / brochures", "Basic print preparation"],
    },
    {
      id: "merch", tier: 2, icon: "shopping-bag", price: 5000,
      name: ar ? "حزمة المنتجات" : "Merch Pack",
      tagline: ar ? "قطع أكثر، وحضور أوسع — إنتاج مادي للفعاليات الأكبر." : "More pieces, more reach — physical production for larger activations.",
      bestFor: ar ? "الأنسب لـ: تفعيل العلامة، هدايا الرعاة، المتاجر المؤقتة" : "Best for: brand activations, sponsor gifting, retail pop-ups",
      includes: ar ? ["ملصقات", "بطاقات / كتيبات", "حقائب قماشية", "قطع مخصصة/ثلاثية الأبعاد مختارة"] : ["Stickers", "Cards / brochures", "Tote bags", "Selected custom / 3D-produced items"],
    },
    {
      id: "event", tier: 3, icon: "camera", price: 10000,
      name: ar ? "حضور الفعالية" : "Event Presence",
      tagline: ar ? "إنتاج مادي مع تغطية إعلامية حقيقية للفعالية." : "Physical production paired with real event coverage.",
      bestFor: ar ? "الأنسب لـ: المؤتمرات والإطلاقات التي تحتاج توثيقاً" : "Best for: conferences, launches, activations that need documentation",
      includes: ar ? ["طباعة ومنتجات مختارة", "تصوير فوتوغرافي للفعالية", "تغطية مرئية قصيرة", "قطع تفعيل مادية مختارة"] : ["Print & merchandise selection", "Event/product photography", "Short-form visual coverage", "Selected physical activation items"],
    },
    {
      id: "full", tier: 4, icon: "sparkles", price: 15000,
      name: ar ? "التجربة الكاملة" : "Full Experience",
      tagline: ar ? "تجربة INK الكاملة — كل التخصصات، تحت توجيه واحد." : "The complete INK experience — every discipline, one direction.",
      bestFor: ar ? "الأنسب لـ: الإطلاقات الرئيسية والحملات الشاملة" : "Best for: flagship launches, campaigns that need it all",
      includes: ar ? ["طباعة", "منتجات", "قطع مخصصة / ثلاثية الأبعاد", "تصوير فوتوغرافي / فيديو", "موشن وأنيميشن"] : ["Print", "Merchandise", "Custom / 3D objects", "Photography / video coverage", "Motion & animation"],
    },
  ];
}

// Shared "SAR 1,000" / "1,000 ر.س" formatter — same digit grouping in
// both languages (the site's established convention), currency prefixed
// in English and suffixed in Arabic.
function formatPrice(n, ar) {
  const formatted = n.toLocaleString("en-US");
  return ar ? `${formatted} ر.س` : `SAR ${formatted}`;
}

// Deliberate progression, not four unrelated colors: a whisper of INK's
// primary accent → richer warm gold → a cooler cinematic tone → the
// flagship combining all three plus pine on a genuinely dark card, the
// one tier that goes all the way to "premium multi-accent."
const PACKAGE_MOODS = {
  starter: { fg: "var(--ink-900)", fgMuted: "var(--ink-500)", accent: "var(--vermilion-500)", glow: "224,90,60", cardBg: "linear-gradient(160deg, #FCFAF6 0%, #F3ECDE 100%)" },
  merch: { fg: "var(--ink-900)", fgMuted: "var(--ink-700)", accent: "var(--saffron-600)", glow: "230,163,44", cardBg: "linear-gradient(160deg, #FCF4DE 0%, #F1DBA0 100%)" },
  event: { fg: "var(--ink-900)", fgMuted: "var(--ink-700)", accent: "var(--cobalt-600)", glow: "27,54,204", cardBg: "linear-gradient(160deg, #E9EFF8 0%, #B9C8E4 100%)" },
  full: { fg: "var(--paper-050)", fgMuted: "rgba(246,243,237,0.72)", accent: "var(--saffron-500)", glow: "240,179,44", cardBg: "linear-gradient(160deg, #1c1926 0%, #100e15 100%)" },
};

// Package tiers are cumulative: each tier's scene renders the FULL stack
// of every previous tier's objects (via these shared layer components)
// plus its own new layer on top. Nothing is ever swapped out or reset —
// Merch contains Starter's print objects, Event contains Merch's (which
// already contains Starter's), and Full contains all three plus motion.
// Each layer keeps a fixed, discipline-specific identity color (white/
// silver for print, gold for merch, cobalt for production, pine for 3D,
// vermilion for motion) regardless of which tier's own accent/background
// it's rendered on top of — the same fixed-per-thread convention the
// approved Full Experience card already used before this pass.

// Print layer — two overlapping cards/brochures, a simple printed card,
// a white/silver sticker sheet, and the grouped sticker/card cluster:
// unmistakably clean white/basic print, never accent-filled. Present on
// every tier from Starter up.
function PrintLayer({ active, reduced, narrow, shiftY = 0 }) {
  return (
    <>
      {/* Two overlapping cards/brochures, beside the price block. Desktop
          only — on narrow, wrapped text shifts everything below it, so
          these fixed positions are hidden rather than risk drifting onto
          the price block on a narrower card. shiftY (0 for Starter, +27
          Merch/Event, +56 Full) keeps this tracking each tier's actual
          price-block position as their longer includes lists push it
          down. */}
      {[0, 1].map((i) => (
        <span
          key={i}
          aria-hidden="true"
          style={{
            position: "absolute", insetInlineEnd: 110 + i * 16, top: 236 + shiftY + i * 6, width: 46, height: 42, borderRadius: 6, zIndex: i,
            background: i === 1 ? "var(--paper-000)" : "var(--sand-100)",
            border: "1px solid var(--ink-200)",
            opacity: active && !narrow ? 1 : 0,
            transform: active ? `rotate(${i === 0 ? -6 : 5}deg)` : "rotate(0deg)",
            boxShadow: active ? "0 8px 16px rgba(30,20,8,0.10)" : "none",
            transition: `transform 520ms cubic-bezier(0.22,1,0.36,1) ${i * 70}ms, opacity 380ms ease ${i * 70}ms, box-shadow 380ms ease`,
          }}
        />
      ))}
      {/* A simple printed card — beside the heading, right of the short
          name/tier-number row, and above the tagline (a fixed position
          across every tier), so it needs no shiftY. */}
      <span aria-hidden="true" style={{ position: "absolute", insetInlineEnd: 30, top: 44, width: 44, height: 28, zIndex: 2, opacity: active ? 0.85 : 0, transform: active ? "rotate(6deg)" : "rotate(16deg) scale(0.7)", transition: "transform 520ms cubic-bezier(0.22,1,0.36,1) 140ms, opacity 400ms ease 140ms" }}>
        <span style={{ position: "absolute", inset: 0, borderRadius: 4, border: "1.4px solid var(--ink-300)", background: "var(--paper-000)" }} />
        <span style={{ position: "absolute", insetInlineStart: 8, top: "50%", insetInlineEnd: 8, height: 1, background: "var(--ink-200)", transform: "translateY(-50%)" }} />
      </span>
      {/* Sticker sheet — white/silver with its own confined dot cluster,
          never accent-colored, beside the price block. Desktop only. */}
      <span aria-hidden="true" style={{ position: "absolute", insetInlineStart: 186, top: 246 + shiftY, width: 26, height: 26, borderRadius: 8, background: "var(--paper-000)", border: "1.4px solid var(--ink-300)", zIndex: 2, opacity: active && !narrow ? 0.9 : 0, transform: active ? "rotate(-6deg) scale(1)" : "rotate(8deg) scale(0.6)", transition: "transform 500ms cubic-bezier(0.22,1,0.36,1) 220ms, opacity 380ms ease 220ms" }}>
        <span style={{ position: "absolute", inset: 0, display: "grid", gridTemplateColumns: "repeat(2, 1fr)", placeItems: "center" }}>
          {[0, 1, 2, 3].map((i) => <span key={i} style={{ width: 2.4, height: 2.4, borderRadius: "50%", background: "var(--ink-300)" }} />)}
        </span>
      </span>
      {/* A grouped sticker-sheet/card cluster — right of every tier's own
          includes list (the widest, Merch's 4-item list, reaches x276, so
          x300+ is clear on every tier), a fixed position needing no
          shiftY. Desktop only. */}
      <span aria-hidden="true" style={{ position: "absolute", insetInlineStart: 300, top: 128, width: 62, height: 44, zIndex: 2, opacity: active && !narrow ? 1 : 0, transition: `opacity 420ms ease 180ms, transform 620ms cubic-bezier(0.22,1,0.36,1) 180ms`, transform: active ? "translateY(0)" : "translateY(-16px)" }}>
        <span style={{ position: "absolute", inset: "8px 0 0 8px", borderRadius: 8, background: "var(--paper-000)", border: "1.4px solid var(--ink-300)", transform: "rotate(6deg)" }}>
          <span style={{ position: "absolute", inset: 0, display: "grid", gridTemplateColumns: "repeat(2, 1fr)", placeItems: "center" }}>
            {[0, 1, 2, 3].map((i) => <span key={i} style={{ width: 2.6, height: 2.6, borderRadius: "50%", background: "var(--ink-300)" }} />)}
          </span>
        </span>
        <span style={{ position: "absolute", inset: "0 8px 8px 0", borderRadius: 6, background: "var(--sand-100)", border: "1.4px solid var(--ink-200)", transform: "rotate(-8deg)" }} />
      </span>
      {/* A very restrained, slow pearl/paper sheen — the "premium paper"
          response asked for, deliberately never glossy or metallic. */}
      <span aria-hidden="true" style={{ position: "absolute", inset: 0, overflow: "hidden", opacity: active ? 1 : 0, transition: "opacity 400ms ease" }}>
        <span style={{ position: "absolute", inset: "-20% -10%", background: "linear-gradient(100deg, transparent 42%, rgba(255,255,255,0.45) 50%, transparent 58%)", animation: active && !reduced ? "ink-svc-sweep 9000ms ease-in-out infinite" : "none" }} />
      </span>
    </>
  );
}

// The established green wireframe isometric solid (the same 3-face
// shape the homepage's own 3D & Custom card and the Services page's
// QuadScene3D use), always pine-green regardless of which tier it sits
// on — this is "the 3D layer's" identity color, not a tier accent.
function Cube3D({ active, reduced, size = 44, floatAnim }) {
  const s = 13;
  const top = `M${s * 1.5},0 L${s * 2.9},${s * 0.8} L${s * 1.5},${s * 1.6} L${s * 0.1},${s * 0.8} Z`;
  const left = `M${s * 0.1},${s * 0.8} L${s * 1.5},${s * 1.6} L${s * 1.5},${s * 3.2} L${s * 0.1},${s * 2.4} Z`;
  const right = `M${s * 2.9},${s * 0.8} L${s * 1.5},${s * 1.6} L${s * 1.5},${s * 3.2} L${s * 2.9},${s * 2.4} Z`;
  return (
    <svg aria-hidden="true" viewBox={`0 0 ${s * 3} ${s * 3.2}`} width={size} height={size * 1.07} style={{ display: "block", overflow: "visible" }}>
      <g style={{ transformBox: "fill-box", transformOrigin: "center", animation: active && !reduced ? `${floatAnim} 7800ms ease-in-out infinite` : "none" }}>
        {[top, left, right].map((d, i) => (
          <path key={i} d={d} fill="none" stroke="var(--pine-500)" strokeWidth="1.8" pathLength="1" strokeDasharray="1" strokeDashoffset={active ? 0 : 1} opacity={active ? 0.9 : 0} style={{ transition: `stroke-dashoffset 650ms var(--ease-standard) ${i * 90}ms, opacity 450ms ease ${i * 90}ms` }} />
        ))}
      </g>
    </svg>
  );
}

function ScenePkgStarter({ active, reduced, narrow }) {
  return <PrintLayer active={active} reduced={reduced} narrow={narrow} shiftY={0} />;
}

// Merch layer — a much larger tote (moved in from the corner to become a
// real visual anchor), the merch tag, and the established green
// wireframe 3D cube. Fixed gold (saffron-500) identity for the tote/tag/
// sticker regardless of which tier's own accent color it sits on top of
// — "the merch layer," not a tier-tinted decoration. Present on every
// tier from Merch up, on top of the full PrintLayer beneath it.
function MerchLayer({ active, reduced, narrow, shiftY = 27 }) {
  const gold = "var(--saffron-500)";
  return (
    <>
      {/* Upper-middle-right: a much larger tote, moved in from the
          corner to become a real visual anchor, with the tag beside it. */}
      <svg aria-hidden="true" viewBox="0 0 60 60" style={{ position: "absolute", insetInlineEnd: 160, top: 10, width: 58, height: 58, opacity: active ? 0.9 : 0, transform: active ? "translateY(0) scale(1)" : "translateY(8px) scale(0.85)", transition: "opacity 450ms ease 120ms, transform 450ms cubic-bezier(0.22,1,0.36,1) 120ms" }}>
        <path d="M14,20 L14,50 Q14,54 18,54 L42,54 Q46,54 46,50 L46,20 Z" fill="none" stroke={gold} strokeWidth="1.8" />
        <path d="M21,20 V13 Q21,6 30,6 Q39,6 39,13 V20" fill="none" stroke={gold} strokeWidth="1.8" />
      </svg>
      <span aria-hidden="true" style={{ position: "absolute", insetInlineEnd: 100, top: 44, width: 26, height: 18, opacity: active ? 0.85 : 0, transform: active ? "rotate(18deg)" : "rotate(30deg) scale(0.7)", transition: "opacity 450ms ease 220ms, transform 450ms cubic-bezier(0.22,1,0.36,1) 220ms" }}>
        <span style={{ position: "absolute", inset: 0, borderRadius: 3, border: `1.4px solid ${gold}` }} />
        <span style={{ position: "absolute", insetInlineStart: 4, top: "50%", width: 3, height: 3, borderRadius: "50%", border: `1px solid ${gold}`, transform: "translateY(-50%)" }} />
      </span>

      {/* The established green wireframe 3D cube — middle-right,
          communicating the newly added custom/3D capability. Desktop
          only: on narrow the tagline/list wrap to more lines and shift
          this zone, so it's hidden rather than risking drift onto text. */}
      <span aria-hidden="true" style={{ position: "absolute", insetInlineEnd: 60, top: 140, opacity: active && !narrow ? 1 : 0, transition: "opacity 450ms ease 300ms" }}>
        <Cube3D active={active} reduced={reduced} size={46} floatAnim="ink-svc-float-a" />
      </span>
      {/* Gold sticker with its own dot cluster. Desktop only, beside the
          price block (shiftY-tracked). */}
      <span aria-hidden="true" style={{ position: "absolute", insetInlineStart: 190, top: 243 + shiftY, width: 28, height: 28, borderRadius: 8, background: gold, zIndex: 2, opacity: active && !narrow ? 0.9 : 0, transform: active ? "rotate(-7deg) scale(1)" : "rotate(10deg) scale(0.6)", transition: "transform 500ms cubic-bezier(0.22,1,0.36,1) 260ms, opacity 380ms ease 260ms" }}>
        <span style={{ position: "absolute", inset: 0, display: "grid", gridTemplateColumns: "repeat(2, 1fr)", placeItems: "center" }}>
          {[0, 1, 2, 3].map((i) => <span key={i} style={{ width: 2.6, height: 2.6, borderRadius: "50%", background: "var(--paper-000)" }} />)}
        </span>
      </span>
      {/* The existing stacked print sheets — moved farther right, clear
          of the sticker above and of PrintLayer's own card pair, spread
          across the width instead of clustering. Desktop only. */}
      {[0, 1, 2].map((i) => (
        <span
          key={i}
          aria-hidden="true"
          style={{
            position: "absolute", insetInlineEnd: 16 + i * 9, top: 235 + shiftY + i * 4, width: 40, height: 38, borderRadius: 6, zIndex: i,
            background: i === 2 ? "var(--paper-000)" : "var(--sand-200)",
            border: "1px solid var(--ink-200)",
            opacity: active && !narrow ? 1 : 0,
            transform: active ? `rotate(${(i - 1) * 6}deg) translate(${(i - 1) * 5}px, 0)` : "rotate(0deg) translate(0,0)",
            boxShadow: active ? "0 8px 18px rgba(30,20,8,0.12)" : "none",
            transition: `transform 520ms cubic-bezier(0.22,1,0.36,1) ${i * 60}ms, opacity 380ms ease ${i * 60}ms`,
          }}
        />
      ))}

      {/* Restrained moving gold sheen, plus two tiny warm glints — the
          "premium gold behavior" asked for, deliberately sparse rather
          than glitter or jewelry-styled. */}
      <span aria-hidden="true" style={{ position: "absolute", inset: 0, overflow: "hidden", opacity: active ? 1 : 0, transition: "opacity 400ms ease" }}>
        <span style={{ position: "absolute", inset: "-20% -10%", background: "linear-gradient(100deg, transparent 42%, rgba(240,179,44,0.32) 50%, transparent 58%)", animation: active && !reduced ? "ink-svc-sweep 8000ms ease-in-out infinite" : "none" }} />
      </span>
      {[{ x: "insetInlineEnd", xv: 220, y: "top", yv: 8, d: "0s", safe: true }, { x: "insetInlineStart", xv: 380, y: "top", yv: 170, d: "1.4s", safe: false }].map((g, i) => (
        <svg key={i} aria-hidden="true" viewBox="0 0 16 16" style={{ position: "absolute", [g.x]: g.xv, [g.y]: g.yv, width: 10, height: 10, opacity: active && (g.safe || !narrow) ? 0.8 : 0, transition: "opacity 450ms ease 380ms" }}>
          <path d="M8,0 L9.4,6.6 L16,8 L9.4,9.4 L8,16 L6.6,9.4 L0,8 L6.6,6.6 Z" fill="var(--saffron-500)">
            {active && !reduced ? <animate attributeName="opacity" values="0.3;1;0.3" dur="3.2s" begin={g.d} repeatCount="indefinite" /> : null}
          </path>
        </svg>
      ))}
    </>
  );
}

// A simplified but genuinely recognizable camera object (body, viewfinder
// bump, lens) — not concentric circles — plus a real microphone and a
// studio light. Fixed cobalt identity, "the production layer," present
// on Event and Full.
function ProductionLayer({ active, reduced, narrow, shiftY = 27 }) {
  const cobalt = "var(--cobalt-600)";
  return (
    <>
      {/* Camera — a real simplified camera symbol (body + viewfinder +
          lens), enlarged, right of this tier's own list, left of the 3D
          cube above it. Desktop only, for the usual wrap reason. Sits
          below PrintLayer's grouped card cluster (top:128, height 44) —
          top:182 clears its bottom edge instead of overlapping it. */}
      <svg aria-hidden="true" viewBox="0 0 54 42" style={{ position: "absolute", insetInlineEnd: 130, top: 182, width: 58, height: 45, opacity: active && !narrow ? 0.85 : 0, transform: active ? "translateY(0)" : "translateY(10px)", transition: "opacity 450ms ease 180ms, transform 450ms cubic-bezier(0.22,1,0.36,1) 180ms" }}>
        <rect x="2" y="11" width="50" height="28" rx="4" fill="none" stroke={cobalt} strokeWidth="2" />
        <rect x="17" y="2" width="15" height="10" rx="2" fill="none" stroke={cobalt} strokeWidth="2" />
        <circle cx="27" cy="25" r="10" fill="none" stroke={cobalt} strokeWidth="2" />
        <circle cx="27" cy="25" r="4.4" fill="none" stroke={cobalt} strokeWidth="1.4" />
      </svg>
      {/* Studio light — production/event coverage, upper-right, enlarged.
          Above the tagline, so safe at any width. Sits below PrintLayer's
          simple printed card (top:44, height 28) — top:82 clears its
          bottom edge instead of overlapping it. */}
      <svg aria-hidden="true" viewBox="0 0 30 30" style={{ position: "absolute", insetInlineEnd: 30, top: 82, width: 34, height: 34, opacity: active ? 0.8 : 0, transform: active ? "translateY(0)" : "translateY(-8px)", transition: "opacity 450ms ease 180ms, transform 450ms cubic-bezier(0.22,1,0.36,1) 180ms" }}>
        <path d="M8,4 L22,4 L26,16 L4,16 Z" fill="none" stroke={cobalt} strokeWidth="1.6" />
        <line x1="15" y1="16" x2="15" y2="24" stroke={cobalt} strokeWidth="1.4" />
        <line x1="9" y1="24" x2="21" y2="24" stroke={cobalt} strokeWidth="1.4" />
      </svg>
      {/* Microphone — enlarged (it read far too small before), right of
          this tier's own list. Desktop only. Nudged down slightly to stay
          visually paired with the lowered camera. */}
      <svg aria-hidden="true" viewBox="0 0 20 40" style={{ position: "absolute", insetInlineStart: 270, top: 140, width: 26, height: 52, opacity: active && !narrow ? 0.85 : 0, transform: active ? "translateY(0)" : "translateY(10px)", transition: "opacity 450ms ease 240ms, transform 450ms cubic-bezier(0.22,1,0.36,1) 240ms" }}>
        <rect x="6" y="2" width="8" height="16" rx="4" fill="none" stroke={cobalt} strokeWidth="1.6" />
        <path d="M3,16 a7,7 0 0 0 14,0" fill="none" stroke={cobalt} strokeWidth="1.4" />
        <line x1="10" y1="23" x2="10" y2="34" stroke={cobalt} strokeWidth="1.4" />
        <line x1="4" y1="34" x2="16" y2="34" stroke={cobalt} strokeWidth="1.4" />
      </svg>

      <span aria-hidden="true" style={{ position: "absolute", inset: 0, overflow: "hidden", opacity: active ? 1 : 0, transition: "opacity 420ms ease" }}>
        <span style={{ position: "absolute", inset: "-25% -10%", background: "linear-gradient(100deg, transparent 40%, rgba(27,54,204,0.10) 50%, transparent 60%)" }} />
      </span>
      {/* Crystalline light glints — small, controlled, cool — never a
          card covered in diamonds. The first sits low near the price
          block (shiftY-tracked, desktop only); the second is above the
          tagline, safe at any width. */}
      {[{ x: "insetInlineEnd", xv: 100, y: "top", yv: 256 + shiftY, d: "0s", safe: false }, { x: "insetInlineStart", xv: 150, y: "top", yv: 30, d: "1.6s", safe: true }].map((g, i) => (
        <svg key={i} aria-hidden="true" viewBox="0 0 16 16" style={{ position: "absolute", [g.x]: g.xv, [g.y]: g.yv, width: 9, height: 9, opacity: active && (g.safe || !narrow) ? 0.75 : 0, transition: "opacity 450ms ease 380ms" }}>
          <path d="M8,0 L9.4,6.6 L16,8 L9.4,9.4 L8,16 L6.6,9.4 L0,8 L6.6,6.6 Z" fill="var(--paper-000)">
            {active && !reduced ? <animate attributeName="opacity" values="0.25;0.95;0.25" dur="3s" begin={g.d} repeatCount="indefinite" /> : null}
          </path>
        </svg>
      ))}
    </>
  );
}

function ScenePkgMerch({ active, reduced, narrow }) {
  return (
    <>
      <PrintLayer active={active} reduced={reduced} narrow={narrow} shiftY={27} />
      <MerchLayer active={active} reduced={reduced} narrow={narrow} shiftY={27} />
    </>
  );
}

function ScenePkgEvent({ active, reduced, narrow }) {
  return (
    <>
      <PrintLayer active={active} reduced={reduced} narrow={narrow} shiftY={27} />
      <MerchLayer active={active} reduced={reduced} narrow={narrow} shiftY={27} />
      <ProductionLayer active={active} reduced={reduced} narrow={narrow} shiftY={27} />
    </>
  );
}

// Tier 4 — Full Experience: the flagship. All four disciplines present at
// once — paper/print, a camera bracket, a self-drawing motion path, and a
// wireframe solid — plus a soft multi-tone radial bloom, the richest
// reveal of the four.
// Tier 4 — Full Experience: everything before it converging, plus motion
// as the final layer. Print (paper), merch (tote/tag), 3D (the wireframe
// gem), media (camera bracket/lens + a microphone for production) all sit
// on the card's right/lower open space, clear of the longer includes list
// on the left; the motion path now carries the site's own circle/square/
// triangle markers instead of plain dots. Dark cosmic bg, bloom and depth
// all kept exactly as approved.
// The final, Full-Experience-only layer: the site's own red motion
// spline carrying its circle → square → triangle vocabulary, made large
// and confident — "one of the strongest graphics in the composition,"
// not a tiny decorative curve. Travels through the card's central area;
// since the whole Scene renders behind the real copy (zIndex 0 vs 1),
// it's automatically behind the text wherever the two overlap.
function MotionLayer({ active, reduced }) {
  const d = "M36,332 C130,332 96,146 232,146 C338,146 356,258 466,208";
  return (
    <svg aria-hidden="true" viewBox="0 0 500 410" preserveAspectRatio="none" style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}>
      {[0, 1, 2].map((i) => (
        <path key={i} d={d} fill="none" stroke="var(--vermilion-500)" strokeWidth="2.4" opacity={active ? 0.06 + i * 0.03 : 0} transform={`translate(${i * 5},${-i * 4})`} style={{ transition: `opacity 450ms ease ${i * 80}ms` }} />
      ))}
      <path d={d} fill="none" stroke="var(--vermilion-500)" strokeWidth="3.2" pathLength="1" strokeDasharray="1" strokeDashoffset={active ? 0 : 1} opacity={active ? 0.85 : 0} style={{ transition: "stroke-dashoffset 1300ms cubic-bezier(0.22,1,0.36,1) 100ms, opacity 500ms ease 100ms" }} />
      {active && (
        <circle r="5" fill="var(--vermilion-500)" opacity="0.95">
          <animateMotion dur="5.5s" repeatCount={reduced ? 0 : "indefinite"} path={d} />
          {!reduced ? <animate attributeName="opacity" values="0.5;1;0.5" dur="2.4s" repeatCount="indefinite" /> : null}
        </circle>
      )}
      <g style={{ transformBox: "fill-box", transformOrigin: "center", animation: active && !reduced ? "ink-svc-travel-a 5200ms ease-in-out infinite" : "none" }}>
        <circle cx="36" cy="332" r="11" fill="none" stroke="var(--vermilion-500)" strokeWidth="2.8" opacity={active ? 0.95 : 0} style={{ transition: "opacity 420ms ease 320ms" }} />
      </g>
      <g style={{ transformBox: "fill-box", transformOrigin: "center", animation: active && !reduced ? "ink-svc-travel-b 6200ms ease-in-out infinite" : "none" }}>
        <rect x="214" y="128" width="36" height="36" fill="none" stroke="var(--vermilion-500)" strokeWidth="2.8" opacity={active ? 0.95 : 0}
          style={{ transform: active ? "scale(1) rotate(0deg)" : "scale(0) rotate(-20deg)", transformBox: "fill-box", transformOrigin: "center", transition: "transform 550ms cubic-bezier(0.22,1,0.36,1) 420ms, opacity 420ms ease 420ms" }} />
      </g>
      <g style={{ transformBox: "fill-box", transformOrigin: "center", animation: active && !reduced ? "ink-svc-travel-c 5800ms ease-in-out infinite" : "none" }}>
        <path d="M466,190 L484,222 L448,222 Z" fill="none" stroke="var(--vermilion-500)" strokeWidth="2.8" opacity={active ? 0.95 : 0}
          style={{ transform: active ? "scale(1)" : "scale(0)", transformBox: "fill-box", transformOrigin: "center", transition: "transform 550ms cubic-bezier(0.22,1,0.36,1) 480ms, opacity 420ms ease 480ms" }} />
      </g>
    </svg>
  );
}

// Tier 4 — Full Experience: everything from Event Presence (print, merch,
// 3D, production) still present, plus the final motion layer — the
// site's own red spline as the dominant new graphic. Dark cosmic
// atmosphere, bloom and depth kept exactly as approved, just as a plain
// gradient instead of a slice-scaled SVG now that this card composes the
// same absolutely-positioned layers as the other three tiers.
function ScenePkgFull({ active, reduced, narrow }) {
  return (
    <>
      <span aria-hidden="true" style={{ position: "absolute", inset: 0, opacity: active ? 1 : 0, transition: "opacity 550ms ease", background: "radial-gradient(60% 65% at 50% 42%, rgba(240,179,44,0.30) 0%, rgba(27,54,204,0.16) 55%, rgba(240,179,44,0) 100%)" }} />
      <PrintLayer active={active} reduced={reduced} narrow={narrow} shiftY={56} />
      <MerchLayer active={active} reduced={reduced} narrow={narrow} shiftY={56} />
      <ProductionLayer active={active} reduced={reduced} narrow={narrow} shiftY={56} />
      <MotionLayer active={active} reduced={reduced} />
    </>
  );
}

const PACKAGE_SCENES = { starter: ScenePkgStarter, merch: ScenePkgMerch, event: ScenePkgEvent, full: ScenePkgFull };

function PackageCard({ pkg, ar, active, otherActive, narrow, reduced, onEnter, onFocus, onBlur, onToggle, setPage }) {
  const mood = PACKAGE_MOODS[pkg.id];
  const Scene = PACKAGE_SCENES[pkg.id];
  const isFull = pkg.id === "full";
  return (
    <div
      className="ink-pkg-card"
      role={narrow ? "button" : "group"}
      tabIndex={0}
      aria-expanded={active}
      aria-label={pkg.name}
      onMouseEnter={!narrow ? onEnter : undefined}
      onFocus={!narrow ? onFocus : undefined}
      onBlur={!narrow ? onBlur : undefined}
      onClick={narrow ? onToggle : undefined}
      onKeyDown={narrow ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggle(); } } : undefined}
      style={{
        position: "relative",
        overflow: "hidden",
        cursor: narrow ? "pointer" : "default",
        borderRadius: "var(--radius-xl)",
        border: `var(--rule-weight-strong) solid ${active && isFull ? "var(--ink-900)" : "var(--border-strong)"}`,
        width: "100%",
        minHeight: narrow ? undefined : 296,
        background: active ? mood.cardBg : "var(--surface-card)",
        transform: active && !narrow ? `translateY(-6px) scale(${isFull ? 1.01 : 1})` : "translateY(0) scale(1)",
        boxShadow: active ? `0 26px ${isFull ? 56 : 40}px rgba(15,12,10,${isFull ? 0.32 : 0.16})` : "none",
        opacity: !narrow && otherActive && !active ? 0.82 : 1,
        filter: !narrow && otherActive && !active ? "saturate(0.92)" : "none",
        transition: "background 560ms ease, transform 500ms cubic-bezier(0.22,1,0.36,1), box-shadow 500ms ease, opacity 420ms ease, filter 420ms ease, border-color 500ms ease",
      }}
    >
      <span aria-hidden="true" style={{ position: "absolute", inset: 0, zIndex: 0 }}>
        <Scene active={active} reduced={reduced} narrow={narrow} accent={mood.accent} />
      </span>
      <div style={{ position: "relative", zIndex: 1, display: "flex", flexDirection: "column", gap: "var(--space-3)", height: "100%", padding: "var(--space-6)" }}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
          <div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
            <Icon name={pkg.icon} size={20} color={active ? mood.accent : "var(--ink-900)"} style={{ transition: "background 400ms ease" }} />
            <span className="ink-label" style={{ color: active ? mood.accent : "var(--text-muted)", transition: "color 400ms ease" }}>{`0${pkg.tier}`}</span>
          </div>
          {narrow && <Icon name="chevron-down" size={16} color={active ? mood.fgMuted : "var(--ink-400)"} style={{ transition: "transform 400ms ease, background 400ms ease", transform: active ? "rotate(180deg)" : "rotate(0deg)" }} />}
        </div>
        <div style={{ fontFamily: ar ? "var(--font-arabic)" : "var(--font-display)", fontWeight: ar ? 700 : "var(--weight-title)", fontStretch: ar ? "normal" : "var(--stretch-title)", fontSize: "var(--size-h3)", lineHeight: 1.2, color: active ? mood.fg : "var(--ink-900)", transition: "color 480ms ease" }}>{pkg.name}</div>
        <p style={{ fontSize: "var(--size-body-sm)", color: active ? mood.fgMuted : "var(--text-muted)", fontFamily: ar ? "var(--font-arabic)" : "var(--font-body)", transition: "color 480ms ease" }}>{pkg.tagline}</p>

        <div style={{ display: "grid", gridTemplateRows: active ? "1fr" : "0fr", transition: "grid-template-rows 480ms cubic-bezier(0.22,1,0.36,1)" }}>
          <div style={{ overflow: "hidden" }}>
            <ul style={{ listStyle: "none", padding: 0, margin: "var(--space-1) 0 0", display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
              {pkg.includes.map((it) => (
                <li key={it} style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", fontSize: "var(--size-body-sm)", color: mood.fgMuted, fontFamily: ar ? "var(--font-arabic)" : "var(--font-body)" }}>
                  <Icon name="minus" size={13} color={mood.accent} />
                  <span>{it}</span>
                </li>
              ))}
            </ul>
            <p style={{ fontSize: "var(--size-body-sm)", color: mood.fgMuted, marginTop: "var(--space-3)", fontFamily: ar ? "var(--font-arabic)" : "var(--font-body)" }}>{pkg.bestFor}</p>
          </div>
        </div>

        <div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: "var(--space-3)", paddingTop: "var(--space-2)" }}>
          <div>
            <div className="ink-spec" style={{ color: active ? mood.fgMuted : "var(--text-faint)", transition: "color 480ms ease" }}>{ar ? "يبدأ من" : "Starting from"}</div>
            <div style={{ fontFamily: ar ? "var(--font-arabic)" : "var(--font-display)", fontWeight: ar ? 700 : "var(--weight-title)", fontStretch: ar ? "normal" : "var(--stretch-title)", fontSize: "var(--size-h3)", color: active ? mood.fg : "var(--ink-900)", transition: "color 480ms ease" }}>{formatPrice(pkg.price, ar)}</div>
          </div>
          <Button
            variant={active && isFull ? "accent" : active ? "primary" : "secondary"}
            size="sm"
            iconRight="arrow-up-right"
            onClick={(e) => { e.stopPropagation(); setPage("Contact"); }}
          >
            {ar ? "اطلب هذه الحزمة" : "Request this package"}
          </Button>
        </div>
      </div>
    </div>
  );
}

function Packages({ ar, setPage }) {
  const narrow = useIsNarrow();
  const reduced = useReducedMotion();
  const [activeId, setActiveId] = React.useState(null);
  const PACKAGES = packagesData(ar);
  const activeIndex = Math.max(0, PACKAGES.findIndex((p) => p.id === activeId));
  const glowPct = (activeIndex + 0.5) * (100 / PACKAGES.length);
  const activeMood = activeId ? PACKAGE_MOODS[activeId] : null;
  // Full Experience is the one tier whose ambient shouldn't reuse the
  // generic per-package glow RGB below — its own glow (240,179,44) sits in
  // the same gold family as Merch's (230,163,44), so selecting either one
  // washed the section in nearly the same amber. A flat white+charcoal
  // pass fixed the collision but read as plain grey rather than the
  // premium final tier; this is now a genuinely deep-space direction
  // instead — near-black carrying a whisper of midnight blue-violet (a
  // restrained nebula haze, never enough to read as literal purple) with
  // a single warm-gold pinpoint echoing the sparkle icon's own accent, so
  // gold stays a small luminous detail rather than the section's
  // dominant color. The other three packages are untouched — same
  // single-layer colored glow as before.
  const isFullMood = activeId === "full";
  const packageGlowBg = activeMood
    ? isFullMood
      ? "radial-gradient(closest-side at 72% 30%, rgba(255,212,121,0.16), rgba(255,212,121,0) 40%), radial-gradient(closest-side, rgba(112,92,196,0.16), rgba(112,92,196,0) 62%), radial-gradient(closest-side, rgba(9,8,16,0.6), rgba(9,8,16,0) 76%)"
      : `radial-gradient(closest-side, rgba(${activeMood.glow},0.16), rgba(${activeMood.glow},0) 72%)`
    : "transparent";

  return (
    <Section index="02" title={ar ? "الحزم" : "Packages"} decor={narrow ? <HomeMobileDecor variant="packages" reduced={reduced} /> : null}>
      <style>{`
        .ink-pkg-card:focus { outline: none; }
        .ink-pkg-card:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
      `}</style>
      <div style={{ position: "relative" }}>
        {/* Section-wide focus: a soft, localized glow tracks the active
            package's position (horizontally on desktop, vertically down
            the stack on narrow), and the whole row dims by a hair — the
            effect the brief asks for is scoped entirely to this wrapper,
            never touching the rest of the page. */}
        <div
          aria-hidden="true"
          style={
            narrow
              ? {
                  position: "absolute", insetInlineStart: 0, insetInlineEnd: 0, height: "34%",
                  top: `${glowPct}%`, transform: "translateY(-50%)",
                  background: packageGlowBg,
                  opacity: activeId ? 1 : 0, filter: "blur(6px)",
                  transition: reduced ? "opacity 300ms ease" : "opacity 420ms ease, top 480ms cubic-bezier(0.22,1,0.36,1)",
                  pointerEvents: "none", zIndex: 0,
                }
              : {
                  position: "absolute", top: "-8%", bottom: "-8%", width: "46%",
                  insetInlineStart: `${glowPct}%`, transform: "translateX(-50%)",
                  background: packageGlowBg,
                  opacity: activeId ? 1 : 0, filter: "blur(6px)",
                  transition: reduced ? "opacity 300ms ease" : "opacity 420ms ease, inset-inline-start 560ms cubic-bezier(0.22,1,0.36,1)",
                  pointerEvents: "none", zIndex: 0,
                }
          }
        />
        <div
          onMouseLeave={!narrow ? () => setActiveId(null) : undefined}
          style={{ position: "relative", zIndex: 1, display: "flex", flexDirection: narrow ? "column" : "row", gap: "var(--space-5)", alignItems: "stretch", width: "100%" }}
        >
          {PACKAGES.map((pkg, i) => {
            const pct = activeId === pkg.id ? PACKAGE_EXPANDED_PCT : activeId ? PACKAGE_COLLAPSED_PCT : 25;
            return (
              <div key={pkg.id} style={narrow ? { width: "100%" } : { flex: `0 1 ${pct}%`, minWidth: 0, transition: reduced ? "none" : "flex-basis 640ms cubic-bezier(0.22,1,0.36,1)" }}>
                <Reveal delay={i * 70}>
                  <PackageCard
                    pkg={pkg}
                    ar={ar}
                    setPage={setPage}
                    narrow={narrow}
                    reduced={reduced}
                    active={activeId === pkg.id}
                    otherActive={!!activeId}
                    onEnter={() => setActiveId(pkg.id)}
                    onFocus={() => setActiveId(pkg.id)}
                    onBlur={() => setActiveId((cur) => (cur === pkg.id ? null : cur))}
                    onToggle={() => setActiveId((cur) => (cur === pkg.id ? null : pkg.id))}
                  />
                </Reveal>
              </div>
            );
          })}
        </div>
      </div>
      {/* Subtle, secondary pricing note — never competes visually with the
          package cards themselves (small label + muted caption, no card,
          no background, no icon). */}
      <div style={{ marginTop: "var(--space-6)", maxWidth: "56ch" }}>
        <div className="ink-label" style={{ color: "var(--text-muted)" }}>{ar ? "تسعير أفضل للمشاريع المجمّعة" : "Bundled project pricing"}</div>
        <p style={{ fontSize: "var(--size-caption)", color: "var(--text-faint)", marginTop: "var(--space-2)", fontFamily: ar ? "var(--font-arabic)" : "var(--font-body)", lineHeight: ar ? "var(--leading-arabic)" : "var(--leading-body)" }}>
          {ar ? "عند جمع عدة خدمات في مشروع واحد، يمكن أن تكون التكلفة الإجمالية أفضل من طلب كل خدمة بشكل منفصل." : "Combining services can reduce the total project cost compared with ordering each separately."}
        </p>
      </div>
    </Section>
  );
}

// Vectorized (potrace) trace of assets/ink-mark-only.png — see the
// comment at its usage site in Home() for how/why. Raw potrace path-data
// output (compact relative-command format), used verbatim.
const INK_MARK_PATH = `M33 2643 c-9 -3 -12 -226 -13 -966 0 -529 -5 -1054 -10 -1167 -4
-113 -6 -207 -4 -210 7 -6 392 -2 425 5 l28 6 3 1157 c2 636 0 1161 -4 1166
-7 9 -402 17 -425 9z m343 -99 c8 -30 -10 -54 -27 -37 -13 13 -4 63 11 63 5 0
13 -12 16 -26z m10 -255 c3 -118 2 -148 -11 -163 -8 -11 -18 -16 -22 -12 -10
10 -10 296 0 320 17 43 29 -7 33 -145z m1 -1265 c3 -173 2 -202 -11 -207 -9
-4 -19 -1 -23 6 -14 21 -16 392 -3 418 12 21 13 21 23 3 6 -10 12 -105 14
-220z m-63 -236 c3 -17 4 -34 1 -37 -9 -8 -25 21 -25 47 0 33 17 27 24 -10z
m60 -61 c5 -23 -2 -67 -12 -67 -14 0 -28 35 -24 61 5 35 27 38 36 6z M1045
2633 c-55 -2 -104 -8 -109 -13 -5 -5 -1 -28 12 -57 11 -26 90 -262 175 -523
183 -560 224 -658 259 -609 15 21 -20 1148 -37 1197 -4 12 -48 13 -300 5z
m254 -210 c-2 -46 -4 -83 -7 -83 -2 0 -10 6 -18 14 -15 15 -20 138 -7 172 6
16 7 15 20 -2 10 -13 13 -44 12 -101z m1 -213 c0 -17 -17 -37 -25 -29 -2 2 -1
16 2 31 6 30 23 28 23 -2z m30 -65 c0 -14 -4 -25 -10 -25 -5 0 -10 11 -10 25
0 14 5 25 10 25 6 0 10 -11 10 -25z m0 -177 c0 -51 5 -113 11 -137 7 -30 7
-47 -1 -55 -5 -5 -10 -21 -10 -35 0 -16 -3 -22 -10 -15 -5 5 -13 83 -16 172
-7 147 -6 162 9 162 15 0 17 -13 17 -92z M2690 2592 c-5 -2 -10 -11 -10 -20 0
-18 -374 -740 -392 -757 -7 -7 -9 -2 -5 15 3 14 7 187 8 385 l3 360 -220 5
c-120 3 -222 3 -225 0 -4 -3 -9 -521 -12 -1152 l-5 -1147 22 -14 c16 -10 73
-14 233 -16 l213 -2 -4 348 c-3 191 -2 379 2 416 l7 68 120 -253 c116 -247
201 -430 239 -521 l20 -45 235 -4 c130 -2 239 -1 244 2 4 3 -1 15 -12 26 -13
14 -576 1120 -629 1235 -1 3 81 136 184 295 103 159 243 377 312 484 69 107
138 209 153 225 35 38 38 64 7 67 -45 3 -477 3 -488 0z m-435 -82 c-5 -8 -11
-8 -17 -2 -6 6 -7 16 -3 22 5 8 11 8 17 2 6 -6 7 -16 3 -22z m800 -34 c-16
-25 -45 -36 -45 -17 0 4 10 20 21 35 28 36 48 20 24 -18z m-835 -13 c0 -16 -4
-34 -9 -41 -12 -20 -23 12 -15 44 8 33 24 30 24 -3z m751 -125 c-10 -24 -44
-77 -73 -118 -30 -41 -72 -104 -93 -140 -63 -103 -125 -184 -152 -198 -24 -13
-24 -13 -12 10 7 13 87 133 178 267 154 229 199 282 152 179z m-753 -180 c0
-112 -3 -207 -5 -211 -3 -5 -12 2 -22 15 -13 19 -16 49 -15 173 1 168 8 225
29 225 12 0 14 -34 13 -202z m465 -1104 c5 -28 -6 -40 -21 -21 -9 11 -16 41
-13 61 2 16 29 -15 34 -40z m-443 -45 c0 -10 -4 -19 -10 -19 -5 0 -10 12 -10
26 0 14 4 23 10 19 6 -3 10 -15 10 -26z m-37 -203 c-3 -78 -5 -156 -4 -175 1
-19 -4 -51 -11 -70 l-12 -36 -7 40 c-13 66 -10 345 3 381 10 29 13 31 24 17 8
-12 10 -58 7 -157z m578 53 c103 -197 180 -361 177 -376 -3 -16 -147 229 -172
295 -9 23 -29 68 -46 99 -16 30 -30 67 -30 81 0 34 3 31 71 -99z m-618 -364
c3 -16 0 -24 -7 -22 -14 4 -21 47 -8 47 5 0 12 -11 15 -25z m746 -47 c1 -26
-16 -22 -21 5 -6 34 3 57 13 32 4 -11 8 -28 8 -37z M950 1244 c-6 -14 -10 -32
-10 -39 0 -8 -6 -34 -14 -57 -70 -222 -113 -353 -132 -408 -22 -61 -114 -385
-114 -402 0 -4 19 -19 43 -33 24 -14 55 -37 69 -52 14 -16 42 -43 63 -62 60
-56 88 -108 82 -153 -5 -38 -5 -38 29 -38 34 0 35 1 29 34 -11 57 10 85 167
225 32 28 70 54 84 57 24 6 26 10 20 48 -3 22 -66 225 -141 449 -74 224 -135
419 -135 433 0 32 -28 32 -40 -2z m130 -496 c0 -10 -4 -18 -9 -18 -13 0 -21
26 -20 63 l1 32 13 -30 c8 -16 14 -38 15 -47z m-26 -64 c9 -22 8 -24 -9 -24
-8 0 -15 9 -15 20 0 24 15 27 24 4z m80 -108 c14 -46 26 -94 26 -107 l0 -24
-19 24 c-34 44 -63 166 -44 184 10 11 11 11 37 -77z m-21 -273 c6 -10 -269
-10 -279 0 -4 4 56 7 134 7 77 0 143 -3 145 -7z m-88 -43 c4 -6 -15 -10 -49
-10 -30 0 -58 5 -61 10 -4 6 15 10 49 10 30 0 58 -4 61 -10z m-20 -41 c4 -5 1
-9 -5 -9 -6 0 -9 -4 -5 -9 3 -6 -4 -15 -15 -21 -17 -9 -22 -7 -30 15 -6 15
-10 28 -10 29 0 8 60 3 65 -5z m-15 -63 c0 -3 -4 -8 -10 -11 -5 -3 -10 -1 -10
4 0 6 5 11 10 11 6 0 10 -2 10 -4z`;

// Vectorized (potrace) trace of the custom Arabic "حبر" mark's SILHOUETTE
// ONLY — letterforms (Vazirmatn Black, the project has no Arabic-display
// asset to crop from unlike the Latin mark) plus the ب dot replaced with a
// custom ink-drop. Unlike INK_MARK_PATH, the highlights are NOT baked into
// this raster→potrace pass: a flat potrace fill can't do the soft gradient
// falloff a real specular reflection needs, so they're built as separate
// native SVG gradient shapes at the usage site instead — see the comment
// there. Raw potrace path-data output, used verbatim.
const ARABIC_MARK_PATH = `M16535 11920 c-531 -54 -980 -207 -1390 -474 -126 -83 -362 -270
-521 -413 -367 -331 -698 -724 -983 -1167 -147 -227 -474 -768 -475 -784 -1
-10 130 -79 384 -203 212 -104 634 -311 939 -460 370 -181 559 -269 567 -263
7 5 139 168 295 363 167 210 325 397 388 460 114 115 299 273 396 338 214 146
437 215 690 216 236 0 434 -61 1325 -411 265 -104 1143 -436 1448 -547 57 -21
101 -39 99 -41 -1 -2 -149 -74 -328 -160 -1961 -945 -3060 -1314 -4324 -1454
-449 -49 -912 -64 -1835 -57 -584 4 -722 8 -805 21 -530 86 -762 321 -849 865
-9 51 -54 643 -102 1315 -47 671 -87 1222 -88 1223 -3 3 -2073 -245 -2103
-252 -28 -6 -27 1 -8 -225 41 -497 65 -1031 65 -1496 1 -434 -13 -581 -77
-794 -99 -335 -288 -498 -683 -590 -261 -61 -470 -73 -1205 -67 -522 4 -610 6
-674 21 -233 54 -396 169 -531 374 -126 190 -258 489 -329 747 -17 61 -134
512 -260 1003 -126 491 -231 895 -234 898 -4 4 -733 -231 -1849 -597 l-456
-150 39 -117 c160 -473 433 -1427 533 -1867 188 -822 228 -1534 120 -2147 -88
-504 -258 -881 -557 -1238 -420 -503 -971 -829 -1957 -1157 -321 -106 -832
-247 -1143 -314 -31 -7 -57 -15 -57 -19 0 -7 849 -2292 853 -2297 5 -4 302 61
491 107 1682 412 2993 1285 3763 2505 338 536 584 1160 723 1831 16 82 32 155
35 162 3 9 22 4 67 -19 151 -76 358 -134 628 -177 161 -25 180 -25 770 -30
636 -4 807 2 1065 39 703 100 1409 423 1939 887 l79 69 121 -122 c252 -253
546 -456 883 -610 322 -146 640 -224 1049 -254 182 -14 1695 -14 2005 -1 541
24 1094 89 1533 180 1227 254 2280 684 4266 1742 1041 556 1245 660 1545 791
551 241 928 356 1445 441 l195 32 3 1126 2 1126 -82 6 c-46 3 -128 8 -183 12
-454 27 -977 147 -1655 382 -490 169 -834 311 -1940 797 -859 378 -1156 502
-1523 634 -381 137 -595 197 -857 241 -143 24 -529 35 -685 20z M10206 3842
c-81 -161 -170 -334 -198 -385 -80 -142 -413 -806 -478 -952 -86 -193 -143
-380 -165 -535 -23 -164 -20 -302 10 -423 25 -102 87 -246 129 -302 13 -16 31
-45 40 -64 26 -49 164 -182 231 -222 167 -100 222 -123 391 -163 94 -22 274
-22 368 0 169 40 224 63 391 163 67 40 205 173 231 222 9 19 27 48 40 64 38
50 103 196 124 280 37 143 41 241 19 408 -11 81 -29 178 -40 215 -57 185 -76
237 -129 357 -67 150 -400 814 -480 955 -29 52 -117 225 -196 384 l-142 290
-146 -292z`;

function Home({ lang, setPage }) {
  const ar = lang === "ar";
  const narrow = useIsNarrow();
  // The Arabic headline is a FIXED 72px font (not vw-scaled), so its
  // rendered line width is ~constant in px while the open space beside it
  // shrinks with the viewport — a single right%/width%vw formula for حبر
  // stops being safe well before the "narrow" mobile breakpoint. This adds
  // one more tier (760-1339px) sized from a verified-safe formula (see the
  // build notes for this round) so حبر never reaches the headline's own
  // text at any width; only the Arabic branch below reads it.
  const arCompact = useIsNarrow(1340);
  const orbsRef = React.useRef(null);
  const onHeroMove = (e) => {
    const r = e.currentTarget.getBoundingClientRect();
    const x = (e.clientX - r.left) / r.width - 0.5, y = (e.clientY - r.top) / r.height - 0.5;
    if (orbsRef.current) { orbsRef.current.style.setProperty("--px", x * 26 + "px"); orbsRef.current.style.setProperty("--py", y * 26 + "px"); }
  };
  return (
    <main dir={ar ? "rtl" : "ltr"}>
      {/* Bottom padding cut to space-5 on narrow (was space-8) — the
          biggest single contributor to the moving Work strip below barely
          entering the first viewport. Combined with the tighter CTA gap
          above, this pulls the whole strip up without touching its own
          layout. */}
      <section onMouseMove={onHeroMove} style={{ position: "relative", padding: narrow ? "var(--space-7) var(--page-margin) var(--space-5)" : "var(--space-10) var(--page-margin) var(--space-9)", overflow: "hidden" }}>
        <div ref={orbsRef} style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none", zIndex: 0 }}>
          <div className="ink-blob" style={{ position: "absolute", width: narrow ? 190 : 360, height: narrow ? 190 : 360, left: narrow ? "-12%" : "-6%", top: narrow ? "-10%" : "-8%", background: "var(--vermilion-500)", opacity: narrow ? 0.14 : 0.16, animation: "ink-blob-morph 9s var(--ease-standard) infinite, ink-drift 12s var(--ease-standard) infinite", transform: "translate(var(--px,0px),var(--py,0px))", transition: "transform 420ms var(--ease-out)" }} />
          {/* Enlarged and softened on narrow (vs. the earlier small, distinct
              circle) so it reads as a glow supporting the now much bigger
              INK watermark behind it, rather than a separate shape
              competing with it for the same corner. */}
          {/* Pulled in from the far corner (was right:-18%) now that the
              watermark itself is centered rather than edge-anchored — this
              now sits just inside the frame, supporting the mark's upper
              edge instead of occupying a separate corner of its own. */}
          <div className="ink-blob" style={{ position: "absolute", width: narrow ? 220 : 260, height: narrow ? 220 : 260, right: narrow ? "-4%" : "2%", top: narrow ? "5%" : "2%", background: "var(--cobalt-600)", opacity: narrow ? 0.14 : 0.13, animation: "ink-blob-morph 9s var(--ease-standard) infinite -3s, ink-drift 15s var(--ease-standard) infinite -4s", transform: "translate(calc(var(--px,0px) * -1),calc(var(--py,0px) * -1))", transition: "transform 420ms var(--ease-out)" }} />
          <div className="ink-blob" style={{ position: "absolute", width: narrow ? 210 : 220, height: narrow ? 210 : 220, left: narrow ? "58%" : "40%", bottom: narrow ? "14%" : "-10%", background: "var(--saffron-500)", opacity: narrow ? 0.18 : 0.16, animation: "ink-blob-morph 9s var(--ease-standard) infinite -6s, ink-drift 10s var(--ease-standard) infinite -2s", transform: "translate(var(--px,0px),calc(var(--py,0px) * -1))", transition: "transform 420ms var(--ease-out)" }} />
        </div>
        {/* Oversized background wordmark — the brand's own signature per
            language, never both at once, switched on the SAME `ar` flag
            that already drives every other bit of hero copy/direction (no
            separate state to desync, no flash of the wrong one). English
            keeps the approved, LOCKED "INK" mark exactly as shipped —
            unchanged placement/scale/opacity/geometry. Arabic mirrors the
            same composition: the English headline is left-aligned so INK
            fills the open right; the Arabic headline is right-aligned (RTL)
            so its mark, حبر ("ink"), fills the open LEFT instead. Both are
            potrace-vectorized (alphamax 1, opttolerance 0.2) into smooth
            Bézier curves — see scratch notes for this round for the
            trace pipeline. pointer-events none throughout, never
            intercepts a click, in both branches. */}
        {!ar ? (
          <svg
            viewBox="0 0 321 266"
            aria-hidden="true"
            style={{
              position: "absolute",
              height: "auto",
              color: "var(--ink-900)",
              pointerEvents: "none",
              userSelect: "none",
              ...(narrow
                // Large and centered across the hero — big enough to read
                // as a dominant editorial watermark the headline sits
                // over, but fully inside the viewport with a deliberate
                // margin on both sides (92vw cap, not 128vw), so no
                // stroke of the letterform is ever cropped by the screen
                // edge. Centered horizontally (left:50% + translateX)
                // rather than edge-anchored, which is what let the
                // previous, wider version escape off the right side.
                ? { left: "50%", top: "4%", width: "clamp(280px, 92vw, 380px)", opacity: 0.065, transform: "translateX(-50%)" }
                : { right: "7%", top: "9%", width: "clamp(300px, 46vw, 820px)", opacity: 0.075 }),
            }}
          >
            <g transform="translate(0,266) scale(0.1,-0.1)" fill="currentColor" stroke="none">
              <path d={INK_MARK_PATH} />
            </g>
          </svg>
        ) : (
          <svg
            viewBox="0 0 2346 1193"
            aria-hidden="true"
            style={{
              position: "absolute",
              height: "auto",
              color: "var(--ink-900)",
              pointerEvents: "none",
              userSelect: "none",
              ...(narrow
                ? { left: "50%", top: "3%", width: "clamp(280px, 92vw, 380px)", opacity: 0.065, transform: "translateX(-50%)" }
                : arCompact
                ? { left: "1%", top: "21.5%", width: "clamp(45px, calc(105vw - 745px), 690px)", opacity: 0.075 }
                : { left: "6.5%", top: "21.5%", width: "clamp(441px, 44vw, 768px)", opacity: 0.075 }),
            }}
          >
            <defs>
              <linearGradient id="arGlow0" gradientUnits="objectBoundingBox" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stopColor="#fff" stopOpacity="0" /><stop offset="14%" stopColor="#fff" stopOpacity="0.85" /><stop offset="45%" stopColor="#fff" stopOpacity="1" /><stop offset="80%" stopColor="#fff" stopOpacity="0.7" /><stop offset="100%" stopColor="#fff" stopOpacity="0" /></linearGradient>
              <linearGradient id="arGlow1" gradientUnits="objectBoundingBox" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stopColor="#fff" stopOpacity="0" /><stop offset="14%" stopColor="#fff" stopOpacity="0.85" /><stop offset="45%" stopColor="#fff" stopOpacity="1" /><stop offset="80%" stopColor="#fff" stopOpacity="0.7" /><stop offset="100%" stopColor="#fff" stopOpacity="0" /></linearGradient>
              <linearGradient id="arGlow2" gradientUnits="objectBoundingBox" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stopColor="#fff" stopOpacity="0" /><stop offset="14%" stopColor="#fff" stopOpacity="0.85" /><stop offset="45%" stopColor="#fff" stopOpacity="1" /><stop offset="80%" stopColor="#fff" stopOpacity="0.7" /><stop offset="100%" stopColor="#fff" stopOpacity="0" /></linearGradient>
              <linearGradient id="arGlow3" gradientUnits="objectBoundingBox" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stopColor="#fff" stopOpacity="0" /><stop offset="14%" stopColor="#fff" stopOpacity="0.85" /><stop offset="45%" stopColor="#fff" stopOpacity="1" /><stop offset="80%" stopColor="#fff" stopOpacity="0.7" /><stop offset="100%" stopColor="#fff" stopOpacity="0" /></linearGradient>
            </defs>
            <g transform="translate(0,1193) scale(0.1,-0.1)" fill="currentColor" stroke="none">
              <path d={ARABIC_MARK_PATH} />
            </g>
            <path d="M 1578.4,31.9 L 1576.4,32.1 L 1573.5,32.8 L 1569.6,34.0 L 1564.1,35.8 L 1558.2,37.8 L 1553.1,39.6 L 1548.4,41.4 L 1543.8,43.2 L 1539.5,45.0 L 1535.3,46.9 L 1531.2,48.7 L 1527.4,50.6 L 1523.6,52.5 L 1520.0,54.3 L 1516.4,56.2 L 1513.0,58.1 L 1509.6,60.0 L 1506.4,62.0 L 1503.2,63.9 L 1500.1,65.9 L 1497.1,67.8 L 1494.1,69.8 L 1491.1,71.7 L 1488.1,73.8 L 1485.1,75.8 L 1482.3,77.8 L 1479.5,79.8 L 1476.8,81.9 L 1474.0,83.9 L 1471.2,86.0 L 1468.5,88.1 L 1465.9,90.1 L 1463.4,92.1 L 1460.7,94.2 L 1457.9,96.5 L 1455.3,98.7 L 1453.1,100.7 L 1450.8,102.8 L 1448.4,104.9 L 1445.9,107.1 L 1443.6,109.3 L 1441.3,111.5 L 1439.0,113.7 L 1436.8,115.9 L 1434.6,118.1 L 1432.4,120.3 L 1430.3,122.6 L 1428.2,124.8 L 1426.2,127.0 L 1424.1,129.2 L 1422.0,131.5 L 1420.0,133.8 L 1418.0,136.1 L 1416.1,138.4 L 1414.2,140.7 L 1412.3,143.0 L 1410.5,145.3 L 1408.8,147.6 L 1407.0,150.0 L 1405.3,152.3 L 1403.6,154.6 L 1402.0,156.9 L 1400.4,159.2 L 1398.7,161.6 L 1397.1,164.0 L 1395.5,166.3 L 1394.0,168.7 L 1392.4,171.2 L 1391.0,173.5 L 1389.6,175.7 L 1388.1,178.1 L 1386.5,180.6 L 1385.1,182.9 L 1383.7,185.3 L 1382.2,187.7 L 1380.9,190.2 L 1379.8,192.4 L 1378.5,194.8 L 1377.3,197.2 L 1376.0,199.6 L 1374.8,201.9 L 1373.5,204.3 L 1372.3,206.8 L 1371.1,209.2 L 1370.0,211.6 L 1368.9,213.9 L 1367.7,216.4 L 1366.6,218.8 L 1365.4,221.2 L 1364.3,223.6 L 1363.1,226.1 L 1362.0,228.5 L 1360.9,230.9 L 1359.8,233.3 L 1358.7,235.7 L 1357.6,238.1 L 1356.5,240.6 L 1355.6,243.0 L 1354.6,245.4 L 1353.4,248.3 L 1352.4,250.7 L 1352.4,251.3 L 1355.8,253.4 L 1357.3,251.8 L 1359.3,249.4 L 1361.1,247.2 L 1362.7,245.2 L 1364.3,243.3 L 1366.0,241.4 L 1367.6,239.4 L 1369.4,237.4 L 1371.1,235.3 L 1372.8,233.2 L 1374.5,231.2 L 1376.2,229.1 L 1377.9,227.0 L 1379.6,224.9 L 1381.3,222.9 L 1383.0,220.8 L 1384.7,218.7 L 1386.3,216.7 L 1387.9,214.8 L 1389.5,212.9 L 1391.2,210.9 L 1393.0,208.8 L 1394.7,206.6 L 1396.5,204.6 L 1398.1,202.5 L 1399.7,200.6 L 1401.2,198.9 L 1402.8,197.1 L 1404.5,195.3 L 1406.3,193.3 L 1408.2,191.2 L 1410.2,189.0 L 1412.2,186.7 L 1414.0,184.6 L 1415.7,182.6 L 1417.4,180.7 L 1419.1,178.8 L 1420.9,176.8 L 1422.8,174.8 L 1424.7,172.6 L 1426.6,170.5 L 1428.4,168.4 L 1430.2,166.4 L 1432.0,164.4 L 1433.8,162.4 L 1435.7,160.4 L 1437.5,158.4 L 1439.4,156.3 L 1441.3,154.3 L 1443.2,152.2 L 1445.2,150.2 L 1447.2,148.0 L 1449.3,145.8 L 1451.4,143.6 L 1453.4,141.3 L 1455.3,139.2 L 1457.3,137.1 L 1459.3,134.9 L 1461.3,132.8 L 1463.4,130.6 L 1465.4,128.4 L 1467.5,126.2 L 1469.7,123.9 L 1471.9,121.6 L 1474.1,119.3 L 1476.1,117.1 L 1477.9,115.1 L 1480.1,113.0 L 1482.4,110.7 L 1484.9,108.2 L 1487.2,105.9 L 1489.5,103.5 L 1491.9,101.2 L 1494.2,98.8 L 1496.6,96.5 L 1499.0,94.1 L 1501.3,91.8 L 1503.7,89.5 L 1506.2,87.2 L 1508.8,84.8 L 1511.4,82.4 L 1513.9,80.0 L 1516.4,77.7 L 1519.1,75.4 L 1521.7,73.1 L 1524.5,70.8 L 1527.5,68.4 L 1530.5,66.1 L 1533.7,63.7 L 1536.9,61.3 L 1540.3,58.9 L 1543.9,56.6 L 1547.7,54.2 L 1551.6,51.7 L 1555.8,49.3 L 1560.2,46.8 L 1565.3,44.2 L 1570.8,41.5 L 1574.9,39.3 L 1577.8,37.6 L 1579.7,36.2 L 1580.1,35.8 Z" fill="url(#arGlow0)" />
            <path d="M 1348.6,259.6 L 1343.0,262.8 L 1337.4,269.0 L 1332.4,276.8 L 1328.9,284.9 L 1327.6,291.9 L 1328.1,293.9 L 1329.6,294.8 L 1335.2,291.7 L 1340.8,285.4 L 1345.8,277.6 L 1349.3,269.5 L 1350.6,262.5 L 1350.2,260.5 Z" fill="url(#arGlow0)" />
            <path d="M 938.0,201.7 L 937.7,202.3 L 937.4,203.1 L 937.2,204.4 L 937.1,206.3 L 937.0,208.4 L 936.8,210.4 L 936.7,212.2 L 936.6,214.1 L 936.4,216.0 L 936.3,217.9 L 936.2,219.8 L 936.1,221.7 L 936.0,223.6 L 935.9,225.5 L 935.8,227.4 L 935.7,229.3 L 935.6,231.3 L 935.5,233.3 L 935.4,235.1 L 935.2,236.9 L 935.1,238.8 L 935.0,240.9 L 934.9,242.7 L 934.8,244.5 L 934.8,246.5 L 934.7,248.4 L 934.6,250.2 L 934.5,252.1 L 934.4,254.1 L 934.3,255.9 L 934.2,257.7 L 934.1,259.7 L 934.1,261.8 L 934.1,263.6 L 934.0,265.3 L 933.9,267.1 L 933.9,269.1 L 933.8,271.1 L 933.7,273.0 L 933.7,274.8 L 933.6,276.5 L 933.6,278.4 L 933.6,280.5 L 933.7,282.6 L 933.8,284.3 L 933.8,285.9 L 933.8,287.8 L 933.8,289.9 L 933.8,291.8 L 933.9,293.6 L 933.9,295.5 L 933.9,297.4 L 934.0,299.2 L 934.1,301.1 L 934.1,303.0 L 934.2,304.9 L 934.3,306.7 L 934.4,308.6 L 934.5,310.5 L 934.6,312.4 L 934.8,314.2 L 934.9,316.1 L 935.0,318.0 L 935.2,319.9 L 935.3,321.7 L 935.5,323.6 L 935.6,325.5 L 935.8,327.5 L 936.0,329.4 L 936.1,331.1 L 936.3,333.0 L 936.4,335.0 L 936.5,336.9 L 936.6,338.8 L 936.8,340.6 L 936.9,342.5 L 937.1,344.4 L 937.2,346.2 L 937.4,348.1 L 937.5,350.0 L 937.7,351.9 L 937.9,353.8 L 938.0,355.6 L 938.2,357.3 L 938.4,359.2 L 938.6,361.3 L 938.9,363.2 L 939.2,364.9 L 939.4,366.7 L 939.6,368.6 L 939.8,370.6 L 940.0,372.5 L 940.2,374.4 L 940.4,376.2 L 940.6,378.1 L 940.8,380.0 L 941.1,382.0 L 941.3,383.9 L 941.5,385.8 L 941.6,387.7 L 941.8,389.7 L 941.9,391.7 L 942.1,393.5 L 942.2,395.4 L 942.4,397.4 L 942.6,399.4 L 942.7,401.3 L 942.8,403.4 L 942.9,405.5 L 943.0,407.6 L 943.0,409.7 L 943.0,411.7 L 943.0,413.9 L 943.0,416.0 L 943.1,416.9 L 946.2,417.4 L 946.8,416.0 L 947.5,413.9 L 948.1,411.7 L 948.6,409.7 L 949.2,407.5 L 949.6,405.4 L 950.1,403.2 L 950.5,401.1 L 950.8,399.0 L 951.1,397.0 L 951.4,395.1 L 951.7,393.3 L 952.1,391.4 L 952.4,389.5 L 952.7,387.5 L 953.0,385.4 L 953.2,383.3 L 953.4,381.3 L 953.6,379.4 L 953.9,377.5 L 954.1,375.6 L 954.3,373.8 L 954.5,371.9 L 954.7,370.0 L 954.9,368.1 L 955.1,366.0 L 955.3,363.8 L 955.4,361.8 L 955.5,360.0 L 955.6,358.5 L 955.8,356.8 L 956.0,355.0 L 956.2,353.1 L 956.4,351.3 L 956.5,349.4 L 956.7,347.5 L 956.8,345.6 L 957.0,343.8 L 957.1,341.9 L 957.3,340.0 L 957.4,338.1 L 957.6,336.3 L 957.7,334.4 L 957.8,332.4 L 957.9,330.2 L 958.0,328.0 L 958.0,325.9 L 958.0,323.9 L 958.0,322.1 L 958.0,320.2 L 958.0,318.3 L 958.0,316.4 L 958.0,314.6 L 958.0,312.7 L 957.9,310.8 L 957.9,308.9 L 957.8,307.1 L 957.8,305.2 L 957.7,303.3 L 957.7,301.4 L 957.6,299.6 L 957.5,297.7 L 957.4,295.8 L 957.3,293.9 L 957.2,292.1 L 957.1,290.2 L 956.9,288.3 L 956.8,286.3 L 956.6,284.2 L 956.4,281.9 L 956.2,279.9 L 955.9,278.4 L 955.8,276.9 L 955.6,275.2 L 955.4,273.4 L 955.2,271.5 L 955.0,269.6 L 954.8,267.7 L 954.6,265.7 L 954.3,263.5 L 954.0,261.3 L 953.7,259.5 L 953.4,258.0 L 953.1,256.4 L 952.9,254.7 L 952.6,252.8 L 952.4,250.8 L 952.1,248.6 L 951.7,246.4 L 951.3,244.4 L 951.0,242.5 L 950.6,240.8 L 950.2,239.2 L 949.9,237.6 L 949.6,235.8 L 949.3,233.7 L 948.9,231.6 L 948.5,229.6 L 948.1,227.7 L 947.7,225.8 L 947.3,224.0 L 946.9,222.1 L 946.5,220.3 L 946.1,218.4 L 945.6,216.6 L 945.2,214.7 L 944.8,212.9 L 944.4,211.0 L 943.9,209.2 L 943.5,207.2 L 943.0,205.2 L 942.6,203.5 L 942.2,202.3 L 941.7,201.6 L 941.4,201.3 Z" fill="url(#arGlow1)" />
            <path d="M 939.5,192.5 L 941.4,187.5 L 942.0,180.4 L 941.3,172.5 L 939.6,165.2 L 937.0,159.8 L 935.8,158.7 L 934.4,158.8 L 932.4,163.8 L 931.9,170.9 L 932.5,178.8 L 934.3,186.1 L 936.8,191.5 L 938.0,192.6 Z" fill="url(#arGlow1)" />
            <path d="M 487.1,230.2 L 485.0,230.4 L 481.9,231.0 L 477.6,232.0 L 471.8,233.5 L 465.5,235.2 L 459.6,236.8 L 453.7,238.4 L 447.8,239.9 L 442.0,241.5 L 436.1,243.1 L 430.2,244.7 L 424.3,246.3 L 418.5,247.9 L 412.6,249.5 L 406.8,251.1 L 400.9,252.7 L 395.1,254.3 L 389.3,255.9 L 383.6,257.5 L 377.7,259.2 L 371.8,260.8 L 366.0,262.4 L 360.2,264.1 L 354.5,265.7 L 348.7,267.4 L 342.9,269.0 L 337.5,270.7 L 332.5,272.3 L 327.9,273.9 L 323.7,275.6 L 320.2,277.2 L 317.4,278.6 L 314.2,280.8 L 313.0,281.7 L 329.4,284.3 L 330.2,294.0 L 330.8,293.0 L 332.0,289.4 L 332.0,289.6 L 313.1,294.7 L 310.9,291.6 L 311.2,292.7 L 311.7,294.5 L 312.2,296.2 L 312.9,298.3 L 313.5,300.3 L 314.1,302.2 L 314.7,304.0 L 315.3,305.9 L 315.9,307.7 L 316.6,309.7 L 317.3,311.6 L 317.9,313.5 L 318.4,315.0 L 319.0,316.8 L 319.7,318.9 L 320.4,320.9 L 321.1,322.7 L 321.8,324.4 L 322.5,326.2 L 323.2,328.1 L 324.0,329.9 L 324.7,331.7 L 325.5,333.6 L 326.2,335.4 L 327.0,337.2 L 327.7,339.0 L 328.5,340.8 L 329.3,342.7 L 330.1,344.6 L 330.9,346.5 L 331.7,348.2 L 332.4,350.0 L 333.2,351.9 L 333.9,353.8 L 334.7,355.6 L 335.4,357.4 L 336.2,359.2 L 336.9,361.0 L 337.7,362.8 L 338.4,364.7 L 339.2,366.5 L 340.0,368.3 L 340.8,370.1 L 341.5,371.9 L 342.3,373.6 L 343.0,375.3 L 343.8,377.1 L 344.7,379.0 L 345.6,380.8 L 346.4,382.5 L 347.2,384.3 L 348.1,386.3 L 348.9,388.1 L 349.7,389.9 L 350.5,391.7 L 351.4,393.6 L 352.4,395.5 L 352.9,396.3 L 355.9,395.4 L 355.9,393.8 L 355.7,391.7 L 355.5,389.6 L 355.3,387.7 L 355.0,385.7 L 354.8,383.7 L 354.5,381.6 L 354.2,379.5 L 353.8,377.4 L 353.5,375.4 L 353.2,373.7 L 353.0,371.9 L 352.8,370.1 L 352.5,368.1 L 352.3,366.2 L 352.0,364.2 L 351.7,362.3 L 351.4,360.4 L 351.1,358.4 L 350.8,356.5 L 350.5,354.5 L 350.2,352.6 L 349.9,350.7 L 349.6,348.7 L 349.3,346.8 L 348.9,344.6 L 348.5,342.4 L 348.0,340.2 L 347.6,338.2 L 347.1,336.3 L 346.7,334.3 L 346.3,332.4 L 345.9,330.5 L 345.4,328.6 L 345.0,326.6 L 344.5,324.7 L 344.1,322.8 L 343.6,320.9 L 343.1,318.9 L 342.6,317.0 L 342.1,315.2 L 341.7,313.5 L 341.3,311.9 L 340.9,310.2 L 340.4,308.1 L 339.8,305.9 L 339.2,303.8 L 338.6,301.9 L 338.1,300.0 L 337.5,298.1 L 336.9,296.2 L 336.3,294.4 L 335.7,292.5 L 335.1,290.6 L 334.4,288.5 L 333.6,286.4 L 333.0,284.7 L 332.0,282.3 L 317.7,275.5 L 309.7,285.1 L 309.7,285.7 L 310.4,283.9 L 309.8,285.5 L 322.8,300.5 L 328.2,297.1 L 328.4,296.7 L 328.9,296.3 L 330.9,294.9 L 333.5,293.5 L 336.8,291.8 L 340.9,290.0 L 345.6,288.1 L 350.7,286.2 L 356.3,284.2 L 361.9,282.1 L 367.5,280.0 L 373.2,277.9 L 378.8,275.8 L 384.5,273.7 L 390.2,271.5 L 395.9,269.4 L 401.5,267.3 L 407.1,265.1 L 412.8,263.0 L 418.4,260.8 L 424.1,258.7 L 429.8,256.5 L 435.5,254.4 L 441.2,252.2 L 446.9,250.1 L 452.5,247.9 L 458.2,245.7 L 463.9,243.5 L 469.7,241.3 L 475.8,238.9 L 480.9,236.9 L 484.6,235.3 L 487.1,234.1 L 488.2,233.3 Z" fill="url(#arGlow2)" />
            <path d="M 496.4,229.6 L 501.7,230.5 L 508.8,229.5 L 516.3,227.2 L 523.1,224.0 L 527.9,220.4 L 528.7,219.0 L 528.3,217.6 L 523.0,216.7 L 515.9,217.7 L 508.4,220.0 L 501.6,223.2 L 496.8,226.8 L 496.0,228.3 Z" fill="url(#arGlow2)" />
            <path d="M 951.1,994.6 L 952.4,992.6 L 953.7,989.7 L 955.0,987.0 L 956.2,984.4 L 957.6,981.8 L 958.9,979.1 L 960.3,976.5 L 961.6,973.8 L 963.1,971.1 L 964.5,968.3 L 965.9,965.5 L 967.3,962.6 L 968.7,959.7 L 970.1,956.7 L 971.5,953.6 L 972.9,950.5 L 974.2,947.3 L 975.5,944.1 L 976.9,940.7 L 978.2,937.3 L 979.4,933.9 L 980.7,930.3 L 982.0,926.7 L 983.3,923.1 L 984.6,919.3 L 985.9,915.5 L 987.2,911.7 L 988.6,907.8 L 989.9,903.8 L 991.4,899.6 L 993.0,895.0 L 993.9,891.5 L 991.9,890.5 L 989.6,893.2 L 986.8,897.2 L 984.1,900.9 L 981.7,904.3 L 979.3,907.7 L 977.0,911.1 L 974.7,914.4 L 972.5,917.7 L 970.4,921.1 L 968.3,924.4 L 966.3,927.7 L 964.5,931.0 L 962.7,934.2 L 961.0,937.5 L 959.4,940.8 L 957.9,944.1 L 956.6,947.4 L 955.3,950.6 L 954.2,953.9 L 953.2,957.2 L 952.3,960.4 L 951.5,963.6 L 950.8,966.8 L 950.2,970.0 L 949.7,973.2 L 949.3,976.3 L 949.0,979.4 L 948.8,982.4 L 948.7,985.4 L 948.6,988.6 L 948.6,991.8 L 948.9,994.2 Z" fill="url(#arGlow3)" />
            <path d="M 948.3,1000.2 L 946.0,1002.8 L 944.2,1007.1 L 943.1,1012.1 L 942.7,1016.9 L 943.3,1020.8 L 943.8,1021.7 L 944.8,1021.9 L 947.1,1019.2 L 948.9,1015.0 L 950.0,1010.0 L 950.4,1005.2 L 949.8,1001.3 L 949.3,1000.4 Z" fill="url(#arGlow3)" />
          </svg>
        )}
        <div style={{ position: "relative", maxWidth: "var(--container-max)", margin: "0 auto" }}>
          <Reveal><Kicker>{ar ? "استوديو إبداعي · الظهران" : "Creative studio · Dhahran"}</Kicker></Reveal>
          <Reveal delay={90} as="h1" style={{ fontFamily: ar ? "var(--font-arabic-display)" : "var(--font-display)", fontWeight: ar ? 800 : "var(--weight-display)", fontStretch: ar ? "normal" : "var(--stretch-display)", fontSize: narrow ? (ar ? "clamp(34px, 11vw, 52px)" : "clamp(34px, 11.5vw, 56px)") : (ar ? 72 : "var(--size-display-xl)"), lineHeight: ar ? 1.15 : "var(--leading-display)", letterSpacing: ar ? 0 : "var(--tracking-display)", textTransform: ar ? "none" : "uppercase", marginTop: "var(--space-6)", maxWidth: "18ch" }}>
            {ar ? "من الفكرة إلى شيء تلمسه" : <>An idea,<br />made visible<br />and touchable</>}
          </Reveal>
          {/* Narrow gets a noticeably tighter gap here (both the headline's
              own marginTop and the description->CTA gap) than desktop's —
              on desktop this gap is horizontal breathing room between an
              inline paragraph and button group; on mobile, where they
              stack, the same 48px value read as dead air pushing the CTAs
              (and, in turn, the moving Work strip below the section) too
              far down to fit in the first viewport. */}
          <Reveal delay={180} style={{ display: "flex", gap: narrow ? "var(--space-5)" : "var(--space-8)", marginTop: narrow ? "var(--space-6)" : "var(--space-7)", alignItems: "flex-start", flexWrap: "wrap" }}>
            <p style={{ maxWidth: "52ch", fontSize: "var(--size-body-lg)", fontFamily: ar ? "var(--font-arabic)" : "var(--font-body)", lineHeight: ar ? "var(--leading-arabic)" : "var(--leading-body)" }}>
              {ar ? "تصميم، إنتاج إعلامي، طباعة، وإنتاج مادي — كل ما يحتاجه الحدث أو الحملة تحت توجيه واحد." : "Design, media production, printing and physical production — everything one event or campaign needs, under one direction."}
            </p>
            <div style={{ display: "flex", flexDirection: narrow ? "column" : "row", gap: "var(--space-4)", width: narrow ? "100%" : undefined }}>
              <Button variant="accent" size="lg" iconRight="arrow-up-right" fullWidth={narrow} onClick={() => setPage("Contact")}>{ar ? "ابدأ مشروع" : "Start a project"}</Button>
              <Button variant="secondary" size="lg" fullWidth={narrow} onClick={() => setPage("Work")}>{ar ? "الأعمال" : "See the work"}</Button>
            </div>
          </Reveal>
        </div>
      </section>

      <PortfolioShowcase ar={ar} setPage={setPage} />

      <WhatWeMake ar={ar} setPage={setPage} />

      <Packages ar={ar} setPage={setPage} />

      <Section index="03" title={ar ? "الطريقة" : "How we work"} style={{ background: "var(--sand-200)" }}>
        <div style={{ display: "grid", gridTemplateColumns: narrow ? "1fr" : "repeat(4, minmax(0, 1fr))", gap: narrow ? "var(--space-8)" : "var(--space-6)" }}>
          {processSteps(ar).map(([t, d], i) => (
            <Reveal key={t} delay={i * 90}>
              <Rule weight="heavy" />
              <div className="ink-label" style={{ marginTop: "var(--space-4)", color: "var(--vermilion-500)" }}>{`0${i + 1}`}</div>
              <div className="ink-h3" style={{ marginTop: "var(--space-2)" }}>{t}</div>
              <p style={{ fontSize: "var(--size-body-sm)", color: "var(--ink-700)", marginTop: "var(--space-3)" }}>{d}</p>
            </Reveal>
          ))}
        </div>
        {/* Same three-part visual rhythm as before (dark / red-emphasized
            middle / dark, via StatBlock's existing value+label+note slots)
            — only the content changed, from three unsupported metrics to
            a brand statement: one starting brief, one direction everything
            moves under, one studio carrying it through. The big/colored
            "value" slot carries "One" (EN) or the noun (AR, since Arabic
            adjective order puts "واحد" after the noun it describes), the
            small caps "label" slot carries the matching second word, and
            "note" carries the supporting sentence — reusing the exact
            structure the old stats already had. */}
        <Reveal delay={80} style={{ display: "flex", flexWrap: "wrap", gap: narrow ? "var(--space-7)" : "var(--space-10)", marginTop: "var(--space-9)" }}>
          <StatBlock rtl={ar} value={ar ? "موجز" : "One"} label={ar ? "واحد" : "Brief"} note={ar ? "رسالة واحدة تكفي لنبدأ." : "One message to start."} />
          <StatBlock rtl={ar} value={ar ? "توجّه" : "One"} label={ar ? "واحد" : "Direction"} note={ar ? "كل شيء يتحرك معًا." : "Everything moves together."} tone="accent" />
          <StatBlock rtl={ar} value={ar ? "استوديو" : "One"} label={ar ? "واحد" : "Studio"} note={ar ? "من الفكرة إلى التنفيذ." : "From concept to execution."} />
        </Reveal>
      </Section>
    </main>
  );
}

Object.assign(window, { Home });
