// PHYSIO SAMOS — shared components: Nav, Footer, BookingModal, Marquee, etc.
const { useState, useEffect, useRef, useMemo } = React;
const useT = window.PS.useT;

// ---------- Photography (public/media, built by media-src/build-images.sh) ----------
// window.PS_MEDIA (assets/js/media-manifest.js) lists the widths that were actually
// generated for each name — the build never upscales, so a srcset must not offer a
// width the source could not fill.
const mediaWidths = (name) =>
  (window.PS_MEDIA && window.PS_MEDIA[name]) || [1200];

window.PS.img = (name, w = 1200) => {
  const widths = mediaWidths(name);
  return `media/${name}-${widths.find((x) => x >= w) || widths[widths.length - 1]}.jpg`;
};

const srcSet = (name, ext) =>
  mediaWidths(name).map((w) => `media/${name}-${w}.${ext} ${w}w`).join(", ");

// `picture { display: contents }` in main.css keeps the <img> as the layout child, so
// every existing width/height/object-fit style on the img keeps working unchanged.
const Img = ({ name, w = 1200, sizes = "100vw", alt = "", eager = false, position, style, className }) => (
  <picture>
    <source type="image/webp" srcSet={srcSet(name, "webp")} sizes={sizes} />
    <img src={window.PS.img(name, w)} srcSet={srcSet(name, "jpg")} sizes={sizes}
         alt={alt} className={className} draggable={false}
         loading={eager ? "eager" : "lazy"} decoding={eager ? "sync" : "async"}
         fetchpriority={eager ? "high" : undefined}
         style={position ? { ...style, objectPosition: position } : style} />
  </picture>
);
window.Img = Img;

// ---------- Silent looping clips ----------
// False until the first paint is out of the way, and false forever if the visitor asked
// for reduced motion or is on a narrow screen — so those visitors never fetch the clip
// at all, and phones are not charged a megabyte for decoration.
window.PS.useMotionOK = function useMotionOK(minWidth = 0) {
  const [ok, setOk] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)");
    if (mq && mq.matches) return;
    if (window.innerWidth < minWidth) return;
    const id = setTimeout(() => setOk(true), 300);
    return () => clearTimeout(id);
  }, [minWidth]);
  return ok;
};

// The still is always rendered and stays the LCP element; the clip fades in over it only
// once it can actually play, so a browser that never fires canplay simply keeps the photo.
const Loop = ({ name, poster, w = 1200, sizes = "100vw", alt = "", eager = false, position, minWidth = 900 }) => {
  const enabled = window.PS.useMotionOK(minWidth);
  const [ready, setReady] = useState(false);
  return (
    <>
      <Img name={poster} w={w} sizes={sizes} alt={alt} eager={eager} position={position}
           style={{ width: "100%", height: "100%", objectFit: "cover" }} />
      {enabled && (
        <video className="ps-loop" data-ready={ready ? "1" : "0"} aria-hidden="true"
               autoPlay muted loop playsInline preload="auto"
               onCanPlay={() => setReady(true)}
               style={position ? { objectPosition: position } : undefined}>
          <source src={`media/${name}.mp4`} type="video/mp4" />
        </video>
      )}
    </>
  );
};
window.Loop = Loop;
// Theme-aware brand logos (uploads/) — lightmode = dark text for light surfaces, darkmode = cream text for dark surfaces
window.PS.logoLight = "uploads/full-logo-lightmode.png";
window.PS.logoDark = "uploads/full-logo-darkmode.png";
window.PS.useTheme = function useTheme() {
  const { useState, useEffect } = React;
  const [theme, setTheme] = useState(window.PS.getTheme());
  useEffect(() => {
    const onTheme = (e) => setTheme(e.detail);
    window.addEventListener("ps:theme", onTheme);
    return () => window.removeEventListener("ps:theme", onTheme);
  }, []);
  return theme;
};

// ---------- Icons ----------
// Icons come from window.PS_ICONS, which tools/build-icons.sh vendors out of Lucide
// (and Simple Icons for the two brand marks). Nothing here is drawn by hand: to add a
// glyph, add its name to that script and re-run it.
//
// Brand marks are filled paths rather than strokes, so they get .icon-brand instead.
const BRAND_ICONS = { instagram: true, facebook: true };
const Icon = ({ name, size = 20, className }) => {
  const markup = (window.PS_ICONS || {})[name];
  if (!markup) {
    // A missing name is a build mistake, not a runtime state — say so once and render
    // nothing rather than leaving an empty box that looks intentional.
    if (!Icon._warned) { Icon._warned = {}; }
    if (!Icon._warned[name]) { Icon._warned[name] = 1; console.warn(`[PS] unknown icon "${name}" — add it to tools/build-icons.sh`); }
    return null;
  }
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true"
         className={className || (BRAND_ICONS[name] ? "icon-brand" : "icon-stroke")}
         dangerouslySetInnerHTML={{ __html: markup }} />
  );
};
window.Icon = Icon;

// ---------- Levels ----------
// Wraps a run of sections at one level. Consecutive sections share a wrapper so a slab is
// one shape with one background and no seam between its parts; put them in separate
// wrappers only when the backdrop should show between them.
const Level = ({ at = "above", children }) => (
  <div className={at === "below" ? "lvl-below" : "lvl-above"}>{children}</div>
);
window.Level = Level;

// ---------- Page backdrop ----------
// One Grainient for the whole page, fixed behind everything. "Below" sections are simply
// transparent and let it through; "above" sections are opaque slabs on top of it. That
// means a page can alternate above/below as often as it likes on a single WebGL context.
//
// Colours are the site's own tokens, re-read whenever the theme flips, so the backdrop is
// a slow drift within the palette rather than a foreign gradient. Under
// prefers-reduced-motion it renders nothing and the page background shows instead.
const PageBackdrop = () => {
  const theme = window.PS.useTheme();
  const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (reduced) return null;

  const read = (name, fallback) => {
    const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
    return /^#[0-9a-f]{6}$/i.test(v) ? v : fallback;
  };
  // Blend two hex colours; t = 0 keeps a, t = 1 gives b.
  const mix = (a, b, t) => {
    const ch = (h, i) => parseInt(h.slice(1 + i * 2, 3 + i * 2), 16);
    const to = (n) => Math.round(n).toString(16).padStart(2, "0");
    return "#" + [0, 1, 2].map((i) => to(ch(a, i) + (ch(b, i) - ch(a, i)) * t)).join("");
  };
  const dark = theme === "dark";
  // Three steps of the palette. Light stays inside the creams with one sage breath.
  //
  // Dark cannot just swap in the dark-mode tokens: --sage-dark is a *light* sage there
  // (#9cc6bf), so using it as the middle pole lit the whole backdrop and the below levels
  // came out brighter than the slabs they sit behind, with body copy washing out on top.
  // The accent is now mixed most of the way back to --bg, and gamma pulls the whole thing
  // down further, so the gradient reads as a dark ground with a hint of teal in it.
  const colors = dark
    ? { c1: read("--bg-2", "#18221f"),
        c2: mix(read("--sage", "#74b0a8"), read("--bg", "#111917"), 0.72),
        c3: read("--bg", "#111917") }
    : { c1: read("--bg", "#f5f1ea"), c2: read("--sage-light", "#a8c8c2"), c3: read("--bg-2", "#ede7dc") };

  return (
    <div className="page-backdrop" aria-hidden="true">
      <window.Grainient
        color1={colors.c1} color2={colors.c2} color3={colors.c3}
        timeSpeed={0.08} warpStrength={1} warpFrequency={3.2} warpSpeed={1.1}
        warpAmplitude={70} blendSoftness={0.1} rotationAmount={200} noiseScale={1.4}
        grainAmount={0.05} contrast={0.9} saturation={dark ? 0.5 : 0.45}
        gamma={dark ? 0.82 : 1} colorBalance={0.18} zoom={0.85} />
    </div>
  );
};
window.PageBackdrop = PageBackdrop;

// Shared tuning for the page headings' proximity effect (assets/js/variable-proximity.jsx).
// The resting weight is the h1's own 700, so every heading looks exactly as it did before and
// only the pointer changes anything. The radius is large because these are display sizes —
// upstream's default of 50 would light barely one letter of a 144px heading.
window.PS.PROX = {
  fromFontVariationSettings: "'wght' 700",
  toFontVariationSettings: "'wght' 900",
  radius: 160,
  falloff: "gaussian",
};

// ---------- Page hero aurora ----------
// A band of light across the top of the three cream page heroes. The home hero is a
// full-bleed photograph and does not get one.
//
// Colours are the site's own, re-read when the theme flips: sage-light -> sage -> terra, the
// cool-to-warm sweep the palette already runs on. Amplitude and blend are deliberately low —
// this sits behind an h1 and an eyebrow, so it has to read as light on the slab rather than
// as a picture competing with them.
const HeroAurora = () => {
  const theme = window.PS.useTheme();
  const reduced = window.matchMedia
    && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (reduced) return null;

  const read = (name, fallback) => {
    const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
    return /^#[0-9a-f]{6}$/i.test(v) ? v : fallback;
  };
  const dark = theme === "dark";
  const stops = [
    read("--sage-light", dark ? "#b3d2cc" : "#a8c8c2"),
    read("--sage", dark ? "#74b0a8" : "#68a29b"),
    read("--terra", dark ? "#e0855f" : "#d97757"),
  ];
  return (
    <div className="page-hero__aurora" aria-hidden="true">
      <window.Aurora colorStops={stops} amplitude={dark ? 0.85 : 0.95}
                     blend={dark ? 0.68 : 0.6} speed={0.4} />
    </div>
  );
};
window.HeroAurora = HeroAurora;

// ---------- Filled-star rating ----------
// Shared: the About reviews section and the home hero's proof badge both draw it. Amber
// rather than a site token — a rating star that is not gold reads as a decoration.
const Stars = ({ n = 5, size = 16 }) => {
  const color = "#f5a623";
  return (
    <span style={{ display: "inline-flex", gap: 2 }} aria-label={`${n}/5`}>
      {Array.from({ length: 5 }).map((_, i) => (
        <span key={i} style={{ color, display: "inline-flex", opacity: i < n ? 1 : 0.28 }}>
          <Icon name="star" size={size} className="icon-fill" />
        </span>
      ))}
    </span>
  );
};
window.Stars = Stars;

// ---------- Clinic map ----------
// The clinic's coordinates, supplied by the owner. Everything that points at the building
// reads them from here, so there is one place to correct.
// The order the seven services are shown in — on the services page's stack and the home
// carousel alike, so a visitor moving between the two meets them in the same sequence.
//
// **Named by glyph, not written as indices.** `SVC_ICONS` / `SVC_STACK_ICONS` are positional
// against `CONTENT.services.items`, so an order written as raw numbers would hand every
// service the next one's place the moment the dictionary is reordered. Naming them means the
// order travels with the service.
//
// Display order only: the cards still receive their DICTIONARY index, which is what keys the
// icons, the accents and the `#service-N` deep links.
window.PS.SVC_ORDER = ["svcOrtho", "svcModalities", "svcAcupuncture",
                       "svcExercise", "svcWomens", "svcFoot", "svcMassage"];
// Anything in the dictionary that this list does not name is appended rather than dropped —
// a service added to CONTENT without being named here still appears, at the end, instead of
// silently vanishing from both pages.
window.PS.svcOrder = (items, icons) => {
  const order = window.PS.SVC_ORDER
    .map((name) => icons.indexOf(name))
    .filter((i) => i >= 0 && i < items.length);
  const seen = new Set(order);
  items.forEach((_, i) => { if (!seen.has(i)) order.push(i); });
  return order;
};

window.PS.CLINIC = {
  lat: 37.794228530424185,
  lon: 26.70125046521299,
  // The clinic's own Google Maps place, not a coordinate search. A `?api=1&query=lat,lon`
  // URL only drops an anonymous pin; this short link resolves to the listed business, with
  // its name, hours and reviews. Owner-supplied — don't rebuild it from lat/lon.
  maps: "https://maps.app.goo.gl/VNYBimBnzRTNz78N7",
  // The clinic's contact address. It lives here rather than in the dictionaries because it is
  // the same string in every language and it was previously written as a literal at six call
  // sites — the same arrangement that once left the clinic page with a map pin 800m out to
  // sea after the About page had been corrected. Every mailto: and every rendering of it
  // reads this.
  email: "info@physiosamos.gr",
  // Legal identity, from the ΓΕΜΗ record (publicity.businessportal.gr/company/174307551000,
  // supplied by the owner 2026-09-05): a general partnership (Ο.Ε.) founded 2024-01-08,
  // registered with the Chamber of Samos. Rendered as the "business details" rows on /legal
  // (legal-page.jsx) and read by tools/build-seo.js for the structured data. An empty value is
  // simply not rendered, so the two still missing leave no placeholder behind.
  legalName: "ATZEMIAN PHYSIO SAMOS Ο.Ε.",   // registered name ("ATZEMIAN PHYSIO SAMOS G.P." in Latin script)
  tradeName: "ATZEMIAN PHYSIO SAMOS",        // διακριτικός τίτλος — what the public knows the clinic as
  legalForm: "Ο.Ε.",                         // the dictionaries carry the spelled-out form per language
  vat: "802322778",                          // ΑΦΜ
  taxOffice: "",                             // ΔΟΥ — not on the register extract; owner to supply if wanted
  gemi: "174307551000",                      // Αρ. ΓΕΜΗ (EUID ELGEMI.174307551000)
  founded: "2024-01-08",                     // ημερομηνία σύστασης of the Ο.Ε. (the register)
  since: 2017,                               // the year the clinic opened (legacy site); drives the home "years" stat
  licence: "",                               // operating licence number + issuing authority — owner to supply
  legalUpdated: "2026-09-05",                // ISO date of the legal texts; /legal formats it per language
};

// Drawn from OpenStreetMap vector data by tools/build-map.py, not embedded from a tile
// server. An iframe could not be recoloured to the site's tokens, could not follow the
// theme, and carried its own zoom controls and attribution bar.
//
// Four colours, all of them site tokens: the land is --bg-2, minor streets --line-2,
// major streets --ink-3, and the marker --terra. Nothing here is interactive.
//
// The credit line is an ODbL obligation for the derived geometry, not decoration.
// `top` and `bottom` overlay content onto the map itself. The map is inert
// (`pointer-events: none`), so anything interactive placed in them has to opt back in — see
// `.clinic-map__bottom .btn` in main.css.
const ClinicMap = ({ label, className = "", top, bottom, name }) => {
  const { useRef, useEffect } = React;
  const m = window.PS_MAP;
  const rootRef = useRef(null);
  const topRef = useRef(null);

  // Publish the top bar's height so the CSS can centre the marker on the map you can actually
  // see rather than on the whole box. It has to be measured rather than written down: the bar
  // is 93px at 1440 and 135px at 375, because the address wraps beside the button.
  // Hooks run before the `!m` bail-out below, or the hook order would change between renders.
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const bar = topRef.current;
    if (!bar) { root.style.removeProperty("--cmap-top-h"); return; }
    const apply = () => root.style.setProperty("--cmap-top-h", bar.offsetHeight + "px");
    apply();
    const ro = new ResizeObserver(apply);
    ro.observe(bar);
    return () => ro.disconnect();
  }, [top, name]);

  if (!m) return null;
  return (
    <div ref={rootRef} className={`clinic-map ${className}`.trim()} role="img" aria-label={label}>
      <svg viewBox={m.viewBox} aria-hidden="true" preserveAspectRatio="xMidYMid slice">
        {m.water.map((d, i) => <path key={`w${i}`} d={d} className="cmap-water" />)}
        {m.building.map((d, i) => <path key={`b${i}`} d={d} className="cmap-building" />)}
        {(m.path || []).map((d, i) => <path key={`p${i}`} d={d} className="cmap-path" />)}
        {m.minor.map((d, i) => <path key={`n${i}`} d={d} className="cmap-minor" />)}
        {m.major.map((d, i) => <path key={`m${i}`} d={d} className="cmap-major" />)}
        {/* Street names ride along their own road, rotated to its angle. */}
        {(m.labels || []).map((l, i) => (
          <text key={`t${i}`} x={l.x} y={l.y} className="cmap-label"
                transform={`rotate(${l.angle} ${l.x} ${l.y})`}>{l.text}</text>
        ))}
        <g className="cmap-pin" transform={`translate(${m.marker[0]} ${m.marker[1]})`}>
          <circle r="30" className="cmap-pin__halo" />
          <circle r="11" className="cmap-pin__dot" />
          {/* The business name, above its own pin. It sits inside the SVG rather than in an
              overlay so it stays pinned to the marker through the `slice` crop at any shape.
              Above, not below: "Ομήρου" runs to the right of the marker and the street grid
              is denser under it. */}
          {name ? <text y="-42" className="cmap-pin__label">{name}</text> : null}
        </g>
      </svg>
      {top ? <div ref={topRef} className="clinic-map__top">{top}</div> : null}
      {bottom ? <div className="clinic-map__bottom">{bottom}</div> : null}
      {/* An ODbL obligation, not decoration — it must stay visible, so it sits above the
          overlays and they leave room for it. */}
      <span className="cmap-credit">© OpenStreetMap</span>
    </div>
  );
};
window.ClinicMap = ClinicMap;

// ---------- Photo caption ----------
// The one caption format for text sitting inside a photograph: a translucent category
// chip, the name in the display face, and a small muted line under it. Every in-image
// description on the site uses this — the bio portraits it came from, the home featured
// tiles, and the services spotlights, which each used to have their own arrangement.
//
// `size` only scales it for the box it sits in; the order and the styling stay fixed.
// Two lines beside an accent bar, and never more than two — the same shape the home-visits
// note and the social tiles use (`.contact-note`): a full-height accent on the left, the
// labels stacked to the right. The translucent category chip that used to sit above the
// title is gone; a chip, a title and a subtitle was three registers of type competing inside
// a photograph. Where a call site only had a chip, that string is now the subtitle.
const PhotoCaption = ({ title, sub, size = "md" }) => (
  <div className={`photo-cap photo-cap--${size}`}>
    <span className="photo-cap__accent" aria-hidden="true" />
    <div className="photo-cap__text">
      <div className="photo-cap__title">{title}</div>
      {sub ? <div className="photo-cap__sub">{sub}</div> : null}
    </div>
  </div>
);
window.PhotoCaption = PhotoCaption;

// ---------- Logo ----------
const Logo = ({ size = 44, showSubtitle = false }) => {
  const [t] = useT();
  return (
    <a href="/" className="nav-logo" aria-label={t.brand.name}>
      <img draggable={false}
           src={window.PS.useTheme() === "dark" ? window.PS.logoDark : window.PS.logoLight}
           alt={t.brand.name} style={{ height: size }} />
      {showSubtitle && (
        <div className="nav-logo-text">
          <span>{t.brand.subtitle}</span>
        </div>
      )}
    </a>
  );
};
window.Logo = Logo;

// ---------- Flags ----------
// From window.PS_FLAGS, vendored out of flag-icons by tools/build-icons.sh. Like the
// icons, none of these are drawn here.
const Flag = ({ code }) => {
  const flags = window.PS_FLAGS || {};
  const f = flags[code] || flags.el;
  if (!f) return null;
  // Sized by `.flag-svg` in main.css, not inline: an inline width beats every stylesheet
  // rule, which is what stopped the touch-target block from holding the flag at 28px inside
  // its enlarged 40px button. The flag fills whatever box it is given.
  return (
    <svg viewBox={f.viewBox} preserveAspectRatio="xMidYMid slice" aria-hidden="true"
         className="flag-svg" dangerouslySetInnerHTML={{ __html: f.markup }} />
  );
};
window.Flag = Flag;

// ---------- Theme toggle ----------
const ThemeToggle = () => {
  const [theme, setThemeState] = useState(window.PS.getTheme());
  useEffect(() => {
    const onTheme = (e) => setThemeState(e.detail);
    window.addEventListener("ps:theme", onTheme);
    return () => window.removeEventListener("ps:theme", onTheme);
  }, []);
  const dark = theme === "dark";
  return (
    <button className="ctl-circle" onClick={() => window.PS.toggleTheme()} title="Theme"
            aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}>
      <Icon name={dark ? "sun" : "moon"} size={18} />
    </button>
  );
};
window.ThemeToggle = ThemeToggle;

// ---------- Language flag menu ----------
const LangFlagMenu = () => {
  const [t, lang, setLang] = useT();
  const [open, setOpen] = useState(false);
  // The page does not scroll under an open menu (owner's call, 2026-09-05). Paired with the
  // sheet's own lock through PS.lockScroll's reference count, so the menu inside the sheet
  // closing does not unlock the sheet.
  useEffect(() => {
    if (!open) return;
    window.PS.lockScroll(true);
    return () => window.PS.lockScroll(false);
  }, [open]);
  const ref = useRef(null);
  useEffect(() => {
    if (!open) return;
    const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", onDown);
    return () => document.removeEventListener("mousedown", onDown);
  }, [open]);
  const langs = window.PS.LANGS;
  const current = langs.find((l) => l.code === lang) || langs[0];
  return (
    <div className="lang-flag" ref={ref}>
      <button className="ctl-circle ctl-circle--flag" onClick={() => setOpen((o) => !o)}
              aria-haspopup="true" aria-expanded={open} aria-label={`Language: ${current.label}`}>
        <Flag code={lang} />
      </button>
      {open && (
        <div className="lang-menu" role="menu">
          {langs.map((l) => (
            <button key={l.code} role="menuitemradio" aria-checked={l.code === lang}
                    className={l.code === lang ? "active" : ""}
                    onClick={() => { setLang(l.code); setOpen(false); }}>
              <span className="flag-circle"><Flag code={l.code} /></span>
              <span>{l.label}</span>
            </button>
          ))}
        </div>
      )}
    </div>
  );
};
window.LangFlagMenu = LangFlagMenu;

// ---------- Nav controls (theme + language) ----------
const NavControls = () => (
  <div className="nav-controls">
    <ThemeToggle />
    <LangFlagMenu />
  </div>
);
window.NavControls = NavControls;

// ---------- Booking context ----------
window.PS.openBooking = () => window.dispatchEvent(new Event("ps:open-booking"));

// ---------- Nav ----------
const Nav = ({ current = "home" }) => {
  const [t] = useT();
  const [mobileOpen, setMobileOpen] = useState(false);
  // The page is frozen under the open sheet. `overflow: hidden` alone is not enough on a
  // touch screen — see PS.lockScroll in smooth-scroll.js, which pins the body there.
  useEffect(() => {
    if (!mobileOpen) return;
    window.PS.lockScroll(true);
    return () => window.PS.lockScroll(false);
  }, [mobileOpen]);

  useEffect(() => {
    // The static <head> carries the Greek title and description — tools/build-seo.js writes
    // them there from this same `seo` block — and once mounted both follow the active
    // language, so a visitor's tab and a rendering crawler see the words the page shows.
    const seo = (t.seo && t.seo[current]) || {};
    document.title = seo.title || `${t.brand.name} — ${t.brand.subtitle}`;
    const desc = document.querySelector('meta[name="description"]');
    if (desc && seo.desc) desc.setAttribute("content", seo.desc);
    document.documentElement.lang = window.PS.getLang();
  }, [t, current]);

  const links = [
    { key: "home", label: t.nav.home, href: "/" },
    { key: "services", label: t.nav.services, href: "/services" },
    { key: "clinic", label: t.nav.clinic, href: "/clinic" },
    { key: "about", label: t.nav.about, href: "/about" },
  ];

  return (
    <header className="nav" role="banner">
      <div className="nav-inner">
        <Logo />
        <nav className="nav-links" aria-label="Primary">
          {/* React Bits' PillNav hover, in CSS: a circle rises from the bottom of the pill
              while the label slides up and a second copy slides in behind it. Needs the
              label twice, so the markup carries both. Idle is unchanged. */}
          {links.map(l => (
            <a key={l.key} href={l.href} className={current === l.key ? "active" : ""}>
              <span className="pill-circle" aria-hidden="true" />
              <span className="label-stack">
                <span className="pill-label">{l.label}</span>
                <span className="pill-label-hover" aria-hidden="true">{l.label}</span>
              </span>
            </a>
          ))}
        </nav>
        <div className="nav-right">
          <NavControls />
          <button className="btn btn-primary btn-sm" onClick={() => window.PS.openBooking()}>
            {t.nav.book}<span className="arrow">→</span>
          </button>
          <button className="nav-mobile-btn" onClick={() => setMobileOpen(true)} aria-label="Menu">
            <Icon name="menu" size={24} />
          </button>
        </div>
      </div>
      {/* Portalled to <body> on purpose. .nav carries a backdrop-filter, which makes it
          the containing block for fixed-position descendants — rendered in place, this
          panel's `inset: 0` resolved to the 72px nav strip, so its background covered
          only that and the links spilled over the page below it with nothing behind
          them. A portal puts it back on the viewport. */}
      {mobileOpen && ReactDOM.createPortal(
        <div className="nav-sheet" role="dialog" aria-modal="true" aria-label={t.ui.menu}>
          <div className="nav-sheet__top">
            <Logo />
            <button onClick={() => setMobileOpen(false)} className="nav-sheet__close" aria-label={t.ui.close}>
              <Icon name="close" size={28} />
            </button>
          </div>
          <nav className="nav-sheet__links">
            {links.map(l => (
              <a key={l.key} href={l.href} className={current === l.key ? "active" : ""}>{l.label}</a>
            ))}
          </nav>
          <div className="nav-sheet__foot">
            <NavControls />
            <button className="btn btn-primary btn-lg" style={{ marginTop: 16, width: "100%" }}
                    onClick={() => { setMobileOpen(false); window.PS.openBooking(); }}>
              {t.nav.book}
            </button>
          </div>
        </div>, document.body)}
    </header>
  );
};
window.Nav = Nav;

// ---------- Marquee ----------
const Marquee = () => {
  const { useRef, useEffect } = React;
  const [t] = useT();
  const items = t.marquee;
  const tripled = [...items, ...items, ...items];
  const bandRef = useRef(null);
  const trackRef = useRef(null);
  const rafRef = useRef(0);

  // Hovering slows the band to a quarter speed rather than stopping it. A marquee that halts
  // under the cursor reads as broken or stuck, and most of the time the pointer is only
  // crossing the page on its way somewhere else.
  //
  // The speed change goes through the Web Animations handle on the CSS keyframes, because
  // `playbackRate` keeps the track exactly where it is and only changes how fast it moves
  // from there. Re-declaring `animation-duration` in a hover rule would instead re-resolve
  // the animation's progress against the new duration and snap the band sideways.
  useEffect(() => {
    const band = bandRef.current;
    const track = trackRef.current;
    if (!band || !track || typeof track.getAnimations !== "function") return;
    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    const SLOW = 0.25;
    let target = 1;
    let rate = 1;

    const ramp = () => {
      const anims = track.getAnimations();
      if (!anims.length) { rafRef.current = 0; return; }
      rate += (target - rate) * 0.12;
      if (Math.abs(target - rate) < 0.005) rate = target;
      anims.forEach((a) => { a.playbackRate = rate; });
      rafRef.current = rate === target ? 0 : requestAnimationFrame(ramp);
    };
    // Eased rather than switched: at 38s a marquee is slow enough that an instant drop to a
    // quarter speed reads as a stumble.
    const to = (v) => { target = v; if (!rafRef.current) rafRef.current = requestAnimationFrame(ramp); };
    const enter = () => to(SLOW);
    const leave = () => to(1);

    band.addEventListener("mouseenter", enter);
    band.addEventListener("mouseleave", leave);
    return () => {
      band.removeEventListener("mouseenter", enter);
      band.removeEventListener("mouseleave", leave);
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, []);

  return (
    <div className="marquee" aria-hidden="true" ref={bandRef}>
      <div className="marquee-track" ref={trackRef}>
        {tripled.map((m, i) => <span key={i} className="marquee-item">{m}</span>)}
      </div>
    </div>
  );
};
window.Marquee = Marquee;

// ---------- Booking Modal ----------
const BookingModal = () => {
  const [t] = useT();
  const [open, setOpen] = useState(false);

  useEffect(() => {
    const onOpen = () => setOpen(true);
    window.addEventListener("ps:open-booking", onOpen);
    return () => window.removeEventListener("ps:open-booking", onOpen);
  }, []);

  useEffect(() => {
    if (!open) return;
    const onKey = (e) => e.key === "Escape" && setOpen(false);
    document.addEventListener("keydown", onKey);
    document.body.style.overflow = "hidden";
    return () => {
      document.removeEventListener("keydown", onKey);
      document.body.style.overflow = "";
    };
  }, [open]);

  if (!open) return null;

  return (
    <div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setOpen(false)}>
      <div className="modal" role="dialog" aria-modal="true">
        <aside className="modal-side">
          <div>
            <span className="eyebrow" style={{ color: "rgba(245,241,234,0.5)" }}>· · ·</span>
            <h3 style={{ color: "var(--on-band)", marginTop: 18, fontSize: 32 }}>{t.book.side_title}</h3>
            <p style={{ marginTop: 18, color: "rgba(245,241,234,0.8)", fontSize: 15, lineHeight: 1.6 }}>
              {t.book.side_p}
            </p>
          </div>
          {/* The clinic on the map, taking every pixel the copy above and the one line below
              leave. Same component the clinic page draws, so it follows the theme and keeps
              its ODbL credit; only how it fills its box changes.

              There is no address block under it any more. The panel on the right already
              carries the street address as its "visit" row, and the two were the same three
              facts twice in one dialog — the removed copy was also hardcoded here rather than
              read from a dictionary, so it could not follow the language. */}
          <ClinicMap label={t.contact.address_label} className="clinic-map--fill" name={t.brand.name} />
          <p style={{ marginTop: 0, color: "var(--sage-light)", fontSize: 14, fontStyle: "italic" }}>
            ↳ {t.book.side_tip}
          </p>
        </aside>
        <div className="modal-main">
          <button className="modal-close" onClick={() => setOpen(false)} aria-label="Close">
            <Icon name="close" size={16} />
          </button>
          <span className="eyebrow">{t.contact.eyebrow}</span>
          <h3 style={{ marginTop: 12, fontSize: 36 }}>{t.book.title}</h3>
          <p style={{ marginTop: 14, color: "var(--ink-3)", fontSize: 15, lineHeight: 1.6 }}>
            {t.book.lead}
          </p>
          <div style={{ marginTop: 24 }}>
            {/* No hours line under the number. It read the hours array's first entry, a fixed
                index — Monday — so it printed Monday's hours on every day of the week and was
                false six days in seven. Showing the *current* day instead would need
                the weekday resolved in Europe/Athens rather than the visitor's own zone, and
                would put back the kind of time-dependent claim the "Τώρα ανοιχτά" pill was
                removed for. The full schedule is on the clinic page. */}
            <a href="tel:+302273078420" className="modal-contact-method">
              <span className="icon"><Icon name="phone" size={18} /></span>
              <div>
                <div className="label">{t.book.call}</div>
                <div className="value">{t.contact.phone_value}</div>
              </div>
              <span className="modal-contact-method__go"><Icon name="arrowSmall" size={16} /></span>
            </a>
            <a href={`mailto:${window.PS.CLINIC.email}`} className="modal-contact-method">
              <span className="icon"><Icon name="mail" size={18} /></span>
              <div>
                <div className="label">{t.contact.email_label}</div>
                <div className="value">{window.PS.CLINIC.email}</div>
              </div>
              <span className="modal-contact-method__go"><Icon name="arrowSmall" size={16} /></span>
            </a>
            <a href={window.PS.CLINIC.maps}
               target="_blank" rel="noreferrer" className="modal-contact-method">
              <span className="icon"><Icon name="pin" size={18} /></span>
              <div>
                <div className="label">{t.book.visit}</div>
                <div className="value">{t.contact.address_line1}</div>
                <div className="modal-hours">{t.contact.address_line2} · {t.contact.address_line3}</div>
              </div>
              <span className="modal-contact-method__go"><Icon name="arrowSmall" size={16} /></span>
            </a>
          </div>
        </div>
      </div>
    </div>
  );
};
window.BookingModal = BookingModal;

// ---------- Footer ----------
const Footer = () => {
  const [t] = useT();
  const bottomRef = useRef(null);
  // The bottom row is copyright on the left and the legal links on the right — unless the two
  // do not fit side by side, in which case they stack and centre (owner's call, 2026-09-05).
  // CSS cannot see whether a flex row wrapped, so it is measured: both children keep their
  // intrinsic width (flex-shrink 0, see .footer-bottom in main.css) and the row takes
  // .is-stacked when their sum plus the gap exceeds it. Re-measured on resize, after every
  // language switch (the strings change width — hence the [t] dependency, which runs after the
  // new strings have been committed) and through a ResizeObserver for everything else.
  const fitBottom = () => {
    const row = bottomRef.current;
    if (!row || row.children.length < 2) return;
    const [a, b] = row.children;
    row.classList.toggle("is-stacked", a.offsetWidth + b.offsetWidth + 24 > row.clientWidth);
  };
  useEffect(fitBottom, [t]);
  useEffect(() => {
    window.addEventListener("resize", fitBottom);
    let ro = null;
    if (window.ResizeObserver && bottomRef.current) {
      ro = new ResizeObserver(fitBottom);
      ro.observe(bottomRef.current);
      [...bottomRef.current.children].forEach((c) => ro.observe(c));
    }
    return () => { window.removeEventListener("resize", fitBottom); if (ro) ro.disconnect(); };
  }, []);
  // The footer now sits on --footer-bg, which flips with the theme, so the wordmark has
  // to flip with it too — it used to be pinned to the dark-mode logo against a dark slab.
  const logo = window.PS.useTheme() === "dark" ? window.PS.logoDark : window.PS.logoLight;
  // Brand colours, matching the clinic page's contact tiles: these two marks are the site's
  // one deliberate exception to colouring icons with site tokens, because a brand mark in the
  // wrong colour reads as a mistake. Instagram's is a gradient, not a flat approximation.
  const social = [
    { href: "https://www.facebook.com/people/Atzemian-Physio-Samos/100089499093406/",
      icon: "facebook", label: "Facebook", brand: "#1877F2" },
    { href: "https://www.instagram.com/maria_atzemian/", icon: "instagram", label: "Instagram", ig: true },
  ];
  return (
    <footer className="footer">
      <div className="footer-grid">
        <div className="footer-col footer-brand">
          <img src={logo} alt={t.brand.name} draggable={false}
               style={{ height: 80, width: "auto", marginBottom: 20 }} />
          {/* The tagline already says "Physiotherapy Centre in Neo Karlovasi, Samos", so the
              mono strap that used to sit under the logo said it a second time. */}
          <p>{t.footer.tagline}</p>
          <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
            {social.map(sm => (
              <a key={sm.icon} href={sm.href} target="_blank" rel="noreferrer"
                 className={"footer-social" + (sm.ig ? " footer-social--ig" : "")}
                 style={sm.brand ? { "--brand": sm.brand } : undefined}
                 aria-label={sm.label}>
                <Icon name={sm.icon} size={16} />
              </a>
            ))}
          </div>
        </div>
        <div className="footer-col">
          <h4>{t.footer.nav_title}</h4>
          {/* Bare links, in the same order as `Nav`'s own `links` array. No glyphs: a
              house, a list, a building and two figures label nothing a one-word page name
              does not already say, whereas the contact column's four rows are four
              different kinds of thing (landline, mobile, email, street) that do read
              better tagged. Owner's call, 2026-08-31. */}
          <a href="/">{t.nav.home}</a>
          <a href="/services">{t.nav.services}</a>
          <a href="/clinic">{t.nav.clinic}</a>
          <a href="/about">{t.nav.about}</a>
        </div>
        <div className="footer-col">
          <h4>{t.footer.contact_title}</h4>
          {/* Three peer rows, so they read as one list rather than two links and a
              paragraph. There is one number: the mobile came off site-wide at the owner's
              request, 2026-09-01, and `mobile` went out of build-icons.sh with it. */}
          <a className="footer-link" href="tel:+302273078420">
            <Icon name="phone" size={16} />22730 78420
          </a>
          <a className="footer-link" href={`mailto:${window.PS.CLINIC.email}`}>
            <Icon name="mail" size={16} />{window.PS.CLINIC.email}
          </a>
          {/* Street only. The town and postcode came off at the owner's request, and the
              tagline in the brand column already says Neo Karlovasi, Samos. */}
          <p className="footer-link">
            <Icon name="pin" size={16} />{t.contact.address_line1}
          </p>
        </div>
      </div>
      <div className="footer-mega" aria-hidden="true">PHYSIO · SAMOS</div>
      {/* The year is computed rather than written into the dictionaries, where it used to
          go stale in five languages at once. */}
      <div className="footer-bottom" ref={bottomRef}>
        <span>© {new Date().getFullYear()} {t.brand.name} · {t.footer.rights}</span>
        {/* The legal page's four sections, as cross-page hash links: page-exit.js keeps the hash
            through the curtain and mountApp lands on the target once the page has mounted. */}
        <nav className="footer-legal" aria-label={t.legal.eyebrow}>
          <a href="/legal#company">{t.legal.company}</a>
          <a href="/legal#privacy">{t.legal.privacy}</a>
          <a href="/legal#cookies">{t.legal.cookies}</a>
          <a href="/legal#terms">{t.legal.terms}</a>
        </nav>
      </div>
    </footer>
  );
};
window.Footer = Footer;

// ---------- Reveal hook ----------
// Section bodies rise once as their section arrives. The headings inside animate themselves
// (BlurText on the h2s, the same keyframe on the h1s' letters); this is everything else —
// leads, cards, images, buttons.
//
// It was disabled before because an IntersectionObserver-driven version was unreliable, and
// the failure mode was content that never appeared. Three things fix that rather than a
// second attempt at the same thing:
//
//   * The hidden start state hangs off `html.ps-anim`, added here. If this never runs, or
//     runs under reduced motion, nothing is hidden and everything is simply visible.
//   * The observer is backed by a rect check on scroll and resize, so a missed notification
//     still resolves the moment the visitor moves.
//   * Anything already on screen at mount is revealed immediately, not on the next event.
//
// Heroes are excluded. They are on screen before the first scroll and behind the preloader,
// so their "entrance" would be over before anyone saw it; their headings still animate.
window.PS.useReveal = function useReveal() {
  const { useEffect } = React;
  useEffect(() => {
    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const root = document.documentElement;

    const targets = Array.from(
      document.querySelectorAll(".lvl-above section > .wrap, .lvl-above section > .wrap-wide, " +
                                ".lvl-below section > .wrap, .lvl-below section > .wrap-wide")
    ).filter((el) => !el.closest(".hero-full, .page-hero"));
    if (!targets.length) return;

    root.classList.add("ps-anim");
    targets.forEach((el) => el.setAttribute("data-reveal", ""));

    const reveal = (el) => {
      if (el.classList.contains("is-in")) return;
      el.classList.add("is-in");
      if (io) io.unobserve(el);
    };
    const sweep = () => {
      const vh = window.innerHeight || root.clientHeight;
      let remaining = 0;
      targets.forEach((el) => {
        if (el.classList.contains("is-in")) return;
        const r = el.getBoundingClientRect();
        if (r.top < vh && r.bottom > 0) reveal(el); else remaining++;
      });
      if (!remaining) {
        window.removeEventListener("scroll", sweep);
        window.removeEventListener("resize", sweep);
      }
    };

    let io = null;
    if (typeof IntersectionObserver === "function") {
      io = new IntersectionObserver((entries) => {
        entries.forEach((e) => { if (e.isIntersecting) reveal(e.target); });
      }, { rootMargin: "0px 0px -8% 0px" });
      targets.forEach((el) => io.observe(el));
    }
    window.addEventListener("scroll", sweep, { passive: true });
    window.addEventListener("resize", sweep);
    sweep();

    return () => {
      if (io) io.disconnect();
      window.removeEventListener("scroll", sweep);
      window.removeEventListener("resize", sweep);
    };
  }, []);
};

// ---------- CTA Band ----------

// ---------- Service Drawer ----------
const ServiceDrawer = ({ service, onClose }) => {
  if (!service) return null;
  useEffect(() => {
    const onKey = (e) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", onKey);
    document.body.style.overflow = "hidden";
    return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = ""; };
  }, []);
  return (
    <>
      <div className="drawer-overlay" onClick={onClose}></div>
      <aside className="drawer" role="dialog">
        <div style={{ padding: "32px 40px 24px", borderBottom: "1px solid var(--line)",
                      display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16 }}>
          <div>
            <span className="tag">{service.chip}</span>
            <h2 style={{ marginTop: 18, fontSize: 44 }}>{service.title}</h2>
          </div>
          <button onClick={onClose} className="modal-close" style={{ position: "static", flexShrink: 0 }}>
            <Icon name="close" size={16} />
          </button>
        </div>
        <div style={{ padding: 40 }}>
          <p style={{ fontSize: 19, lineHeight: 1.55, color: "var(--ink-2)", fontFamily: "var(--font-display)", fontWeight: 400 }}>
            {service.subtitle}
          </p>
          <p style={{ marginTop: 24, color: "var(--ink-3)", lineHeight: 1.65 }}>
            {service.body}
          </p>
          <div style={{ marginTop: 32, paddingTop: 24, borderTop: "1px solid var(--line)" }}>
            <span className="eyebrow">{Array.isArray(service.subgroups) ? "Areas of care" : "Includes"}</span>
            {Array.isArray(service.subgroups) ? (
              <div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 14 }}>
                {service.subgroups.map((g, i) => (
                  <div key={i} style={{ padding: "14px 16px", background: "var(--bg-2)", borderRadius: "var(--radius)", border: "1px solid var(--line)" }}>
                    <div style={{ fontFamily: "var(--font-display)", fontSize: 16, fontWeight: 600 }}>{g.title}</div>
                    <p style={{ marginTop: 4, fontSize: 13.5, color: "var(--ink-3)", lineHeight: 1.5 }}>{g.body}</p>
                  </div>
                ))}
              </div>
            ) : (
              <ul className="g g--cards" style={{ marginTop: 16, padding: 0, listStyle: "none",
                           "--cols": "1fr 1fr", gap: "12px 24px" }}>
                {service.bullets.map((b, i) => (
                  <li key={i} style={{ display: "flex", alignItems: "flex-start", gap: 10, fontSize: 14, color: "var(--ink-2)" }}>
                    <span style={{ color: "var(--sage)", marginTop: 1, flexShrink: 0 }}>
                      <Icon name="check" size={16} />
                    </span>
                    {b}
                  </li>
                ))}
              </ul>
            )}
          </div>
          <div style={{ marginTop: 40, display: "flex", gap: 12 }}>
            <button className="btn btn-primary" onClick={() => window.PS.openBooking()}>
              Book this service <span className="arrow">→</span>
            </button>
            <a href="tel:+302273078420" className="btn btn-ghost">
              <Icon name="phone" size={14} /> 22730 78420
            </a>
          </div>
        </div>
      </aside>
    </>
  );
};
window.ServiceDrawer = ServiceDrawer;

// ---------- Services Accordion ----------
const ServicesAccordion = ({ limit, defaultOpen = null }) => {
  const [t] = useT();
  const [openIdx, setOpenIdx] = useState(defaultOpen);
  const [drawer, setDrawer] = useState(null);
  const list = limit ? t.services.items.slice(0, limit) : t.services.items;

  return (
    <div>
      <div>
        {list.map((s, i) => {
          const isOpen = openIdx === i;
          const hasSubgroups = Array.isArray(s.subgroups);
          return (
            <div key={s.title} className={`acc-item ${isOpen ? "open" : ""}`}>
              <div className="acc-row no-num" onClick={() => setOpenIdx(isOpen ? null : i)}>
                <div>
                  <div className="acc-title">{s.title}</div>
                  {!isOpen && <span className="acc-subtitle">{s.subtitle}</span>}
                </div>
                <span className="acc-chip">{s.chip}</span>
                <span className="acc-icon"><Icon name="plus" size={20} /></span>
              </div>
              {isOpen && (
                <div className="acc-body" style={{ animation: "fadeIn 280ms ease", display: "block" }}>
                  <div className="acc-body-left" style={{ maxWidth: 720 }}>
                    <p style={{ fontSize: 17, lineHeight: 1.6, color: "var(--ink-2)" }}>{s.body}</p>
                    <div style={{ marginTop: 24, display: "flex", gap: 12, flexWrap: "wrap" }}>
                      <button className="btn btn-primary btn-sm" onClick={() => setDrawer(s)}>
                        {t.ui.read_more} →
                      </button>
                      <button className="btn btn-ghost btn-sm" onClick={() => window.PS.openBooking()}>
                        {t.ui.book_short}
                      </button>
                    </div>
                  </div>
                </div>
              )}
            </div>
          );
        })}
      </div>
      <ServiceDrawer service={drawer} onClose={() => setDrawer(null)} />
    </div>
  );
};
window.ServicesAccordion = ServicesAccordion;

// ---------- Tweaks ----------
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "palette": ["#f5f1ea", "#68a29b", "#2d4a47", "#d97757"],
  "displayFont": "Literata",
  "bodyFont": "Manrope",
  "radius": 14
}/*EDITMODE-END*/;

const PSTweaksPanel = () => {
  const [tw, setTweak] = window.useTweaks(TWEAK_DEFAULTS);

  // Apply tweaks live to CSS variables
  useEffect(() => {
    const root = document.documentElement;
    if (Array.isArray(tw.palette)) {
      root.style.setProperty("--bg", tw.palette[0]);
      root.style.setProperty("--sage", tw.palette[1]);
      root.style.setProperty("--sage-dark", tw.palette[2]);
      root.style.setProperty("--terra", tw.palette[3]);
      root.style.setProperty("--ink-2", tw.palette[2]);
    }
    root.style.setProperty("--font-display", `"${tw.displayFont}", ui-serif, Georgia, serif`);
    root.style.setProperty("--font-sans", `"${tw.bodyFont}", ui-sans-serif, system-ui, sans-serif`);
    root.style.setProperty("--radius", tw.radius + "px");
  }, [tw]);

  const TP = window.TweaksPanel, TS = window.TweakSection,
        TC = window.TweakColor, TSel = window.TweakSelect,
        TSl = window.TweakSlider, TT = window.TweakToggle;

  return (
    <TP title="Tweaks">
      <TS label="Palette" />
      <TC label="Theme" value={tw.palette} onChange={(v) => setTweak("palette", v)}
        options={[
          ["#f5f1ea", "#68a29b", "#2d4a47", "#d97757"],
          ["#fafaf7", "#3a6b65", "#1a2f2d", "#e8dcc8"],
          ["#ffffff", "#86c1ba", "#0a3a4a", "#f0c674"],
          ["#1a2426", "#7ab8b0", "#f5f1ea", "#c8956d"],
          ["#f5efe8", "#c8956d", "#3a2820", "#68a29b"],
        ]} />
      <TS label="Typography" />
      {/* Only the two families the pages actually load. The old list offered faces with
          no Greek glyphs, so picking one silently swapped the site to a system fallback. */}
      <TSel label="Display" value={tw.displayFont} onChange={(v) => setTweak("displayFont", v)}
        options={["Literata", "Manrope"]} />
      <TSel label="Body" value={tw.bodyFont} onChange={(v) => setTweak("bodyFont", v)}
        options={["Manrope", "Literata"]} />
      <TS label="Shape" />
      <TSl label="Radius" value={tw.radius} min={0} max={32} step={2} unit="px"
        onChange={(v) => setTweak("radius", v)} />
    </TP>
  );
};
window.PSTweaksPanel = PSTweaksPanel;

// Tag the React app root with screen label
window.PS.mountApp = (rootSelector, App, screenLabel) => {
  const rootEl = document.querySelector(rootSelector);
  if (screenLabel) rootEl.setAttribute("data-screen-label", screenLabel);

  // Lift the boot cover on the first painted frame that actually has content in it.
  //
  // Only ever touch the element when it is *our* cover. On the first page of a session the
  // very same element is the real preloader, still warming the media cache behind its progress
  // bar, and mountApp runs ~440ms in — long before its 700ms floor. Removing it there cut the
  // preloader off mid-fetch.
  // A deep link (/legal#privacy, /clinic#contact) has to be landed by hand: the browser tried
  // at load, when #root held only the static fallback and the target did not exist. Both paths
  // need it — the boot curtain on later pages and the preloader on the first — so it runs
  // before the curtain check. ScrollStack's #service-N is left alone; that page owns its own
  // maths (see services-page.jsx) and a native scrollIntoView on a transformed card lands wrong.
  const landHash = () => {
    const h = location.hash;
    if (!h || h.length < 2 || /^#service-/.test(h)) return false;
    let el = null;
    try { el = document.getElementById(decodeURIComponent(h.slice(1))); } catch (e) { return false; }
    if (!el) return false;
    el.scrollIntoView();
    if (window.PS.syncScroll) window.PS.syncScroll();
    return true;
  };
  const uncover = () => {
    const de = document.documentElement;
    const landed = landHash();
    if (!de.classList.contains("ps-booting")) return;
    const cover = document.getElementById("ps-loader");

    // Land at the top before the curtain lifts, so the page is revealed already at its start
    // rather than scrolling under the visitor afterwards. Only for a real navigation: on a
    // back/forward the browser is restoring a position the visitor chose, and overriding that
    // would make the back button feel broken. `syncScroll` is what stops the wheel lerp
    // dragging the page back a frame later — see smooth-scroll.js.
    const nav = performance.getEntriesByType("navigation")[0];
    if (landed) { /* a deep link already put the page where it belongs */ }
    else if (!nav || nav.type === "navigate") {
      window.scrollTo(0, 0);
      if (window.PS.syncScroll) window.PS.syncScroll();
    }

    // `ps-booting` has to outlive the lift: it is what gives the curtain `position: fixed`
    // and `display: flex`, so dropping it first would make the element vanish instead of
    // slide. Both come off together when the animation ends.
    const finish = () => {
      de.classList.remove("ps-booting");
      if (cover && cover.parentNode) cover.parentNode.removeChild(cover);
    };
    const still = window.matchMedia
      && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!cover || still) { finish(); return; }

    const lift = () => {
      cover.addEventListener("animationend", finish, { once: true });
      // A hidden tab runs no CSS animations at all, so `animationend` can simply never
      // arrive. The curtain must still come off — a visitor returning to the tab has to find
      // the page, not a panel.
      setTimeout(finish, 900);
      cover.classList.add("is-lifting");
    };

    // Nothing to sequence against any more: the outgoing sweep runs on the *previous*
    // document (page-exit.js) and is over before this one exists, so the mount is the only
    // thing the lift waits for. The `__psReveal` wait that used to sit here died with the
    // cross-document View Transitions it was written for.
    lift();
  };
  const safety = setTimeout(uncover, 4000);   // a mount that never happens must not strand it
  // Size the headings for the viewport before the curtain lifts (fit-headings.js), so the
  // reader never sees the CSS size jump to the fitted one.
  const done = () => { clearTimeout(safety); if (window.PS.fitHeadings) window.PS.fitHeadings(); uncover(); };

  // Wait for the commit, not for two frames. `createRoot().render()` is asynchronous in React
  // 18 — it schedules the work rather than doing it — so a bare double-rAF after the call can
  // fire before a single node exists. On an unloaded page the two land in the same frame and it
  // looks fine; under load it lifted the cover ~60-130ms *before* the content arrived, which is
  // the one thing the cover exists to prevent. So: watch for the first child, then take two
  // frames to let it paint.
  const afterPaint = () => requestAnimationFrame(() => requestAnimationFrame(done));
  //
  // "First child" cannot be the signal any more: #root is not empty before React runs.
  // tools/build-seo.js writes a crawlable static copy of the page into it (#ps-static) for
  // crawlers and visitors without JavaScript, and React clears that on its first commit —
  // so a bare firstChild check is true before a single React node exists, and the curtain
  // would lift onto the fallback. Wait for a child that is *not* the fallback instead.
  const hasApp = () => Array.prototype.some.call(rootEl.children, (c) => c.id !== "ps-static");
  const mo = new MutationObserver(() => {
    if (!hasApp()) return;
    mo.disconnect();
    afterPaint();
  });
  mo.observe(rootEl, { childList: true });

  ReactDOM.createRoot(rootEl).render(<App />);
  if (hasApp()) { mo.disconnect(); afterPaint(); }
};
