// Shared components: Nav, Footer, ImgSlot, etc.

// ── Routing: real /paths ────────────────────────────────────────────────
// Each screen maps to a real URL so links are shareable/bookmarkable and show
// a proper path on hover. app.jsx drives navigation with history.pushState;
// vercel.json rewrites deep links back to index.html so refresh/direct-hit works.
const ROUTE_PATHS = {
  home: '/',
  services: '/services',
  specs: '/specs',
  configurator: '/configurator',
  portfolio: '/portfolio',
  store: '/store',
  about: '/about',
  subcontractor: '/subcontractor',
  quote: '/quote',
};
// Paths we used to publish. They still resolve to their current screen so old
// links, bookmarks and anything already indexed keep landing on the right page.
const LEGACY_PATHS = {
  '/merch': 'store',   // renamed to Store, Jul 2026
};
const routeToPath = (r) => ROUTE_PATHS[r] || '/';
const pathToRoute = (pathname) => {
  const clean = (pathname || '/')
    .replace(/\/index\.html$/i, '/')   // preview loads index.html directly
    .replace(/(.)\/+$/, '$1');         // drop trailing slash (keep root "/")
  return Object.keys(ROUTE_PATHS).find((r) => ROUTE_PATHS[r] === clean)
    || LEGACY_PATHS[clean]
    || 'home';
};
// Plain left-clicks navigate in-app; modifier / middle clicks fall through so
// the real href still opens in a new tab like any normal link.
function navClick(e, route, go) {
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || (e.button && e.button !== 0)) return;
  e.preventDefault();
  go(route);
}

const ChamblissMark = ({ size = 18, color = 'currentColor' }) => (
  <svg width={size} height={size} viewBox="0 0 18 18" fill="none">
    {/* Stylized truss / building silhouette */}
    <path d="M2 14 L9 4 L16 14 Z" stroke={color} strokeWidth="1.4" />
    <path d="M2 14 L16 14" stroke={color} strokeWidth="1.4" />
    <path d="M5.5 14 L9 9 L12.5 14" stroke={color} strokeWidth="1.2" />
  </svg>
);

const ChamblissLogo = ({ height = 30, color = 'currentColor' }) => {
  // Wordmark with truss-roof glyph. Designed to read at 28-44px height.
  const w = height * (240 / 44);
  return (
    <svg width={w} height={height} viewBox="0 0 240 44" fill="none" aria-label="Chambliss Steel Buildings">
      {/* Truss / building glyph */}
      <g stroke={color} fill="none" strokeWidth="1.6" strokeLinejoin="miter">
        <path d="M3 30 L18 8 L33 30 Z" />
        <path d="M3 30 L33 30" />
        <path d="M8 30 L18 16 L28 30" />
        <path d="M13 30 L13 24 L23 24 L23 30" fill={color} fillOpacity="0.0" />
        {/* Cross-tie */}
        <path d="M11 22 L25 22" strokeWidth="1.2" />
      </g>
      {/* Wordmark */}
      <text
        x="44" y="22"
        fontFamily="Geist, Inter, system-ui, sans-serif"
        fontSize="17"
        fontWeight="700"
        letterSpacing="-0.5"
        fill={color}
      >CHAMBLISS</text>
      <text
        x="44" y="36"
        fontFamily="Geist Mono, ui-monospace, monospace"
        fontSize="8"
        letterSpacing="1.6"
        fill={color}
        opacity="0.55"
      >STEEL · BUILDINGS · LIVE OAK FL</text>
    </svg>
  );
};

const Brand = ({ size = 'sm' }) => {
  const lg = size === 'lg';
  return (
    <a
      href="/"
      onClick={(e) => navClick(e, 'home', (r) => window.dispatchEvent(new CustomEvent('nav', { detail: r })))}
      className="brand"
      style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: lg ? 12 : 10, lineHeight: 1 }}
    >
      {/* Mark-only cut of the logo: the arc, crane and building, with the
          "CHAMBLISS / STEEL BUILDINGS" wordmark cropped off. Both are
          decorative -- the text beside them is the link's name.

          Two files, because the source logo is drawn for a dark background:
          the arc is WHITE, so it disappears on the light "paper" theme.
          -mark.png keeps it white, -mark-dark.png recolours it to #111, and
          the CSS below shows whichever suits the current theme. */}
      <img
        src="assets/img/logo-mark.png"
        className="brand-logo brand-logo--on-dark"
        alt=""
        aria-hidden="true"
        style={{ height: lg ? 52 : 40 }}
      />
      <img
        src="assets/img/logo-mark-dark.png"
        className="brand-logo brand-logo--on-light"
        alt=""
        aria-hidden="true"
        style={{ height: lg ? 52 : 40 }}
      />
      {/* Stacked wordmark. The display face is ~1.6x wider per character than
          Geist, so the name on ONE line runs 232px and blows out the row above
          1240px, where the seven links, phone and CTA are all still visible.
          Two lines echo the logo's own lockup and cost ~100px instead. */}
      <span style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
        <span style={{
          fontFamily: 'var(--font-display)',
          fontSize: lg ? 19 : 14,
          fontWeight: 700,
          letterSpacing: '0.01em',
          textTransform: 'uppercase',
        }}>
          Chambliss
        </span>
        <span style={{
          fontFamily: 'var(--font-display)',
          fontSize: lg ? 11 : 8,
          fontWeight: 500,
          letterSpacing: '0.12em',
          textTransform: 'uppercase',
          color: 'var(--fg-faint)',
        }}>
          Steel Buildings
        </span>
      </span>
    </a>
  );
};

const Nav = ({ route, setRoute }) => {
  const [menuOpen, setMenuOpen] = React.useState(false);
  // Desktop link bar, EIGHT items, and the row is tuned to the pixel to hold
  // them: .nav-link horizontal padding is down to 5px and the wordmark wraps to
  // two lines specifically so this list fits on one row all the way down to the
  // 1260px burger breakpoint. There is no slack left. A ninth link, or a longer
  // label on an existing one, puts the Get a quote button past the right edge in
  // the 1261-1330px band, which scrolls the whole page sideways rather than
  // wrapping (.nav-links is flex-shrink: 0). Measure that band before touching
  // this list.
  const links = [
    { id: 'home', label: 'Home' },
    { id: 'about', label: 'About' },
    { id: 'services', label: 'Services' },
    { id: 'specs', label: 'Specs' },
    // { id: 'portfolio', label: 'Portfolio' }, // hidden for now
    { id: 'configurator', label: 'Configurator' },
    { id: 'quote', label: 'Quote' },
    { id: 'subcontractor', label: 'Subcontractors' },
    { id: 'store', label: 'Store' },
  ];
  // Specs is now in the desktop bar (next to Services, where people look), so the
  // burger menu is just the same list stacked.
  const mobileLinks = links;
  // Nav + close the mobile menu.
  const goAndClose = (id) => { setRoute(id); setMenuOpen(false); };
  return (
    <nav className="nav">
      {/* First tab stop on every page: lets keyboard users jump the whole nav */}
      <a className="skip-link" href="#main">Skip to main content</a>
      <div className="nav-inner">
        <Brand />
        <div className="nav-links">
          {links.map(l => (
            <a
              key={l.id}
              href={routeToPath(l.id)}
              className={`nav-link ${route === l.id ? 'active' : ''}`}
              aria-current={route === l.id ? 'page' : undefined}
              onClick={(e) => navClick(e, l.id, setRoute)}
            >
              {l.label}
            </a>
          ))}
        </div>
        <div className="nav-spacer"></div>
        <div className="nav-meta">
          <span className="dot"></span>
        </div>
        <a href="tel:+13866881121" className="btn btn-ghost mono nav-phone" style={{ fontSize: 12, flexShrink: 0 }}>386.688.1121</a>
        <button className="btn btn-primary" onClick={() => setRoute('quote')} style={{ flexShrink: 0 }}>
          Get a quote
          <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M3 6 L9 6 M6 3 L9 6 L6 9" stroke="currentColor" strokeWidth="1.6"/></svg>
        </button>
        {/* Mobile hamburger, reveals the links that .nav-links hides ≤900px. */}
        <button
          className="nav-burger"
          aria-label={menuOpen ? 'Close menu' : 'Open menu'}
          aria-expanded={menuOpen}
          onClick={() => setMenuOpen(o => !o)}
        >
          {menuOpen ? (
            <svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M4 4 L14 14 M14 4 L4 14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/></svg>
          ) : (
            <svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M2 5 H16 M2 9 H16 M2 13 H16" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/></svg>
          )}
        </button>
      </div>

      {menuOpen && (
        <div className="nav-mobile-menu">
          {mobileLinks.map(l => (
            <a
              key={l.id}
              href={routeToPath(l.id)}
              className={`nav-mobile-link ${route === l.id ? 'active' : ''}`}
              aria-current={route === l.id ? 'page' : undefined}
              onClick={(e) => navClick(e, l.id, goAndClose)}
            >
              {l.label}
            </a>
          ))}
        </div>
      )}
    </nav>
  );
};

// Footer social links with brand glyphs (Simple Icons paths, filled with
// currentColor so they inherit the footer's muted colour + hover).
const SOCIALS = [
  { label: 'Facebook', href: 'https://www.facebook.com/ChamblissSteelBuilding',
    path: 'M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z' },
  { label: 'Instagram', href: 'https://www.instagram.com/chamblisssteelbuildings/',
    path: 'M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077' },
  { label: 'LinkedIn', href: 'https://www.linkedin.com/company/chamblisssb',
    path: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z' },
  { label: 'YouTube', href: 'https://www.youtube.com/@ChamblissSteelBuildings',
    path: 'M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z' },
];

const Footer = ({ setRoute }) => (
  <footer className="footer">
    <div className="page">
      <div className="footer-grid">
        <div className="footer-col">
          {/* Logo only, the wordmark is in the image so a repeated text brand
              would just be a duplicate line under it. */}
          <a href="/" onClick={(e) => navClick(e, 'home', setRoute)} style={{ display: 'inline-block' }}>
            <img src="assets/img/logo.png" alt="Chambliss Steel Buildings, home" style={{ width: 180, display: 'block' }} />
          </a>
          <div style={{ marginTop: 24, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
            <a className="pill" href="https://www.myfloridalicense.com/portalsearches/VerifyLicensee/LicenseDetail?ID=97D8C5F8F4B47D017108086D9DC52706" target="_blank" rel="noopener noreferrer"><span className="led"></span>CBC1267224 ↗</a>
            <a className="pill" href="https://www.bbb.org/us/fl/live-oak/profile/metal-building/chambliss-steel-buildings-0403-236017462" target="_blank" rel="noopener noreferrer">BBB Accredited ↗</a>
          </div>
        </div>
        <div className="footer-col">
          <h5>Site</h5>
          <ul>
            <li><a href={routeToPath('home')} onClick={(e) => navClick(e, 'home', setRoute)}>Home</a></li>
            <li><a href={routeToPath('about')} onClick={(e) => navClick(e, 'about', setRoute)}>About</a></li>
            <li><a href={routeToPath('services')} onClick={(e) => navClick(e, 'services', setRoute)}>Services</a></li>
            <li><a href={routeToPath('specs')} onClick={(e) => navClick(e, 'specs', setRoute)}>Specs</a></li>
            {/* Portfolio hidden for now */}
            <li><a href={routeToPath('configurator')} onClick={(e) => navClick(e, 'configurator', setRoute)}>Configurator</a></li>
            <li><a href={routeToPath('quote')} onClick={(e) => navClick(e, 'quote', setRoute)}>Get a quote</a></li>
            <li><a href={routeToPath('subcontractor')} onClick={(e) => navClick(e, 'subcontractor', setRoute)}>Subcontractor application</a></li>
            <li><a href={routeToPath('store')} onClick={(e) => navClick(e, 'store', setRoute)}>Store</a></li>
            {/* The client's own brochure, the one item in this column that is a
                file rather than a route, so it carries a PDF marker instead of
                the ↗ the external links elsewhere in this footer use. Lives here
                rather than in the top nav on purpose: it's a 4.4 MB download,
                which is a fine thing to offer and a bad thing to put in front of
                a first-time visitor on a phone. */}
            <li>
              <a
                href="assets/pdf/chambliss-building-brochure.pdf"
                target="_blank"
                rel="noopener noreferrer"
                style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}
              >
                Building brochure
                <span className="mono" aria-hidden="true" style={{ opacity: 0.45, fontSize: 10, letterSpacing: '0.06em' }}>PDF</span>
              </a>
            </li>
          </ul>
        </div>
        <div className="footer-col">
          <h5>Contact</h5>
          <ul>
            <li><a href="tel:+13866881121">386.688.1121</a></li>
            <li><a href="mailto:chamblisssteelbuildings@gmail.com">chamblisssteelbuildings@​gmail.com</a></li>
            <li>6825 Skeen Rd</li>
            <li>Live Oak, FL 32060</li>
          </ul>
        </div>
        <div className="footer-col">
          <h5>Follow</h5>
          <ul>
            {SOCIALS.map((s) => (
              <li key={s.label}>
                <a href={s.href} target="_blank" rel="noopener noreferrer" style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
                  <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style={{ flexShrink: 0 }}>
                    <path d={s.path} />
                  </svg>
                  {s.label}
                  <span aria-hidden="true" style={{ opacity: 0.5, fontSize: 11 }}>↗</span>
                </a>
              </li>
            ))}
          </ul>
        </div>
        <div className="footer-col">
          <h5>Platforms</h5>
          <ul>
            <li>
              <a href="https://fabtrk.com" target="_blank" rel="noopener noreferrer" style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                FabTrk <span aria-hidden="true" style={{ opacity: 0.5, fontSize: 11 }}>↗</span>
              </a>
              <div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 1 }}>3D &amp; fabrication</div>
            </li>
            <li style={{ marginTop: 10 }}>
              <a href="https://steelcare.xyz" target="_blank" rel="noopener noreferrer" style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                SteelCare <span aria-hidden="true" style={{ opacity: 0.5, fontSize: 11 }}>↗</span>
              </a>
              <div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 1 }}>steelcare.xyz</div>
            </li>
          </ul>
        </div>
      </div>
      <div className="mob-stack" style={{
        padding: '28px 0',
        borderTop: '1px solid var(--border)',
        display: 'grid',
        gridTemplateColumns: '1fr auto',
        gap: 24,
        alignItems: 'center',
      }}>
        <p style={{ fontSize: 13, fontStyle: 'italic', color: 'var(--fg-dim)', maxWidth: 760 }}>
          “For God so loved the world, that he gave his only Son, that whoever believes in him should
          not perish but have eternal life.” <span className="mono" style={{ fontStyle: 'normal', color: 'var(--fg-faint)' }}>/ John 3:16</span>
        </p>
        <div style={{ display: 'flex', alignItems: 'center', gap: 20, justifySelf: 'start', flexWrap: 'wrap' }}>
          <a
            href="https://www.bbb.org/us/fl/live-oak/profile/metal-building/chambliss-steel-buildings-0403-236017462"
            target="_blank"
            rel="noopener noreferrer"
            aria-label="Chambliss Steel Buildings, BBB Accredited Business profile"
            style={{ display: 'inline-flex', flexShrink: 0, opacity: 0.9 }}
          >
            {/* White BBB mark reads on the dark footer with no backing plate */}
            <img src="assets/img/bbb-logo-white.svg" alt="BBB Accredited Business" style={{ height: 46, width: 'auto', display: 'block' }} />
          </a>
          {/* White-on-transparent version (stars stay white) for the dark footer */}
          <img src="assets/img/made-in-usa-white.png" alt="Made in the USA" style={{ height: 54, width: 'auto', display: 'block', opacity: 0.9 }} />
        </div>
      </div>
      <div className="footer-bottom">
        <span>© 2026 CHAMBLISS STEEL BUILDINGS</span>
        <span>LIVE OAK · FLORIDA · 30.2949°N, 82.9849°W</span>
      </div>
    </div>
  </footer>
);

const IMG_FILL = { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' };

// The standard panel colors, shared so the services page's finish chart and the
// quote form's swatch row can never drift apart.
//
// This is the client's own chart, off page 12 of the Chambliss brochure, and it
// replaces an earlier generic mill set that listed nine colors Chambliss does
// not sell (Pacific Blue, Patriot Red, Black, Taupe, Forest, Terra Cotta,
// Almond, Olive, Slate Blue) while missing nine it does.
//
// Hexes are sampled from the composited PDF page, NOT from the chart image
// placed on it. That image is an older 14-swatch version and the page overlays
// corrected labels on top of it, so the raster reads "Cobalt Blue" where the
// brochure now says Gallery Blue and "Solar White" where it says Evergreen, and
// it has no Colony Green at all. Sampling the image alone gets two names wrong
// and drops a third color.
//
// Painted colors only. Galvalume Plus is a bare finish rather than a paint
// color, and the quote form already offers it as its own roof-color choice, so
// listing it here would contradict that split. Panel stock is 26 gauge, Grade 80
// and Grade 50. Other colors are available on request, and the brochure is
// explicit that final selection should be made from actual color chips.
const STEEL_COLORS = [
  ['#044b75', 'Gallery Blue'],
  ['#356e89', 'Hawaiian Blue'],
  ['#4e453e', 'Burnished Slate'],
  ['#5f5b58', 'Charcoal Gray'],
  ['#bcbcb2', 'Ash Gray'],
  ['#5e4a3f', 'Koko Brown'],
  ['#afa689', 'Desert Sand'],
  ['#c4ad8b', 'Saddle Tan'],
  ['#dcd3b6', 'Light Stone'],
  ['#003b29', 'Evergreen'],
  ['#e8eae9', 'Polar White'],
  ['#954232', 'Rustic Red'],
  ['#c03933', 'Crimson Red'],
  ['#27463e', 'Fern Green'],
  ['#648364', 'Colony Green'],
];

// The roll-up door colors, off the Janus International color chart the client
// orders from. Deliberately NOT merged into STEEL_COLORS above: panel color and
// door color are two separate products from two separate suppliers, specified
// separately on the order. Where a name collides the value genuinely differs
// (Janus Light Stone is #d0c4ab, the panel Light Stone is #7d8389), so keeping
// one list would quietly show customers the wrong chip.
//
// Hexes are sampled from the vector swatches in the chart PDF at
// assets/pdf/janus-door-color-chart.pdf. Galvalume and Woodgrain are printed as
// textures rather than flat fills, so those two carry a gradient approximation
// and are flagged so the copy can say so. Tiers are Janus's own pricing tiers:
// the standard set carries no upcharge, every specialty tier does.
const JANUS_DOOR_COLORS = [
  {
    tier: 'Standard',
    note: 'No upcharge',
    colors: [
      ['#2b1f14', 'Bronze'],
      ['#5e0c01', 'Cedar Red'],
      ['#372719', 'Continental Brown'],
      ['#958a73', 'Desert Sand'],
      ['#c5ab90', 'Desert Tan'],
      ['#ecf3f4', 'High Gloss White'],
      ['#003b2e', 'LG Forest Green'],
      ['#d0c4ab', 'Light Stone'],
      ['#ab9e90', 'Sandstone'],
      ['#c4cbca', 'Satin White'],
      ['#7e837f', 'Silhouette Gray'],
    ],
  },
  {
    tier: 'Specialty · Tier 1',
    note: 'Upcharge applies',
    colors: [
      ['linear-gradient(135deg, #d8dcdd, #f2f4f4 38%, #b9c0c2 68%, #e6e9ea)', 'AG Galvalume', 'texture'],
      ['#687c6b', 'Colony Green'],
      ['#001914', 'Evergreen'],
      ['#002010', 'Fern Green'],
      ['#2e667e', 'Polar Blue'],
      ['#0c416b', 'Royal Blue'],
      ['#0082bd', 'Smart Blue'],
      ['#ea6a30', 'Sunset Orange'],
      ['#0195a2', 'Teal'],
      ['#07316c', 'Ultra Marine Blue'],
      ['#292e5d', 'Cobalt Blue'],
    ],
  },
  {
    tier: 'Specialty · Tier 2',
    note: 'Upcharge applies',
    colors: [
      ['#a21d26', 'Patriot Red'],
      ['#d82d26', 'Sierra Sunset'],
      ['#b11d22', 'Valentine Red'],
      ['#7bc258', 'EXR Wasabi'],
    ],
  },
  {
    tier: 'Specialty · Tier 3',
    note: 'Upcharge applies',
    colors: [
      ['#62bb46', 'Apple Lime Cocktail'],
      ['#28757d', 'Dark Teal'],
      ['#52051c', 'Maroon'],
      ['#ffcf12', 'Safety Yellow'],
      ['#ffd04f', 'LS Yellow'],
    ],
  },
  {
    tier: 'Specialty · Tier 4',
    note: 'Upcharge applies',
    colors: [
      ['linear-gradient(100deg, #6b4423, #8a5a2f 32%, #5c3a1e 58%, #7d5129)', 'Woodgrain', 'texture'],
    ],
  },
];

// Photos under assets/img/projects/ ship as -800/-1600 in both jpg and webp.
// Pass `slug` (bare basename, no size or extension) to get the <picture> with
// both formats and a srcset; `src` stays the path-in, single-file route for
// every older asset that has no siblings. `sizes` should describe the slot's
// rendered width, the default suits a 3-or-4-up grid inside .page.
const ProjectPicture = ({ slug, alt, sizes = '(max-width: 900px) 50vw, 25vw' }) => {
  const base = `assets/img/projects/${slug}`;
  return (
    <picture>
      <source type="image/webp" sizes={sizes}
              srcSet={`${base}-800.webp 800w, ${base}-1600.webp 1600w`} />
      <img
        src={`${base}-800.jpg`}
        srcSet={`${base}-800.jpg 800w, ${base}-1600.jpg 1600w`}
        sizes={sizes}
        alt={alt}
        loading="lazy"
        decoding="async"
        style={IMG_FILL}
      />
    </picture>
  );
};

const ImgSlot = ({ label, dims, tag, height = 360, style = {}, src, slug, sizes }) => {
  // A `src` that 404s used to leave a broken-image glyph sitting in the frame.
  // Falling back to the same labelled placeholder an empty slot gets keeps the
  // layout intact while a photo is still on its way into assets/.
  const [failed, setFailed] = React.useState(false);
  React.useEffect(() => { setFailed(false); }, [src]);
  const placeholder = !slug && (!src || failed);
  return (
    <div className="img-slot" style={{ height, ...style }}>
      {slug && <ProjectPicture slug={slug} alt={label} sizes={sizes} />}
      {!slug && src && !failed && (
        <img src={src} alt={label} style={IMG_FILL} onError={() => setFailed(true)} />
      )}
      {tag && <div className="corner-tag">{tag}</div>}
      {placeholder && (
        <div className="label-wrap">
          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" strokeWidth="1">
            <rect x="2" y="2" width="24" height="24" />
            <path d="M2 22 L10 12 L18 20 L22 16 L26 22" />
            <circle cx="20" cy="8" r="2" />
          </svg>
          <span>{label}</span>
        </div>
      )}
      {dims && <div className="corner-dims">{dims}</div>}
    </div>
  );
};

// Looping silent clip in an ImgSlot-shaped frame. Drop-in sibling of ImgSlot:
// same box, same corner tag, but the fill is video with the poster as its
// still fallback. `src` is a basename under assets/video, both the .webm and
// the .mp4 are offered and the browser picks.
//
// Playback is deliberately conservative. Nothing is fetched until the clip is
// actually wanted (preload="none" + poster), decoding only runs while the clip
// is on screen, and prefers-reduced-motion leaves the poster still. Six of
// these can sit in one grid without the page turning into a space heater.
const LoopVideo = ({
  src,
  label,
  trigger = 'view',      // 'view' = play while on screen, 'hover' = play on hover/focus
  height = 360,
  tag = null,
  style = {},
}) => {
  const wrapRef = React.useRef(null);
  const videoRef = React.useRef(null);

  const play = React.useCallback(() => {
    const v = videoRef.current;
    if (!v) return;
    v.muted = true;              // React can drop the attribute, so set the property
    const p = v.play();          // autoplay rejection is fine, the poster stays up
    if (p && p.catch) p.catch(() => {});
  }, []);

  const pause = React.useCallback((rewind) => {
    const v = videoRef.current;
    if (!v) return;
    v.pause();
    if (rewind) v.currentTime = 0;
  }, []);

  React.useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (reduce) return;          // poster only, never start the loop

    // Scrolling away pauses the clip whatever the trigger is, a hover that
    // scrolls off screen shouldn't keep decoding (mouseleave never fires).
    if (!('IntersectionObserver' in window)) {
      if (trigger === 'view') play();
      return;
    }
    const io = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) pause(false);
        else if (trigger === 'view') play();
      },
      { rootMargin: '200px 0px', threshold: 0.01 }
    );
    io.observe(el);
    return () => io.disconnect();
  }, [trigger, play, pause]);

  // Hover mode: the clip follows the hover/focus state of the card it sits in,
  // not just of its own 220px of pixels, so pointing at the caption plays it
  // too. Falls back to the frame itself when there's no enclosing control.
  // Touch devices never fire these, so phones keep the poster, which is also
  // the right call for their data plan.
  React.useEffect(() => {
    if (trigger !== 'hover') return;
    const el = wrapRef.current;
    if (!el) return;
    const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (reduce) return;

    // No on-screen check here: if a pointer is on the card, the card is on
    // screen. Gating this on the observer only made hover a no-op until an
    // intersection update happened to land.
    const host = el.closest('button, a') || el;
    const enter = () => play();
    const leave = () => pause(true);
    host.addEventListener('mouseenter', enter);
    host.addEventListener('mouseleave', leave);
    host.addEventListener('focus', enter);
    host.addEventListener('blur', leave);
    return () => {
      host.removeEventListener('mouseenter', enter);
      host.removeEventListener('mouseleave', leave);
      host.removeEventListener('focus', enter);
      host.removeEventListener('blur', leave);
    };
  }, [trigger, play, pause]);

  return (
    <div ref={wrapRef} className="img-slot" style={{ height, ...style }}>
      <video
        ref={videoRef}
        className="loop-video"
        poster={`assets/video/${src}-poster.jpg`}
        aria-label={label}
        muted loop playsInline preload="none"
        tabIndex={-1}
      >
        <source src={`assets/video/${src}.webm`} type="video/webm" />
        <source src={`assets/video/${src}.mp4`} type="video/mp4" />
      </video>
      {tag && <div className="corner-tag">{tag}</div>}
    </div>
  );
};

const SectionEyebrow = ({ id, label }) => (
  <div className="section-eyebrow">
    {id && <><span>{id}</span><span>/</span></>}
    <span>{label}</span>
  </div>
);

Object.assign(window, { ChamblissMark, ChamblissLogo, Brand, Nav, Footer, ImgSlot, ProjectPicture, LoopVideo, SectionEyebrow, STEEL_COLORS, JANUS_DOOR_COLORS, ROUTE_PATHS, routeToPath, pathToRoute, navClick });
