/* Ecosystem directory for /app/live-apps.
 *
 * Loaded as a <script type="text/babel"> AFTER ecosystem-data.js, so
 * window.ECOSYSTEM is populated by the time Ecosystem() first runs.
 * No exports — Ecosystem becomes a window global that live-apps.jsx
 * renders. Same convention as every other .jsx here.
 */
const { useState, useMemo, useRef, useEffect, useLayoutEffect } = React

/* ── Motion ───────────────────────────────────────────────────────────
 * GSAP core is vendored (see vendor/README.md) and loaded as a plain
 * <script> before this file, so window.gsap is already defined. Every
 * call site still goes through motionOn(): if the library is ever
 * missing, or the visitor asked for reduced motion, animations are
 * skipped and state changes apply instantly. Nothing here is required
 * for the directory to function — GSAP smooths transitions, it does
 * not drive them.
 *
 * Checked per call rather than cached: a visitor can flip the OS
 * reduced-motion setting without reloading the page. */
function prefersReducedMotion() {
  return typeof window !== 'undefined' &&
    typeof window.matchMedia === 'function' &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches
}

function motionOn() {
  return typeof gsap !== 'undefined' && !prefersReducedMotion()
}

/* Keeps a node mounted for the length of its exit animation, then
 * unmounts it for real.
 *
 * The unmount matters well beyond tidiness: an earlier version of the
 * intent dropdown stayed in the DOM while "closed" and merely hid
 * itself with clip-path, which left its buttons in the tab order and
 * its role="listbox" exposed to assistive tech, and its nowrap
 * children overflowed the page at narrow widths. Anything that hides
 * by animating must therefore leave the DOM when it finishes.
 *
 * `enter` and `leave` receive the node and return a GSAP tween. The
 * leave tween's completion — not a guessed timeout — is what triggers
 * the unmount, so the two can never drift apart. */
function useEnterLeave(isOpen, enter, leave) {
  const [mounted, setMounted] = useState(isOpen)
  const nodeRef = useRef(null)
  const tweenRef = useRef(null)

  useEffect(() => {
    if (isOpen) setMounted(true)
  }, [isOpen])

  useLayoutEffect(() => {
    const node = nodeRef.current
    if (!node) return
    if (tweenRef.current) { tweenRef.current.kill(); tweenRef.current = null }

    if (isOpen) {
      if (!motionOn()) { gsapSafeClear(node); return }
      tweenRef.current = enter(node)
      return
    }

    // Closing. Without motion there is nothing to wait for.
    if (!motionOn()) { setMounted(false); return }
    tweenRef.current = leave(node, () => setMounted(false))
  }, [isOpen])

  useEffect(() => () => { if (tweenRef.current) tweenRef.current.kill() }, [])

  return { mounted: isOpen || mounted, nodeRef }
}

/* Strips inline styles GSAP may have left behind, so a node that
 * mounts while motion is off doesn't inherit a half-finished tween's
 * opacity/transform from a previous, motion-on interaction. */
function gsapSafeClear(node) {
  if (typeof gsap !== 'undefined') gsap.set(node, { clearProps: 'all' })
}

/* Display labels for the derived category pills. A category with no
 * entry here still renders — it just shows its raw id — so adding a
 * new category to the data never breaks the bar. */
const CATEGORY_LABEL = {
  wallet: 'Wallet',
  bridge: 'Bridge',
  defi: 'DeFi',
}

function categoryLabel(id) {
  return CATEGORY_LABEL[id] || id.charAt(0).toUpperCase() + id.slice(1)
}

/* Case-insensitive substring match across the three fields a visitor
 * would plausibly type: the name, any subtag, and the blurb. */
function matchesQuery(entry, query) {
  const q = query.trim().toLowerCase()
  if (!q) return true
  return (
    entry.name.toLowerCase().includes(q) ||
    entry.blurb.toLowerCase().includes(q) ||
    entry.subtags.some((t) => t.toLowerCase().includes(q))
  )
}

/* Names the filters that are actually on, so the empty state says what
 * to undo rather than just "no results". */
function activeFilterSummary(intent, category, query, INTENTS) {
  const parts = []
  if (intent) {
    const i = INTENTS.find((x) => x.id === intent)
    if (i) parts.push(`“${i.label}”`)
  }
  if (category !== 'all') parts.push(categoryLabel(category))
  if (query.trim()) parts.push(`“${query.trim()}”`)
  return parts.length ? parts.join(' + ') : 'those filters'
}

/* Intent dropdown — single-select, grouped. Trigger reads
 * "I want to ___". Reuses the bridge-route__* classes from
 * live-apps.jsx (styling-only, not bridge-specific).
 * Closes on outside click and on Escape.
 *
 * The closed menu is genuinely removed from the DOM, never merely
 * hidden. An earlier version hid it with clip-path + pointer-events,
 * which caused three bugs at once: its nowrap flex-column children
 * forced real width even while clipped (horizontal overflow at 375px —
 * clip-path shrinks a box's paint, not its layout), its buttons stayed
 * in the tab order (Tab from the trigger walked into them and Enter
 * silently applied a filter), and role="listbox" stayed exposed to
 * assistive tech beside aria-expanded="false".
 *
 * useEnterLeave keeps that guarantee while GSAP plays the exit: the
 * node unmounts on the tween's completion callback, so the animation
 * and the unmount cannot drift apart the way a hardcoded timeout can.
 * The trigger's own highlight is driven by `open` directly, so it
 * responds on click regardless of how long the menu takes to animate. */
function IntentSelect({ intent, onSelect, INTENTS }) {
  const [open, setOpen] = useState(false)
  const wrapRef = useRef(null)

  const { mounted: menuMounted, nodeRef: menuRef } = useEnterLeave(
    open,
    (node) => gsap.fromTo(node,
      { opacity: 0, y: -6, scaleY: 0.96 },
      { opacity: 1, y: 0, scaleY: 1, duration: 0.26, ease: 'power3.out', transformOrigin: 'top center' }),
    (node, done) => gsap.to(node,
      { opacity: 0, y: -6, scaleY: 0.96, duration: 0.16, ease: 'power2.in', transformOrigin: 'top center', onComplete: done }),
  )

  const requestClose = () => setOpen(false)
  const toggleTrigger = () => setOpen((o) => !o)

  useEffect(() => {
    if (!open) return
    const onClick = (e) => {
      if (wrapRef.current && !wrapRef.current.contains(e.target)) requestClose()
    }
    const onKey = (e) => { if (e.key === 'Escape') requestClose() }
    document.addEventListener('mousedown', onClick)
    document.addEventListener('keydown', onKey)
    return () => {
      document.removeEventListener('mousedown', onClick)
      document.removeEventListener('keydown', onKey)
    }
  }, [open])

  const current = intent ? INTENTS.find((i) => i.id === intent) : null

  // Preserve data order so the groups read Get started → Bring funds
  // in → Use it privately, matching the visitor's actual sequence.
  const groups = []
  for (const i of INTENTS) {
    let g = groups.find((x) => x.name === i.group)
    if (!g) { g = { name: i.group, items: [] }; groups.push(g) }
    g.items.push(i)
  }

  return (
    <div ref={wrapRef} className="bridge-route" data-open={open ? 'true' : 'false'}>
      <button
        type="button"
        className="bridge-route__trigger"
        aria-haspopup="listbox"
        aria-expanded={open}
        onClick={toggleTrigger}
      >
        <span className="bridge-route__label">
          {current ? `I want to ${current.label}` : 'I want to…'}
        </span>
        <span className="bridge-route__caret" aria-hidden="true">▾</span>
      </button>

      {menuMounted && (
        <div ref={menuRef} className="bridge-route__menu" role="listbox">
          {intent && (
            <button
              type="button"
              className="bridge-route__option bridge-route__option--reset"
              onClick={() => { onSelect(null); requestClose() }}
            >
              [ Show everything ]
            </button>
          )}
          {groups.map((g) => (
            <div key={g.name}>
              <div className="eco-intent__group">{g.name}</div>
              {g.items.map((i) => {
                const isOn = i.id === intent
                return (
                  <button
                    key={i.id}
                    type="button"
                    role="option"
                    aria-selected={isOn}
                    data-on={isOn ? 'true' : 'false'}
                    className="bridge-route__option"
                    onClick={() => { onSelect(isOn ? null : i.id); requestClose() }}
                  >
                    {i.label}
                  </button>
                )
              })}
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

function FilterBar({
  category, counts, categories, onSelectCategory, query, onQuery,
  intent, onSelectIntent, INTENTS,
}) {
  const pills = [{ id: 'all', label: 'All' }].concat(
    categories.map((c) => ({ id: c, label: categoryLabel(c) }))
  )
  return (
    <div className="eco-filters">
      {/* Pills and the intent dropdown share a row, but the intent
          control sits OUTSIDE the pills' role="group" — that group is
          labelled "Filter by category" and the intent phrases are not
          categories. */}
      <div className="eco-filters__left">
        <div className="eco-filters__pills" role="group" aria-label="Filter by category">
          {pills.map((p) => {
            const isOn = p.id === category
            return (
              <button
                key={p.id}
                type="button"
                className="eco-pill"
                data-on={isOn ? 'true' : 'false'}
                aria-pressed={isOn}
                onClick={() => onSelectCategory(p.id)}
              >
                {p.label}
                <span className="eco-pill__count">{counts[p.id] ?? 0}</span>
              </button>
            )
          })}
        </div>

        <IntentSelect intent={intent} onSelect={onSelectIntent} INTENTS={INTENTS} />
      </div>

      <div className="eco-filters__tools">
        <input
          type="search"
          className="eco-search"
          placeholder="Search"
          aria-label="Search the ecosystem"
          value={query}
          onChange={(e) => onQuery(e.target.value)}
        />
      </div>
    </div>
  )
}

/* Glyphs for the website / X destinations in the expanded row. Paths
 * are the site's existing `Icon.globe` and `Icon.x` from live-apps.jsx,
 * copied rather than referenced: that Icon set is dormant code with no
 * other caller, so depending on it would tie this file's rendering to
 * something a future cleanup may delete. Both are aria-hidden — the
 * anchor carries the accessible name. */
function GlyphGlobe() {
  return (
    <svg viewBox="0 0 16 16" width="14" height="14" fill="none"
         stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
      <circle cx="8" cy="8" r="6.5" />
      <ellipse cx="8" cy="8" rx="2.6" ry="6.5" />
      <line x1="1.5" y1="8" x2="14.5" y2="8" />
    </svg>
  )
}

function GlyphX() {
  return (
    <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
      <path d="M12.2 1.5h2.3l-5 5.7 5.9 7.8h-4.6L7.2 9.3l-4.1 5.7H.7l5.4-6.1L.5 1.5h4.7l3.2 4.3 3.8-4.3zm-.8 11.6h1.3L4.6 2.9H3.2l8.2 10.2z" />
    </svg>
  )
}

/* Platform install links, rendered in data order for entries that
 * carry an `install` block (the two wallets today).
 *
 * All four are shown rather than swapped by user-agent, which is what
 * the pre-directory wallet card did via a useIsMobile hook. Sniffing
 * hid the store links from anyone on a desktop, which is exactly when
 * someone wants to send the app to their phone — and it got the
 * answer wrong on tablets and desktop-mode mobile browsers. */
const INSTALL_TARGETS = [
  ['chrome', 'Chrome'],
  ['firefox', 'Firefox'],
  ['ios', 'iOS'],
  ['android', 'Android'],
]

/* Hoisted, NOT inlined at the call site. faulty-terminal.jsx lists
 * gridMul in its boot effect's dependency array, and a fresh `[2, 1]`
 * literal fails Object.is on every render — which tore down and
 * rebuilt the WebGL context on every keystroke in the search box.
 * That was visible, not just wasteful: the shader randomises its time
 * offset on boot, so the backdrop hard-cut to an unrelated frame each
 * time. A stable reference is half the fix; React.memo on the hero is
 * the other half. */
const ECO_GRID_MUL = [2, 1]

/* The expanded body: blurb plus the destinations. An entry whose deep
 * link and marketing site are the same URL renders one button, not
 * two identical ones. */
function ExpandedPanel({ entry }) {
  const hasApp = entry.url && entry.url !== entry.website
  return (
    <div className="eco-row__body">
      <p className="eco-row__blurb">{entry.blurb}</p>
      <div className="eco-row__links">
        <a
          className="eco-link eco-link--primary"
          href={entry.url}
          target="_blank"
          rel="noopener noreferrer"
        >
          Open app ↗
        </a>
        {hasApp && (
          <a
            className="eco-link eco-link--icon"
            href={entry.website}
            target="_blank"
            rel="noopener noreferrer"
            aria-label={`${entry.name} website`}
            title="Website"
          >
            <GlyphGlobe />
          </a>
        )}
        <a
          className="eco-link eco-link--icon"
          href={entry.twitter}
          target="_blank"
          rel="noopener noreferrer"
          aria-label={`${entry.name} on X`}
          title="X"
        >
          <GlyphX />
        </a>
      </div>

      {entry.install && (
        <div className="eco-row__install">
          <span className="eco-row__install-label">Install</span>
          {INSTALL_TARGETS.map(([key, label]) => (
            entry.install[key] ? (
              <a
                key={key}
                className="eco-link eco-link--sm"
                href={entry.install[key]}
                target="_blank"
                rel="noopener noreferrer"
                aria-label={`Install ${entry.name} for ${label}`}
              >
                {label}
              </a>
            ) : null
          ))}
        </div>
      )}
    </div>
  )
}

/* Trailing placeholder row. The pre-directory grid carried three of
 * these as cards, purely to fill its layout; a list has no gap to
 * fill, so one honest row does the same job — the point was always to
 * say the set is still growing.
 *
 * Shown only on the unfiltered view, matching the old rule: once
 * someone picks an intent or a category they are asking to see
 * specific apps, not aspirational ones. Inert — no expand, not
 * focusable, and aria-hidden so it isn't announced as an entry. */
function ComingSoonRow() {
  return (
    <div className="eco-row eco-row--soon" aria-hidden="true">
      <div className="eco-row__head eco-row__head--static">
        <span className="eco-row__logo eco-row__logo--mono eco-row__logo--soon">+</span>
        <span className="eco-row__name">More integrations</span>
        <span className="eco-row__tags">in progress across Starknet DeFi</span>
        <span className="eco-row__status" data-status="soon">SOON</span>
        {/* Mirrors the real row's caret cell. Needs the class, not a
            bare <span>: under the mobile grid-template-areas an
            unplaced child auto-places into an implicit third row and
            makes this row 27px taller than the ones above it. */}
        <span className="eco-row__caret" aria-hidden="true" />
      </div>
    </div>
  )
}

function EntryRow({ entry, isOpen, onToggle }) {
  const [logoFailed, setLogoFailed] = useState(false)

  /* The drawer animates its own height rather than the CSS
     grid-template-rows 0fr→1fr trick it used before. GSAP measures the
     auto height for us, which buys a real easing curve and — more
     usefully — an exit tween whose completion drives the unmount, so
     the panel's links leave the tab order exactly when they stop being
     visible. Same reason the intent menu unmounts. */
  const { mounted: panelMounted, nodeRef: drawerRef } = useEnterLeave(
    isOpen,
    (node) => gsap.fromTo(node,
      { height: 0, opacity: 0 },
      { height: 'auto', opacity: 1, duration: 0.34, ease: 'power3.out',
        onComplete: () => gsap.set(node, { height: 'auto' }) }),
    (node, done) => gsap.to(node,
      { height: 0, opacity: 0, duration: 0.22, ease: 'power2.in', onComplete: done }),
  )

  return (
    <div className="eco-row" data-open={isOpen ? 'true' : 'false'}>
      <button
        type="button"
        className="eco-row__head"
        aria-expanded={isOpen}
        onClick={() => onToggle(entry.id)}
      >
        {logoFailed ? (
          <span className="eco-row__logo eco-row__logo--mono" aria-hidden="true">
            {entry.name.charAt(0)}
          </span>
        ) : (
          <img
            className="eco-row__logo"
            src={entry.logo}
            alt=""
            aria-hidden="true"
            loading="lazy"
            onError={() => setLogoFailed(true)}
          />
        )}
        <span className="eco-row__name">{entry.name}</span>
        <span className="eco-row__tags">
          {categoryLabel(entry.category).toLowerCase()} · {entry.subtags.join(', ')}
        </span>
        <span className="eco-row__status" data-status={entry.status}>
          {entry.status === 'live' ? 'LIVE' : 'SOON'}
        </span>
        <span className="eco-row__caret" aria-hidden="true">▾</span>
      </button>
      {panelMounted && (
        <div ref={drawerRef} className="eco-row__drawer">
          <ExpandedPanel entry={entry} />
        </div>
      )}
    </div>
  )
}

/* Page hero. Typography is lifted from the /build hero (build-page.jsx
 * BP_H + its subline) so this surface doesn't invent a second heading
 * style: display face, 800, uppercase, -0.025em, and the same warm
 * gradient text-fill. Only the size differs — "Privacy" is one short
 * word where /build sets a full sentence, so it can run larger.
 *
 * The subline is deliberately lowercase and grammatically continues
 * the heading: "Privacy / for existing users, liquidity, and real
 * activity." reads as one sentence broken across two type sizes. */
function EcosystemHero() {
  const ref = useRef(null)

  useLayoutEffect(() => {
    const node = ref.current
    if (!node || !motionOn()) return
    const parts = node.querySelectorAll('[data-hero-part]')
    const tween = gsap.fromTo(parts,
      { opacity: 0, y: 16 },
      { opacity: 1, y: 0, duration: 0.65, ease: 'power3.out', stagger: 0.09,
        clearProps: 'opacity,transform' })
    return () => tween.kill()
  }, [])

  /* Same WebGL backdrop as the /build hero, with the same props — a
     strk20-orange-tinted terminal grid under a darkening gradient that
     keeps the headline legible. Two guards:
       · FaultyTerminal is a global from faulty-terminal.jsx; if that
         script is ever dropped from app.html the hero still renders,
         just flat.
       · It animates continuously, so it is skipped entirely under
         prefers-reduced-motion rather than merely slowed. */
  const backdrop = typeof FaultyTerminal !== 'undefined' && !prefersReducedMotion()

  return (
    <header className="eco-hero" ref={ref}>
      {backdrop && (
        <FaultyTerminal
          style={{ position: 'absolute', inset: 0, zIndex: 0, opacity: 0.55 }}
          tint="#c53400"
          scale={1.6}
          gridMul={ECO_GRID_MUL}
          digitSize={1.3}
          timeScale={0.5}
          scanlineIntensity={0.6}
          glitchAmount={1}
          flickerAmount={0.6}
          noiseAmp={1}
          curvature={0.1}
          mouseReact={true}
          mouseStrength={0.35}
          pageLoadAnimation={true}
          brightness={1}
        />
      )}
      <div className="eco-hero__veil" aria-hidden="true" />
      <div className="eco-hero__inner">
        <h1 className="eco-hero__title" data-hero-part>Privacy</h1>
        <p className="eco-hero__sub" data-hero-part>
          for existing users, liquidity, and real activity.
        </p>
      </div>
    </header>
  )
}

/* Memoised because it takes no props, so the comparison is exact and
 * always true. Without it, every filter keystroke in Ecosystem
 * re-renders the hero and remounts the shader beneath it. */
const EcosystemHeroMemo = React.memo(EcosystemHero)

function Ecosystem() {
  const [category, setCategory] = useState('all')
  const [openId, setOpenId] = useState(null)
  const [query, setQuery] = useState('')
  const [intent, setIntent] = useState(null)

  /* The data file ships the full catalogue and names which categories
   * are currently withheld. Everything below this point sees only the
   * visible slice, so a hidden category costs no other code: its pill
   * never appears (categories are derived), its rows never render, and
   * its counts never exist.
   *
   * An intent survives only if at least one entry it points to is
   * visible — otherwise "bring BTC to Starknet" would sit in the
   * dropdown resolving to nothing. Dropping the last intent of a group
   * drops the group heading with it, since headings are derived too. */
  const { ENTRIES, INTENTS } = useMemo(() => {
    const { ENTRIES: all, INTENTS: allIntents, HIDDEN_CATEGORIES = [] } = window.ECOSYSTEM
    const hidden = new Set(HIDDEN_CATEGORIES)
    const entries = all.filter((e) => !hidden.has(e.category))
    const visibleIds = new Set(entries.map((e) => e.id))
    return {
      ENTRIES: entries,
      INTENTS: allIntents.filter((i) => i.entryIds.some((id) => visibleIds.has(id))),
    }
  }, [])

  // Derived, not hardcoded: adding an entry with a new category makes
  // its pill appear with no code change.
  const categories = useMemo(
    () => [...new Set(ENTRIES.map((e) => e.category))],
    [ENTRIES]
  )

  const counts = useMemo(() => {
    const c = { all: ENTRIES.length }
    for (const e of ENTRIES) c[e.category] = (c[e.category] || 0) + 1
    return c
  }, [ENTRIES])

  const visible = useMemo(() => {
    const intentIds = intent
      ? new Set((INTENTS.find((i) => i.id === intent) || { entryIds: [] }).entryIds)
      : null
    return ENTRIES.filter((e) => {
      if (intentIds && !intentIds.has(e.id)) return false
      if (category !== 'all' && e.category !== category) return false
      return matchesQuery(e, query)
    })
  }, [ENTRIES, INTENTS, intent, category, query])

  const toggle = (id) => setOpenId((cur) => (cur === id ? null : id))

  const clearAll = () => {
    setIntent(null); setCategory('all'); setQuery(''); setOpenId(null)
  }

  /* Filtering used to swap the list contents instantly, which reads as
     a flicker — the rows are the same shape, so without motion there is
     nothing to tell you the set changed. A short staggered rise gives
     the change a direction.

     Keyed on the resulting ids rather than on the filter inputs, so
     typing a query that doesn't change the result set (say "a" → "av",
     both matching only AVNU) doesn't re-run the animation on every
     keystroke. */
  const listRef = useRef(null)
  const visibleKey = visible.map((e) => e.id).join(',')
  useLayoutEffect(() => {
    const node = listRef.current
    if (!node || !motionOn()) return
    const rows = node.querySelectorAll('.eco-row')
    if (!rows.length) return
    const tween = gsap.fromTo(rows,
      { opacity: 0, y: 8 },
      {
        opacity: 1, y: 0, duration: 0.3, ease: 'power2.out',
        stagger: { each: 0.025, from: 'start' },
        // Rows carry no inline styles of their own; clearing avoids
        // leaving a stale transform on an element the user then opens.
        clearProps: 'opacity,transform',
      })
    return () => tween.kill()
  }, [visibleKey])

  /* Intent and category are two routes to the same destination, so
   * they are mutually exclusive. Without this, intent="swap tokens
   * privately" + category="wallet" is a guaranteed empty list
   * reachable in two clicks. Search stacks on top of whichever one is
   * active. */
  const selectIntent = (id) => { setIntent(id); setCategory('all') }
  const selectCategory = (c) => { setCategory(c); setIntent(null) }

  return (
    <section className="eco">
      <EcosystemHeroMemo />
      <FilterBar
        category={category}
        counts={counts}
        categories={categories}
        onSelectCategory={selectCategory}
        query={query}
        onQuery={setQuery}
        intent={intent}
        onSelectIntent={selectIntent}
        INTENTS={INTENTS}
      />

      {visible.length === 0 ? (
        <div className="eco-empty">
          <p className="eco-empty__text">
            Nothing matches {activeFilterSummary(intent, category, query, INTENTS)}.
          </p>
          <button type="button" className="eco-link" onClick={clearAll}>
            Clear filters
          </button>
        </div>
      ) : (
        <div className="eco-list" ref={listRef}>
          {visible.map((e) => (
            <EntryRow key={e.id} entry={e} isOpen={openId === e.id} onToggle={toggle} />
          ))}
          {!intent && category === 'all' && !query.trim() && <ComingSoonRow />}
        </div>
      )}

      <style>{`
        .eco { margin-top: clamp(28px,4vh,48px); }

        /* The backdrop is absolutely positioned inside this box, so the
           hero owns a stacking context and clips the shader. It bleeds
           past .wrap's gutter to full viewport width — a boxed-in
           backdrop reads as a panel rather than a page header — while
           the text stays within the gutter via __inner. */
        .eco-hero {
          position: relative;
          isolation: isolate;
          overflow: hidden;
          text-align: center;
          padding: clamp(64px,15vh,184px) 0 clamp(56px,12vh,148px);
          margin-inline: calc(50% - 50vw);
          padding-inline: max(var(--gut, 24px), calc(50vw - 620px));
        }
        /* Darkens the shader under the headline. Two layers, matching
           the /build hero: an orange bloom at the centre, then a
           vignette out to the page background so the hero dissolves
           into the directory below instead of ending on a hard edge. */
        .eco-hero__veil {
          position: absolute; inset: 0; z-index: 1; pointer-events: none;
          background:
            radial-gradient(ellipse 75% 60% at 50% 50%, rgba(197,52,0,0.15), transparent 60%),
            radial-gradient(ellipse at 50% 50%, rgba(13,13,13,0.62) 22%, var(--bg) 86%);
        }
        .eco-hero__inner { position: relative; z-index: 2; }
        /* Face, weight and gradient come from build-page.jsx's BP_H.
           Size sits deliberately above base.org/ecosystem's 56px: this
           display face is condensed and uppercase where Base sets a
           lowercase humanist sans, so it carries less optical weight at
           the same pixel height. 92px here reads about like their 56px
           does. Still well under the 148px first attempt, which
           overpowered the directory beneath it. */
        .eco-hero__title {
          margin: 0;
          font-family: var(--display);
          font-weight: 800;
          text-transform: uppercase;
          letter-spacing: -0.02em;
          line-height: 1.04;
          font-size: clamp(44px, 6.5vw, 92px);
          background: linear-gradient(178deg, #fffdf1 0%, #f4ece8 55%, #ffcdb6 100%);
          -webkit-background-clip: text;
          background-clip: text;
          -webkit-text-fill-color: transparent;
          color: transparent;
        }
        .eco-hero__sub {
          margin: clamp(12px,1.6vh,18px) auto 0;
          max-width: 52ch;
          font-family: var(--body);
          font-size: clamp(14px, 1.1vw, 18px);
          line-height: 1.55;
          color: var(--dim);
        }

        .eco-intent__group {
          padding: 10px 14px 5px;
          font-family: var(--mono); font-size: 9.5px;
          letter-spacing: 0.16em; text-transform: uppercase;
          color: rgba(255,255,255,0.34);
          pointer-events: none;
        }

        /* bridge-route__* — recovered verbatim from commit 3d9fadd,
         * where these rules lived in LiveApps' style block before
         * Task 2 replaced that component's body. Reused here as-is
         * (styling-only, not bridge-specific); Task 7 deleted the
         * old BridgeRouteSelect/AppRouteSelect JSX from live-apps.jsx,
         * so IntentSelect below is the only consumer of these classes
         * anywhere in the codebase.
         * margin-bottom: 16px is this file's own addition, merged
         * into the same rule rather than left as a second competing
         * .bridge-route block. */
        /* Sits inline in the filter row, immediately right of the last
           category pill, and is styled to read as that pill's peer —
           same height, padding, font, border and radius (see
           .bridge-route__trigger below). Width follows content rather
           than the 600px floor it carried as a standalone full-width
           bar, which would wrap the row. */
        .bridge-route {
          position: relative;
          display: inline-block;
          max-width: 100%;
        }
        /* Metrics deliberately copied from .eco-pill so the control
           reads as a sibling of the category buttons rather than a
           second, heavier bar. Only the label's max-width is its own:
           a selected intent ("I want to trade without moving the
           market") is far longer than any pill label, so it ellipsizes
           instead of stretching the row. */
        .bridge-route__trigger {
          width: 100%;
          display: inline-flex;
          align-items: center;
          justify-content: space-between;
          gap: 8px;
          font-family: var(--mono);
          font-size: 11.5px;
          letter-spacing: 0.08em;
          text-transform: uppercase;
          color: var(--text);
          background: rgba(255,255,255,0.03);
          border: 1px solid var(--line);
          border-radius: 3px;
          padding: 8px 14px;
          cursor: pointer;
          transition:
            border-color .22s cubic-bezier(.2,.85,.25,1),
            color .22s cubic-bezier(.2,.85,.25,1),
            background .22s cubic-bezier(.2,.85,.25,1),
            box-shadow .22s ease-out;
        }
        .bridge-route__trigger:hover {
          border-color: var(--orange);
          color: var(--orange);
        }
        .bridge-route[data-open="true"] .bridge-route__trigger {
          border-color: var(--orange);
          color: var(--orange);
          box-shadow: 0 0 0 1px var(--orange) inset;
        }
        .bridge-route__label {
          overflow: hidden;
          white-space: nowrap;
          text-overflow: ellipsis;
          max-width: 30ch;
        }
        /* 12px, not 16 — at 16 the caret's line box is taller than the
           label's and the control ends up 2px taller than the category
           pills it sits beside. */
        .bridge-route__caret {
          font-size: 12px;
          line-height: 1;
          transition: transform .24s cubic-bezier(.2,.85,.25,1);
        }
        .bridge-route[data-open="true"] .bridge-route__caret {
          transform: rotate(180deg);
        }
        .bridge-route__menu {
          position: absolute;
          top: calc(100% + 8px);
          left: 0;
          min-width: 100%;
          width: max-content;
          max-width: min(640px, 92vw);
          z-index: 5;
          display: flex;
          flex-direction: column;
          padding: 8px;
          background: var(--bg);
          border: 1px solid var(--line);
          border-top: 2px solid var(--orange);
          border-radius: 4px;
          box-shadow: 0 18px 40px -12px rgba(0,0,0,0.55);
          /* No enter/leave transition here — GSAP owns the menu's
             opacity and transform (see IntentSelect). A CSS transition
             on the same properties would fight the tween. The menu is
             only in the DOM while open or animating out, so it needs
             no hidden state. */
        }
        .bridge-route__option {
          text-align: left;
          white-space: nowrap;
          font-family: var(--mono);
          font-size: 14px;
          letter-spacing: 0.14em;
          text-transform: uppercase;
          color: var(--text);
          background: transparent;
          border: none;
          border-radius: 3px;
          padding: 14px 18px;
          cursor: pointer;
          transition:
            background .18s ease-out,
            color .18s ease-out;
        }
        .bridge-route__option:hover {
          background: rgba(197,52,0,0.10);
          color: var(--orange);
        }
        .bridge-route__option[data-on="true"] {
          color: var(--orange);
          background: rgba(197,52,0,0.08);
        }
        .bridge-route__option--reset {
          color: var(--dim);
          font-size: 12px;
          letter-spacing: 0.18em;
          text-transform: uppercase;
          border-bottom: 1px dashed var(--line);
          border-radius: 0;
          margin-bottom: 6px;
          padding: 12px 18px;
        }
        .bridge-route__option--reset:hover {
          color: var(--green);
          background: transparent;
        }

        .eco-filters {
          display: flex; flex-wrap: wrap; gap: 12px;
          align-items: center; justify-content: space-between;
          margin-bottom: 20px;
        }
        .eco-filters__pills { display: flex; flex-wrap: wrap; gap: 8px; }
        .eco-filters__left {
          display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
          min-width: 0;
        }
        .eco-filters__tools { display: flex; align-items: center; gap: 12px; }


        .eco-search {
          padding: 8px 12px; min-width: 160px;
          font-family: var(--mono); font-size: 12px;
          color: var(--text);
          background: rgba(255,255,255,0.03);
          border: 1px solid var(--line);
          border-radius: 3px;
        }
        .eco-search:focus {
          outline: none; border-color: var(--orange);
        }
        .eco-search::placeholder { color: rgba(255,255,255,0.32); }
        .eco-pill {
          display: inline-flex; align-items: center; gap: 8px;
          padding: 8px 14px;
          font-family: var(--mono); font-size: 11.5px;
          letter-spacing: 0.08em; text-transform: uppercase;
          color: var(--text);
          background: rgba(255,255,255,0.03);
          border: 1px solid var(--line);
          border-radius: 3px;
          cursor: pointer;
          transition: border-color .18s ease, background .18s ease, color .18s ease;
        }
        .eco-pill:hover { border-color: rgba(255,255,255,0.28); }
        .eco-pill[data-on="true"] {
          color: var(--orange);
          border-color: var(--orange);
          background: rgba(197,52,0,0.10);
        }
        .eco-pill__count {
          font-size: 10.5px;
          opacity: 0.55;
          pointer-events: none;
        }

        .eco-list {
          border: 1px solid var(--line);
          border-radius: 5px;
          border-top: 2px solid var(--orange);
          overflow: hidden;
        }
        .eco-row + .eco-row { border-top: 1px solid var(--line); }

        .eco-empty {
          padding: 44px 18px;
          text-align: center;
          border: 1px solid var(--line);
          border-top: 2px solid var(--orange);
          border-radius: 5px;
        }
        .eco-empty__text {
          margin: 0 0 16px;
          font-size: 14px; color: rgba(255,255,255,0.6);
        }
        .eco-empty .eco-link { background: none; cursor: pointer; }

        .eco-row__head {
          display: grid;
          grid-template-columns: 28px minmax(120px, 1fr) minmax(0, 2fr) auto 16px;
          align-items: center; gap: 14px;
          width: 100%;
          padding: 14px 18px;
          background: none; border: 0;
          text-align: left; cursor: pointer;
          transition: background .18s ease;
        }
        .eco-row__head:hover { background: rgba(255,255,255,0.035); }

        .eco-row__logo {
          width: 28px; height: 28px;
          border-radius: 50%; object-fit: cover;
          background: rgba(255,255,255,0.06);
        }
        .eco-row__logo--mono {
          display: inline-flex; align-items: center; justify-content: center;
          font-family: var(--display); font-weight: 800; font-size: 13px;
          color: rgba(255,255,255,0.65);
          background: rgba(255,255,255,0.08);
        }
        .eco-row__name {
          font-family: var(--display); font-weight: 800;
          font-size: 14px; letter-spacing: -0.01em;
          text-transform: uppercase; color: var(--text);
        }
        .eco-row__tags {
          font-family: var(--mono); font-size: 11.5px;
          letter-spacing: 0.04em; color: rgba(255,255,255,0.5);
          overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
        }
        .eco-row__status {
          font-family: var(--mono); font-size: 10px;
          letter-spacing: 0.12em;
          padding: 3px 8px; border-radius: 999px;
          border: 1px solid currentColor;
        }
        .eco-row__status[data-status="live"] { color: #4ade80; }
        .eco-row__status[data-status="soon"] { color: rgba(255,255,255,0.42); }

        .eco-row__caret {
          font-size: 11px; color: rgba(255,255,255,0.45);
          transition: transform .22s ease;
        }
        .eco-row[data-open="true"] .eco-row__caret { transform: rotate(180deg); }

        /* GSAP owns this element's height and opacity (see EntryRow).
           overflow:hidden is what makes a height tween read as a wipe
           rather than a squash of the content inside. */
        .eco-row__drawer { overflow: hidden; }

        .eco-row__body { padding: 0 18px 18px 60px; }
        .eco-row__blurb {
          margin: 0 0 14px;
          font-size: 14px; line-height: 1.55;
          color: rgba(255,255,255,0.72);
          max-width: 62ch;
        }
        .eco-row__links { display: flex; flex-wrap: wrap; gap: 8px; }

        .eco-link {
          display: inline-flex; align-items: center;
          padding: 8px 14px;
          font-family: var(--mono); font-size: 11px;
          letter-spacing: 0.08em; text-transform: uppercase;
          color: var(--text); text-decoration: none;
          border: 1px solid var(--line); border-radius: 3px;
          transition: border-color .18s ease, background .18s ease;
        }
        .eco-link:hover { border-color: rgba(255,255,255,0.32); }
        /* Icon-only destinations: square target, no letter-spacing
           padding meant for uppercase text. */
        .eco-link--icon {
          padding: 0;
          width: 34px; height: 34px;
          justify-content: center;
          color: rgba(255,255,255,0.62);
        }
        .eco-link--icon:hover { color: var(--text); }
        .eco-link--sm {
          padding: 6px 11px;
          font-size: 10.5px;
          color: rgba(255,255,255,0.68);
        }
        .eco-link--sm:hover { color: var(--text); }

        .eco-row__install {
          display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
          margin-top: 12px;
        }
        .eco-row__install-label {
          font-family: var(--mono); font-size: 10px;
          letter-spacing: 0.16em; text-transform: uppercase;
          color: rgba(255,255,255,0.36);
          margin-right: 2px;
        }

        /* Placeholder row: same skeleton as a real one so the list
           rhythm holds, dimmed and inert so it never reads as an entry
           you can open. */
        .eco-row--soon { opacity: 0.5; }
        .eco-row__head--static {
          display: grid;
          grid-template-columns: 28px minmax(120px, 1fr) minmax(0, 2fr) auto 16px;
          align-items: center; gap: 14px;
          width: 100%;
          padding: 14px 18px;
          cursor: default;
        }
        .eco-row__logo--soon {
          font-family: var(--mono); font-weight: 400; font-size: 15px;
          color: rgba(255,255,255,0.4);
        }
        @media (max-width: 720px) {
          .eco-row__head--static {
            grid-template-columns: 28px 1fr auto;
            grid-template-areas:
              "logo name   status"
              "tags tags   tags";
            row-gap: 6px;
          }
        }
        .eco-link--primary {
          color: var(--orange);
          border-color: var(--orange);
          background: rgba(197,52,0,0.10);
        }

        @media (max-width: 720px) {
          .eco-row__head {
            grid-template-columns: 28px 1fr auto;
            grid-template-areas:
              "logo name   status"
              "tags tags   tags";
            row-gap: 6px;
          }
          .eco-row__logo   { grid-area: logo; }
          .eco-row__name   { grid-area: name; }
          .eco-row__status { grid-area: status; }
          .eco-row__tags   { grid-area: tags; white-space: normal; }
          .eco-row__caret  { display: none; }
        }

        @media (max-width: 720px) {
          .eco-row__body { padding-left: 18px; }
        }

        /* ── Touch sizing ────────────────────────────────────────────
           Every interactive control on this surface was sized for a
           mouse: 32px tall pills, trigger and search, and 31-36px
           links in the expanded row. Apple's HIG floor is 44px and
           Material's is 48; below that, thumbs mis-hit. Measured at
           375px before this block: pill 77x32, trigger 127x32, search
           180x32, Open app 107x36, icon links 34x34, install links
           ~70x31 — all under.

           Height only. Widths already wrap cleanly onto three rows at
           375px, and forcing wider controls would push the filter row
           to four. Desktop keeps its tighter 32px rhythm. */
        @media (max-width: 720px) {
          .eco-pill,
          .bridge-route__trigger,
          .eco-search {
            min-height: 44px;
          }
          .eco-link { min-height: 44px; }
          .eco-link--icon { width: 44px; height: 44px; }
          .eco-link--sm { min-height: 44px; padding-inline: 14px; }

          /* Labels sized for a desktop reading distance are genuinely
             hard to read on a phone. Nudged over the 12px floor
             without disturbing the type hierarchy. */
          .eco-row__status      { font-size: 11.5px; }
          .eco-pill__count      { font-size: 11.5px; }
          .eco-row__tags        { font-size: 12.5px; }
          .eco-row__install-label { font-size: 11.5px; }
          .eco-intent__group    { font-size: 11px; }
        }

        @media (prefers-reduced-motion: reduce) {
          /* GSAP tweens are skipped entirely via motionOn(); these
             cover the transitions CSS still owns. */
          .eco-row__caret  { transition: none; }
          .eco-pill        { transition: none; }
          .eco-link        { transition: none; }
          .eco-row__head   { transition: none; }
        }
      `}</style>
    </section>
  )
}

// No export — loaded as a script tag, Ecosystem becomes a window
// global referenced by live-apps.jsx.
