{!showBreadcrumb && }
diff --git a/_includes/head.html b/_includes/head.html index fab490a729..d29c530f8d 100644 --- a/_includes/head.html +++ b/_includes/head.html @@ -103,9 +103,4 @@ {% endif %} - {% if page.url contains "/learn" or page.url contains "/docs" or page.url contains "/search" or page.url contains - "/changelog" %} - - - {% endif %} diff --git a/_includes/learn/search-input.html b/_includes/learn/search-input.html deleted file mode 100644 index c1374ca7d8..0000000000 --- a/_includes/learn/search-input.html +++ /dev/null @@ -1,8 +0,0 @@ -{% if include.domain == "docs" %} -{% assign action = "/docs-search" %} -{% else %} -{% assign action = "/search" %} -{% endif %} -
- - \ No newline at end of file diff --git a/_includes/learn/top-bar.html b/_includes/learn/top-bar.html deleted file mode 100644 index 8b39688ff4..0000000000 --- a/_includes/learn/top-bar.html +++ /dev/null @@ -1,27 +0,0 @@ - diff --git a/public/js/inkeep.js b/public/js/inkeep.js deleted file mode 100644 index f3c5fd9db0..0000000000 --- a/public/js/inkeep.js +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Inkeep widget settings (cxkit-js) - */ -import "https://cdn.jsdelivr.net/npm/@inkeep/cxkit-js@0.5.117/dist/embed.js"; - -const baseConfig = { - baseSettings: { - apiKey: "6dd55673e83be3649d9ef8281b40795329b492a8fc320985", - organizationDisplayName: "Metabase", - primaryBrandColor: "#509EE3", - theme: { - styles: [{ type: "link", value: "/css/inkeep.css" }], - }, - }, - aiChatSettings: { - aiAssistantName: "Metabase", - chatSubjectName: "Metabase", - placeholder: "How long does it take to bake a pie chart?", - introMessage: - "Hi! You can ask me about Metabase. (Please check the sources; I'm still learning.)", - prompts: ["Be succinct"], - aiAssistantAvatar: { - light: "/images/icons/metabase-icon.svg", - dark: "/images/icons/metabase-icon.svg", - }, - userAvatar: "/images/icons/person.svg", - }, - searchSettings: { - placeholder: "Search", - }, - shouldShowAskAICard: false, - askAILabel: "Ask", - forceDefaultView: "search", - modalSettings: { shortcutKey: "k" }, -}; - -const getSearchQuery = () => - new URLSearchParams(window.location.search).get("query") || ""; - -let inkeepWidget; - -const handleOpenChange = (isOpen) => { - inkeepWidget?.update({ - modalSettings: { isOpen, onOpenChange: handleOpenChange }, - }); -}; - -const initializeInkeep = () => { - const initialQuery = getSearchQuery(); - const shouldOpenOnLoad = - window.location.pathname.startsWith("/search") && initialQuery.length > 0; - - const config = shouldOpenOnLoad - ? { - ...baseConfig, - modalSettings: { - ...baseConfig.modalSettings, - isOpen: true, - onOpenChange: handleOpenChange, - }, - searchSettings: { ...baseConfig.searchSettings, defaultQuery: initialQuery }, - } - : baseConfig; - - inkeepWidget = window.Inkeep.SearchBar("#inkeep", config); - - if (shouldOpenOnLoad) { - try { - inkeepWidget.setView("search"); - inkeepWidget.search.updateQuery(initialQuery); - } catch (_e) { - // Widget still mounts even if these helpers aren't ready on first paint. - } - } -}; - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initializeInkeep); -} else { - initializeInkeep(); -} diff --git a/src/components/TopBar.astro b/src/components/TopBar.astro new file mode 100644 index 0000000000..a469a59ffb --- /dev/null +++ b/src/components/TopBar.astro @@ -0,0 +1,33 @@ +--- +import LiquidInclude from "@/components/LiquidInclude.astro"; +import Search from "@/components/search/Search.astro"; +import type { LiquidRenderer } from "@/lib/liquid/liquidRenderer"; + +interface Props { + lq: LiquidRenderer; + showBreadcrumb?: boolean; +} + +const { lq, showBreadcrumb = false } = Astro.props; +--- + + diff --git a/src/components/inkeep/shared.css b/src/components/inkeep/shared.css new file mode 100644 index 0000000000..7790e2b430 --- /dev/null +++ b/src/components/inkeep/shared.css @@ -0,0 +1,37 @@ +/* Styles for the widget's shadow DOM, inlined through `theme.styles` rather than + linked, so each widget carries only what it needs. cxkit-js derives these class + names from each component's `_id` debug prop, kebab-cased (`modal__Content` + becomes `.ikp-modal__content`). */ +.ikp-modal-widget__content { + font-family: Lato, sans-serif; +} + +.ikp-ai-chat__toolbar-header { + visibility: hidden; +} + +.ikp-ai-chat-page-wrapper { + padding-top: 10px; +} + +.ikp-ai-chat__message-box { + background-color: #eef6fc; + border: 1px solid #e4ecfb; + box-shadow: none; +} + +.ikp-ai-chat-footer__content-wrapper { + padding-bottom: 30px; +} + +.ikp-code { + background-color: #f9fbfe; + color: #5a6072; + font-weight: 400; +} + +.ikp-chat-bubble__user-message img { + left: -2px; + position: relative; + zoom: 1.3; +} diff --git a/src/components/search/Search.astro b/src/components/search/Search.astro new file mode 100644 index 0000000000..5f93fa4555 --- /dev/null +++ b/src/components/search/Search.astro @@ -0,0 +1,20 @@ + + + + + diff --git a/src/components/search/header-offset.ts b/src/components/search/header-offset.ts new file mode 100644 index 0000000000..c66f7e8574 --- /dev/null +++ b/src/components/search/header-offset.ts @@ -0,0 +1,63 @@ +export const MODAL_TOP = "--inkeep-modal-top"; +export const GAP = 24; + +// `.promo-banner` renders inside `.navigation-header`, above the nav row, and +// `--navigation-header-height` is a static 77/85px that does not account for it — +// nor for the 5px it is already short by. Measure instead. +const HEADER = "header.bootstrap, .navigation-header"; +const BANNER_PARENT = ".navigation-header"; + +export const modalTop = (headerBottom: number): string => + `${Math.max(0, Math.round(headerBottom)) + GAP}px`; + +// The header is sticky at `top: -48px` so the promo banner scrolls away while the +// nav row stays pinned — its bottom edge moves between 130px and 82px. Read the +// live rect rather than assuming a resting height. +export function publishHeaderOffset(doc: Document): string | null { + const header = doc.querySelector(HEADER); + if (!header) return null; + + const top = modalTop(header.getBoundingClientRect().bottom); + doc.documentElement.style.setProperty(MODAL_TOP, top); + return top; +} + +export function trackHeaderOffset(doc: Document): () => void { + const header = doc.querySelector(HEADER); + const view = doc.defaultView; + if (!header || !view) return () => {}; + + let last = publishHeaderOffset(doc); + + // Scrolling moves the header's bottom edge without firing either observer. The + // browser already caps scroll events at one per frame, so read straight through + // and skip the style write while the value holds. + const onScroll = () => { + const top = modalTop(header.getBoundingClientRect().bottom); + if (top === last) return; + last = top; + doc.documentElement.style.setProperty(MODAL_TOP, top); + }; + view.addEventListener("scroll", onScroll, { passive: true }); + + const republish = () => { + last = publishHeaderOffset(doc); + }; + + // A ResizeObserver on the header does not fire when promo-banner.js removes the + // banner, even though the header visibly shrinks — measured, not assumed. The + // banner is a direct child, so childList alone catches it without subtree noise. + const bannerParent = doc.querySelector(BANNER_PARENT); + const mutation = new MutationObserver(republish); + if (bannerParent) mutation.observe(bannerParent, { childList: true }); + + // Still worth observing for the 77/85px breakpoint and font reflow. + const resize = new ResizeObserver(republish); + resize.observe(header); + + return () => { + view.removeEventListener("scroll", onScroll); + mutation.disconnect(); + resize.disconnect(); + }; +} diff --git a/public/css/inkeep.css b/src/components/search/inkeep-shadow.css similarity index 59% rename from public/css/inkeep.css rename to src/components/search/inkeep-shadow.css index f962e3103c..e2a3a8d449 100644 --- a/public/css/inkeep.css +++ b/src/components/search/inkeep-shadow.css @@ -1,31 +1,22 @@ -inkeep-portal { - position: relative; - z-index: 100000; -} +/* Search-widget shadow DOM. Twin of metabase.github.io + apps/marketing-astro/src/components/search/inkeep-shadow.css — keep the two in sync. */ -/* cxkit-js builds class names from each component's `_id` debug prop via a - helper that kebab-cases and lowercases the suffix (e.g., `modal__Content` - becomes `.ikp-modal__content`). Push the modal below our fixed header. */ -.ikp-modal__content { - margin-top: var(--navigation-header-height, 77px); -} +/* `--inkeep-modal-top` is published by header-offset.ts: the widget pins the modal + at `top: 5%`, and CSS in here cannot measure the header past the shadow boundary. + The fallback is the desktop header plus the same gap. -.ikp-modal__overlay { - padding-top: var(--navigation-header-height, 77px); + Below 768px the widget sets `data-mobile` and goes full-screen via `inset: 0`, at a + specificity a bare class cannot reach — `:not()` opts us out rather than losing `top` + while `max-height` still applied and stranded a dead strip at the bottom. */ +.ikp-modal__content:not([data-mobile]) { + max-height: calc(100dvh - var(--inkeep-modal-top, 101px) - 24px); + top: var(--inkeep-modal-top, 101px); } .ikp-search-bar-trigger__container { font-family: Lato, "Helvetica Neue", Helvetica, sans-serif; } -.ikp-modal-widget__content { - font-family: Lato, sans-serif; -} - -.ikp-ai-chat__toolbar-header { - visibility: hidden; -} - .ikp-search-bar-trigger__wrapper .ikp-btn { background-color: #eef6fc; border: 1px solid #e4ecfb; @@ -41,14 +32,10 @@ inkeep-portal { background-color: white; } -.ikp-search-bar-trigger__container--variant_subtle:is(:hover,[data-hover]) { +.ikp-search-bar-trigger__container--variant_subtle:is(:hover, [data-hover]) { background-color: #eef6fc !important; } -.ikp-ai-chat-page-wrapper { - padding-top: 10px; -} - .ikp-search-bar-trigger__text--size_compact { font-size: 14px; } @@ -64,7 +51,9 @@ inkeep-portal { font-weight: 700; margin-left: 6px; padding: 18px; - transition: background-color 0.2s ease, color 0.2s ease; + transition: + background-color 0.2s ease, + color 0.2s ease; } .ikp-switch-view-toggle button:hover { @@ -92,33 +81,12 @@ inkeep-portal { fill: #509ee3; } -.ikp-code { - background-color: #f9fbfe; - color: #5a6072; - font-weight: 400; -} - -.ikp-chat-bubble__user-message img { - left: -2px; - position: relative; - zoom: 1.3; -} - +/* prettier-ignore -- wrapping this selector trips stylelint's indentation rule */ div.ikp-d_flex.ikp-flex_row.ikp-items_center.ikp-pt_3.ikp-pb_1:has(.ikp-search-bar-stand-alone__search-input) { margin-right: -2px; margin-top: 6px; } -.ikp-ai-chat__message-box { - background-color: #eef6fc; - border: 1px solid #e4ecfb; - box-shadow: none; -} - -.ikp-ai-chat-footer__content-wrapper { - padding-bottom: 30px; -} - @media screen and (max-width: 992px) { .ikp-search-bar-trigger__text { display: none; diff --git a/src/components/search/search.ts b/src/components/search/search.ts new file mode 100644 index 0000000000..4767838646 --- /dev/null +++ b/src/components/search/search.ts @@ -0,0 +1,113 @@ +import sharedCss from "../inkeep/shared.css?raw"; +import { trackHeaderOffset } from "./header-offset"; +import shadowCss from "./inkeep-shadow.css?raw"; + +const EMBED_URL = + "https://cdn.jsdelivr.net/npm/@inkeep/cxkit-js@0.5.117/dist/embed.js"; + +const MOUNT = "#inkeep"; + +interface SearchBarConfig { + baseSettings: { + theme?: { + styles?: { key?: string; type: "link" | "style"; value: string }[]; + }; + [key: string]: unknown; + }; + modalSettings: Record