/* ============================================================
   Shared content + primitives across all 3 variations.
   ============================================================ */

// --------- CONTENT (single source of truth) ---------
const HJ = {
  brand: "HJ기획",
  tag: "Doctor Traffic",
  hero: {
    eyebrow: "현직 한의사가 운영하는 의료 마케팅",
    headline: "검색을 넘어,\nAI가 추천하는 병원으로.",
    sub: "의료광고법을 이해하는 한의사가 직접 설계하고, AI 검색 시대에 맞춰 노출되는 컨텐츠를 만듭니다.",
    cta1: "카카오톡 상담",
    cta2: "상품 살펴보기",
    micro: ["의료광고법 이중검수", "AEO + SEO 동시 대응", "1인 직접 응대"],
  },
  diff: [
    { n: "01", t: "현직 한의사 직접 운영", d: "에이전시가 아닌 의료 현장의 언어를 아는 한의사가 처음부터 끝까지 책임집니다", k: "doctor" },
    { n: "02", t: "의료광고법 이중검수", d: "1차 카피 작성 시점 + 발행 직전, 두 번 검수해 행정 리스크를 차단합니다", k: "shield" },
    { n: "03", t: "AI 추천 시대 대응", d: "ChatGPT·Perplexity가 인용하는 구조로 컨텐츠를 설계합니다", k: "ai" },
  ],
  products: [
    { name: "홈페이지형 블로그", price: "25만원~", tag: "Starter", desc: "검색 노출에 최적화된 블로그 채널을 빠르게 셋업합니다.", points: ["키워드 리서치", "월 4~8건 발행", "내부 링크 설계"] },
    { name: "템플릿형 홈페이지", price: "40만원~", tag: "Lite", desc: "검증된 템플릿으로 짧은 시간 내에 신뢰감 있는 사이트를 오픈합니다.", points: ["반응형 5페이지", "기본 SEO", "1주 배포"] },
    { name: "기본형 홈페이지", price: "150만원~", tag: "Standard", desc: "진료과목과 브랜드에 맞춘 구조 설계까지 포함된 정공법 사이트입니다.", points: ["정보구조 설계", "AEO 컨텐츠 5건", "광고법 검수"] },
    { name: "맞춤형 홈페이지", price: "400만원~", tag: "Premium", desc: "브랜드, 진료, 환자 여정까지 1:1 맞춤으로 설계하는 풀패키지.", points: ["풀 커스텀 디자인", "AEO/SEO 통합", "운영 컨설팅 3개월"] },
  ],
  compare: {
    rows: [
      { axis: "운영 주체", a: "외주·인턴 작가", b: "현직 한의사 직접" },
      { axis: "의료광고법 이해", a: "체크리스트 수준", b: "임상 맥락까지 이해, 이중검수" },
      { axis: "컨텐츠 전략", a: "검색 키워드 채우기", b: "검색(SEO) + AI 추천(AEO) 동시 설계" },
      { axis: "AI 시대 대응", a: "기존 SEO 그대로", b: "ChatGPT·Perplexity 인용 구조" },
      { axis: "응대 방식", a: "담당자 변경 잦음", b: "1인 사업자 직접 응대" },
      { axis: "리스크 관리", a: "발행 후 사후 대응", b: "발행 전 광고법 이중검수" },
    ],
  },
  faq: [
    { q: "정말 한의사 본인이 직접 작업하나요?", a: "네. 기획·카피·광고법 검수까지 직접 진행합니다. 일부 디자인 외 모든 작업은 1인이 책임집니다." },
    { q: "AEO가 정확히 무엇인가요?", a: "Answer Engine Optimization. ChatGPT·Perplexity 같은 AI가 답변을 생성할 때 인용·추천될 수 있도록 컨텐츠를 구조화하는 작업입니다." },
    { q: "기존 홈페이지가 있어도 의뢰할 수 있나요?", a: "가능합니다. 진단 후 부분 리뉴얼·블로그 단독 진행·전면 이전 중 가장 적합한 방안을 제안드립니다." },
    { q: "계약 기간은 어떻게 되나요?", a: "홈페이지 구축은 단건, 컨텐츠 운영은 월 단위입니다. 최소 약정은 없으며 효과를 보고 결정하실 수 있습니다." },
    { q: "광고법 위반 리스크는 누가 책임지나요?", a: "발행 전 이중검수를 거치며, 발견되는 모든 표현 리스크는 사전 컨설팅으로 안내드립니다." },
  ],

  // ============================================================
  // 2섹션 — AI 추천 작동 원리
  // ============================================================
  aiCriteria: {
    title: "AI 추천은 어떻게 이루어질까요?",
    sub: "AI는 무작위로 병원을 추천하지 않습니다.\n인용 가능한 신뢰 지표를 가진 컨텐츠만 답변에 등장합니다.",
    items: [
      { n: "01", t: "공식 홈페이지의 유무", d: "병원이 직접 운영하는 도메인은 가장 강력한 신뢰 신호입니다. 외부 채널만 있는 병원은 AI 답변에 거의 등장하지 않습니다.", k: "shield" },
      { n: "02", t: "다채널 일관성", d: "홈페이지·블로그·SNS·유튜브에서 같은 메시지가 반복될수록 AI는 신뢰합니다.", k: "search" },
      { n: "03", t: "전문성·신뢰성 신호", d: "면허번호, 학회 활동, 다년간의 누적 컨텐츠가 E-E-A-T 점수로 작동합니다.", k: "doctor" },
      { n: "04", t: "출처가 명확한 1차 정보", d: "원장이 직접 작성한 임상 경험·진료 철학·환자 케이스가 인용 가치를 만듭니다.", k: "ai" },
    ],
  },

  // ============================================================
  // 3섹션 — 2025 의료광고법 개정
  // ============================================================
  lawUpdate: {
    badge: "왜 홈페이지가 필요한가",
    title: "4개 채널이 함께 작동할 때\nAI에 반영이 더 잘됩니다",
    groups: [
      {
        status: "open",
        label: "심의 면제",
        headline: "직접 노출 가능",
        channels: [{ name: "홈페이지", k: "globe" }],
      },
      {
        status: "blocked",
        label: "사전심의 필수",
        headline: "직접 노출 불가",
        channels: [
          { name: "블로그", k: "search" },
          { name: "SNS", k: "chat" },
          { name: "유튜브", k: "play" },
        ],
      },
    ],
    laws: [
      { code: "의료법 시행령", article: "제24조 제2항", shortDesc: "사전심의 대상 매체 — 인터넷 매체의 의료광고는 사전심의가 필수입니다." },
      { code: "의료법", article: "제57조 제1항", shortDesc: "의료광고 사전심의 — 의료기관·시술 정보의 직접 게시는 제한됩니다." },
    ],
    conclusion: {
      title: "외부 채널로 홈페이지에 트래픽을 보내야 합니다",
      sub: "사전심의가 필수인 채널은 홈페이지로 향하는 통로입니다.",
    },
  },

  // ============================================================
  // 4섹션 — 3채널 유입 전략
  // ============================================================
  channelStrategy: {
    title: "홈페이지를 허브로\n3개 채널이 환자를 데려옵니다",
    sub: "각 채널의 사용자 행동이 다르기 때문에, 한 채널만으로는 충분하지 않습니다.\n세 채널이 동시에 작동할 때 유입이 안정적으로 누적됩니다.",
    channels: [
      {
        name: "SNS",
        platform: "Instagram",
        role: "발견과 첫인상",
        desc: "카드뉴스·릴스로 잠재 환자에게 노출되어 병원 인지도와 친근감을 만듭니다.",
        formats: ["카드뉴스", "릴스(Reels)", "스토리"],
        k: "chat",
      },
      {
        name: "Blog",
        platform: "Naver · Blogger",
        role: "검색 유입의 주력",
        desc: "증상·치료법을 검색하는 사용자를 홈페이지로 자연스럽게 안내합니다.",
        formats: ["네이버 블로그", "구글 Blogger", "키워드 컨텐츠"],
        k: "search",
      },
      {
        name: "YouTube",
        platform: "Shorts",
        role: "신뢰의 증폭",
        desc: "원장이 직접 출연한 숏츠는 가장 강력한 신뢰 신호로 작용합니다.",
        formats: ["원장 인터뷰 숏츠", "시술 설명 숏츠", "Q&A"],
        k: "ai",
      },
    ],
  },

  // ============================================================
  // 5섹션 — 상품 / 가격
  // ============================================================
  pricingV2: {
    title: "상품 안내",
    sub: "마케팅 부문과 홈페이지 부문, 필요한 만큼만 선택하실 수 있습니다.",
    marketing: {
      title: "마케팅",
      sub: "채널별 단건 / 패키지 — 월 단위 운영",
      items: [
        {
          name: "SNS",
          channel: "Instagram",
          desc: "카드뉴스 · 릴스 제작",
          rows: [
            { label: "카드뉴스 (5장)", price: "8만원" },
            { label: "릴스 1편", price: "15만원" },
          ],
        },
        {
          name: "Blog",
          channel: "Naver · Blogger",
          desc: "네이버 블로그 · Blogger 포스팅",
          rows: [
            { label: "네이버 블로그 1건", price: "10만원" },
            { label: "Blogger 1건 (영문 SEO)", price: "12만원" },
          ],
        },
        {
          name: "YouTube",
          channel: "Shorts",
          desc: "숏츠 영상 제작",
          rows: [
            { label: "숏츠 1편 (편집 포함)", price: "20만원" },
          ],
        },
      ],
      package: {
        name: "통합 패키지",
        sub: "월 단위 — SNS + Blog + YouTube 동시 운영",
        rows: [
          { label: "SNS 카드뉴스 4건 + 릴스 2편", note: "Instagram" },
          { label: "네이버 블로그 4건 + Blogger 2건", note: "Blog" },
          { label: "유튜브 숏츠 2편", note: "YouTube" },
        ],
        price: "월 130만원",
        save: "단건 대비 약 25% 절감",
      },
    },
    homepage: {
      title: "홈페이지",
      sub: "단건 — 의료광고법 검수 포함",
      items: [
        { name: "홈페이지형 블로그", price: "25만원~", tag: "Starter", desc: "검색 노출에 최적화된 블로그 채널을 빠르게 셋업합니다.", points: ["키워드 리서치", "월 4~8건 발행", "내부 링크 설계"] },
        { name: "템플릿형 홈페이지", price: "40만원~", tag: "Lite", desc: "검증된 템플릿으로 짧은 시간 내에 신뢰감 있는 사이트를 오픈합니다.", points: ["반응형 5페이지", "기본 SEO", "1주 배포"] },
        { name: "기본형 홈페이지", price: "150만원~", tag: "Standard", desc: "진료과목과 브랜드에 맞춘 구조 설계까지 포함된 정공법 사이트입니다.", points: ["정보구조 설계", "AEO 컨텐츠 5건", "광고법 검수"] },
        { name: "맞춤형 홈페이지", price: "400만원~", tag: "Premium", desc: "브랜드, 진료, 환자 여정까지 1:1 맞춤으로 설계하는 풀패키지.", points: ["풀 커스텀 디자인", "AEO/SEO 통합", "운영 컨설팅 3개월"] },
      ],
    },
  },
};

// --------- ICONS (line / 1.5px) ---------
const Icon = ({ kind, size = 20, color = "currentColor", strokeWidth = 1.5 }) => {
  const props = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: color, strokeWidth, strokeLinecap: "round", strokeLinejoin: "round" };
  switch (kind) {
    case "doctor": return (
      <svg {...props}><path d="M8 4v4a4 4 0 0 0 8 0V4" /><path d="M12 12v3a5 5 0 0 0 10 0v-1" /><circle cx="22" cy="14" r="2" /><path d="M6 4h4M14 4h4" /></svg>
    );
    case "shield": return (
      <svg {...props}><path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" /><path d="m9 12 2 2 4-4" /></svg>
    );
    case "ai": return (
      <svg {...props}><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" /><circle cx="12" cy="12" r="3" /></svg>
    );
    case "search": return (
      <svg {...props}><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
    );
    case "chat": return (
      <svg {...props}><path d="M21 12a8 8 0 0 1-11.5 7.2L4 21l1.8-5.5A8 8 0 1 1 21 12Z" /></svg>
    );
    case "check": return (
      <svg {...props}><path d="m5 12 4 4 10-10" /></svg>
    );
    case "x": return (
      <svg {...props}><path d="M6 6l12 12M18 6L6 18" /></svg>
    );
    case "arrow": return (
      <svg {...props}><path d="M5 12h14M13 6l6 6-6 6" /></svg>
    );
    case "plus": return (
      <svg {...props}><path d="M12 5v14M5 12h14" /></svg>
    );
    case "minus": return (
      <svg {...props}><path d="M5 12h14" /></svg>
    );
    case "kakao": return (
      <svg width={size} height={size} viewBox="0 0 24 24" fill={color}><path d="M12 3C6.5 3 2 6.6 2 11c0 2.8 1.9 5.3 4.7 6.7-.2.7-.7 2.7-.8 3.1-.1.5.2.5.4.4.2-.1 2.6-1.7 3.6-2.4.7.1 1.4.2 2.1.2 5.5 0 10-3.6 10-8s-4.5-8-10-8Z"/></svg>
    );
    case "globe": return (
      <svg {...props}><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></svg>
    );
    case "naver": return (
      <svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
        <path d="M14.06 12.55 9.83 6H6v12h3.94V11.45L14.17 18H18V6h-3.94v6.55Z" />
      </svg>
    );
    case "instagram": return (
      <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round">
        <rect x="3" y="3" width="18" height="18" rx="5" />
        <circle cx="12" cy="12" r="4" />
        <circle cx="17.5" cy="6.5" r="0.6" fill={color} />
      </svg>
    );
    case "youtube": return (
      <svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
        <path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 12a29 29 0 0 0 .46 5.58 2.78 2.78 0 0 0 1.94 2C5.12 20 12 20 12 20s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2A29 29 0 0 0 23 12a29 29 0 0 0-.46-5.58Z" />
        <path d="m9.75 15.02 5.75-3.27-5.75-3.27v6.54Z" fill="#fff" />
      </svg>
    );
    case "play": return (
      <svg {...props}><rect x="3" y="5" width="18" height="14" rx="3" /><path d="m10 9 5 3-5 3z" fill={color} stroke="none" /></svg>
    );
    default: return null;
  }
};

// --------- viewport hook (responsive breakpoints) ---------
// Breakpoints follow PC 모바일 가이드: mobile ≤768, tablet 769~1024, desktop >1024
const useViewport = () => {
  const [w, setW] = React.useState(() => (typeof window !== "undefined" ? window.innerWidth : 1440));
  React.useEffect(() => {
    const onResize = () => setW(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return {
    width: w,
    isMobile: w <= 768,
    isTablet: w > 768 && w <= 1024,
    isDesktop: w > 1024,
  };
};

// --------- helpers ---------
const Reveal = ({ children, delay = 0, y = 16, as: As = "div", style = {}, ...rest }) => {
  const ref = React.useRef(null);
  const [vis, setVis] = React.useState(false);
  React.useEffect(() => {
    const el = ref.current; if (!el) return;
    // Fallback: if IntersectionObserver doesn't fire (e.g. inside snap-scroll containers
    // or sandboxed iframes), still reveal after a short delay so content never stays hidden.
    const fallback = setTimeout(() => setVis(true), 200);
    if (typeof IntersectionObserver === "undefined") {
      setVis(true);
      return () => clearTimeout(fallback);
    }
    const io = new IntersectionObserver((entries) => {
      entries.forEach((e) => { setVis(e.isIntersecting); if (e.isIntersecting) clearTimeout(fallback); });
    }, { threshold: 0.08, root: el.closest('[data-scroll-root]') || null });
    io.observe(el);
    return () => { io.disconnect(); clearTimeout(fallback); };
  }, []);
  return (
    <As ref={ref} style={{
      transform: vis ? "translateY(0)" : `translateY(${y}px)`,
      opacity: vis ? 1 : 0,
      transition: `transform .8s cubic-bezier(.2,.7,.2,1) ${delay}ms, opacity .8s ease ${delay}ms`,
      ...style,
    }} {...rest}>{children}</As>
  );
};

// Artboard scroll wrapper — gives each variation a fixed-size scrollable viewport.
// width/height are in CSS px; the design_canvas frame matches these dimensions.
const Frame = ({ width = 1440, height = 900, bg, color, children, fontFamily }) => {
  return (
    <div
      data-scroll-root="true"
      className="ab-scroll"
      style={{
        width, height,
        overflowY: "auto",
        overflowX: "hidden",
        background: bg,
        color,
        fontFamily: fontFamily || "var(--font-sans)",
        position: "relative",
        scrollbarWidth: "thin",
      }}>
      {children}
    </div>
  );
};

// little chrome strip at top of each frame so they read as homepages
const TopBar = ({ logoColor = "#0A2540", textColor = "#0A2540", subColor = "#5b6b80", border = "rgba(10,37,64,.08)", ctaBg = "#0A2540", ctaColor = "#fff", logoMark, logoSrc, barBg = "rgba(255,255,255,0.72)", logoFilter, fadeColor }) => {
  const { isMobile } = useViewport();
  const [menuOpen, setMenuOpen] = React.useState(false);

  // body scroll lock when mobile menu open
  React.useEffect(() => {
    if (!menuOpen) return;
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    const onKey = (e) => { if (e.key === "Escape") setMenuOpen(false); };
    window.addEventListener("keydown", onKey);
    return () => { document.body.style.overflow = prev; window.removeEventListener("keydown", onKey); };
  }, [menuOpen]);

  const navItems = [
    { label: "AI 추천 원리", href: "#ai-criteria" },
    { label: "법적 근거", href: "#law-update" },
    { label: "채널 전략", href: "#channels" },
    { label: "상품", href: "#pricing-marketing" },
    { label: "비교", href: "#compare" },
    { label: "FAQ", href: "#faq" },
  ];

  const onNavClick = (href) => (e) => {
    e.preventDefault();
    setMenuOpen(false);
    if (typeof window.__dtScrollToId === "function") {
      // small delay so menu close transition starts before scroll begins
      setTimeout(() => window.__dtScrollToId(href), 80);
    } else {
      const el = document.querySelector(href);
      if (el && el.scrollIntoView) el.scrollIntoView({ behavior: "smooth", block: "start" });
    }
  };

  return (
    <div style={{ position: "sticky", top: 0, zIndex: 50 }}>
      <div style={{
        backdropFilter: "saturate(180%) blur(14px)",
        WebkitBackdropFilter: "saturate(180%) blur(14px)",
        background: barBg,
        padding: isMobile ? "12px 16px" : "18px 40px",
        display: "flex", alignItems: "center", justifyContent: "space-between",
        gap: isMobile ? 12 : 24,
        minHeight: isMobile ? 64 : 88,
      }}>
      <div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
        {logoSrc ? (
          <img src={logoSrc} alt="닥터트래픽" style={{ height: isMobile ? 36 : 52, width: "auto", display: "block", filter: logoFilter }} />
        ) : (
          <React.Fragment>
            {logoMark}
            <div style={{ fontWeight: 700, fontSize: 17, letterSpacing: "-0.01em", color: textColor, whiteSpace: "nowrap" }}>닥터트래픽</div>
            <div style={{ fontSize: 11, color: subColor, marginLeft: 4, fontFamily: "var(--font-display)", letterSpacing: ".08em", textTransform: "uppercase", whiteSpace: "nowrap" }}>Doctor Traffic</div>
          </React.Fragment>
        )}
      </div>
      {isMobile ? (
        <button
          aria-label={menuOpen ? "메뉴 닫기" : "메뉴 열기"}
          aria-expanded={menuOpen}
          onClick={() => setMenuOpen((o) => !o)}
          style={{
            width: 44, height: 44, borderRadius: 10,
            background: "transparent", border: "none", cursor: "pointer",
            display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center",
            gap: 5, padding: 0,
          }}>
          <span aria-hidden style={{
            width: 22, height: 2, background: textColor, display: "block",
            transition: "transform .25s ease, opacity .25s ease",
            transform: menuOpen ? "translateY(7px) rotate(45deg)" : "none",
          }} />
          <span aria-hidden style={{
            width: 22, height: 2, background: textColor, display: "block",
            transition: "opacity .2s ease",
            opacity: menuOpen ? 0 : 1,
          }} />
          <span aria-hidden style={{
            width: 22, height: 2, background: textColor, display: "block",
            transition: "transform .25s ease",
            transform: menuOpen ? "translateY(-7px) rotate(-45deg)" : "none",
          }} />
        </button>
      ) : (
        <nav style={{ display: "flex", gap: 28, alignItems: "center", fontSize: 14, color: textColor, flexShrink: 0 }}>
          {navItems.slice(0, 4).map((it) => (
            <a key={it.href} href={it.href} onClick={onNavClick(it.href)} style={{ color: textColor, textDecoration: "none", opacity: .82, whiteSpace: "nowrap", cursor: "pointer" }}>{it.label}</a>
          ))}
          <button style={{
            background: ctaBg, color: ctaColor, border: "none", padding: "10px 18px", borderRadius: 999,
            fontWeight: 600, fontSize: 13.5, letterSpacing: "-.005em", cursor: "pointer",
            fontFamily: "var(--font-sans)", whiteSpace: "nowrap",
          }}>카카오톡 상담</button>
        </nav>
      )}
      </div>
      {fadeColor ? (
        <div aria-hidden style={{
          height: isMobile ? 16 : 32,
          background: `linear-gradient(to bottom, ${fadeColor}, ${fadeColor.replace(/,\s*[\d.]+\)/, ', 0)')})`,
          pointerEvents: "none",
        }} />
      ) : null}

      {/* Mobile fullscreen menu */}
      {isMobile && menuOpen && (
        <div
          onClick={(e) => { if (e.target === e.currentTarget) setMenuOpen(false); }}
          style={{
            position: "fixed", inset: 0, top: 64, zIndex: 49,
            background: barBg,
            backdropFilter: "saturate(180%) blur(20px)",
            WebkitBackdropFilter: "saturate(180%) blur(20px)",
            padding: "32px 20px 40px",
            display: "flex", flexDirection: "column", gap: 8,
            overflowY: "auto",
            animation: "topbarMenuFadeIn .25s ease-out",
          }}>
          <style>{`
            @keyframes topbarMenuFadeIn {
              from { opacity: 0; transform: translateY(-8px); }
              to { opacity: 1; transform: translateY(0); }
            }
          `}</style>
          {navItems.map((it) => (
            <a key={it.href} href={it.href} onClick={onNavClick(it.href)} style={{
              color: textColor, textDecoration: "none", fontSize: 20, fontWeight: 600,
              padding: "16px 8px", borderRadius: 10,
              minHeight: 48, display: "flex", alignItems: "center",
              letterSpacing: "-.01em",
            }}>{it.label}</a>
          ))}
          <button style={{
            marginTop: 16,
            background: ctaBg, color: ctaColor, border: "none",
            padding: "16px 20px", borderRadius: 14,
            fontWeight: 700, fontSize: 16, cursor: "pointer",
            fontFamily: "var(--font-sans)",
            display: "flex", alignItems: "center", justifyContent: "center", gap: 10,
            minHeight: 56,
          }}>
            <Icon kind="kakao" size={18} color="#FEE500" />
            카카오톡 상담
          </button>
        </div>
      )}
    </div>
  );
};

Object.assign(window, { HJ, Icon, Reveal, Frame, TopBar, useViewport });
