diff --git a/knip.json b/knip.json index 9aa8e8cd07..41fb65e82f 100644 --- a/knip.json +++ b/knip.json @@ -5,7 +5,7 @@ "ignore": [ "src/api/**", "src/components/ui/**", - "src/config/announcements.ts", + "src/config/banners.ts", "src/config/preSubmitHooks.ts", "src/components/shared/BetaFeatureWrapper/BetaFeatureWrapper.tsx" ], diff --git a/react-compiler.config.js b/react-compiler.config.js index 4b03d8437f..68bb1faf4a 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -78,7 +78,8 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/components/shared/Dialogs/PipelineNameDialog.tsx", "src/components/shared/SecretsManagement/components/SecretsBackendUnavailable.tsx", "src/components/shared/HighlightText.tsx", - "src/components/shared/AnnouncementBanners.tsx", + "src/components/shared/Banners", + "src/components/shared/Markdown", "src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection", "src/components/ui/typography.tsx", diff --git a/src/components/layout/AppMenu.tsx b/src/components/layout/AppMenu.tsx index 195aa373bd..ac64fbfb05 100644 --- a/src/components/layout/AppMenu.tsx +++ b/src/components/layout/AppMenu.tsx @@ -9,6 +9,7 @@ import logo from "/Tangle_white.png"; import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill"; import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers"; import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication"; +import { BannerInbox } from "@/components/shared/Banners/BannerInbox"; import { CopyText } from "@/components/shared/CopyText/CopyText"; import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle"; import ImportPipeline from "@/components/shared/ImportPipeline"; @@ -128,6 +129,7 @@ const DefaultAppMenu = () => { + {/* Settings & status */} {isOnSettingsRoute ? ( diff --git a/src/components/layout/RootLayout.tsx b/src/components/layout/RootLayout.tsx index d4f3a52fc0..87569378b7 100644 --- a/src/components/layout/RootLayout.tsx +++ b/src/components/layout/RootLayout.tsx @@ -2,6 +2,7 @@ import { Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/router-devtools"; import { ToastContainer } from "react-toastify"; +import { BannerRegion } from "@/components/shared/Banners/BannerRegion"; import { ConfirmDialogBridge } from "@/components/shared/ConfirmDialogBridge/ConfirmDialogBridge"; import { useClickTracking } from "@/hooks/useClickTracking"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; @@ -37,6 +38,7 @@ function RootLayoutContent() {
+
diff --git a/src/components/shared/AnnouncementBanners.tsx b/src/components/shared/AnnouncementBanners.tsx deleted file mode 100644 index 152f24a63d..0000000000 --- a/src/components/shared/AnnouncementBanners.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import "@/config/announcements"; - -import { useState } from "react"; - -import { InfoBox } from "@/components/shared/InfoBox"; -import { BlockStack } from "@/components/ui/layout"; -import { getStorage } from "@/utils/typedStorage"; - -interface DismissedAnnouncementsStorage { - "dismissed-announcements": string[]; -} - -const storage = getStorage< - keyof DismissedAnnouncementsStorage, - DismissedAnnouncementsStorage ->(); - -function getDismissedIds(): string[] { - return storage.getItem("dismissed-announcements") ?? []; -} - -export const AnnouncementBanners = () => { - const [dismissedIds, setDismissedIds] = useState(getDismissedIds); - - const announcements = window.__TANGLE_ANNOUNCEMENTS__ ?? []; - const now = new Date(); - const visible = announcements.filter( - (a) => - !dismissedIds.includes(a.id) && - (!a.expiresAt || new Date(a.expiresAt) > now), - ); - - if (visible.length === 0) { - return null; - } - - const handleDismiss = (id: string) => { - const updated = [...dismissedIds, id]; - storage.setItem("dismissed-announcements", updated); - setDismissedIds(updated); - }; - - return ( - - {visible.map((announcement) => { - const onDismiss = announcement.dismissible - ? () => handleDismiss(announcement.id) - : undefined; - - return ( - - {announcement.body} - - ); - })} - - ); -}; diff --git a/src/components/shared/Banners/BannerCard.tsx b/src/components/shared/Banners/BannerCard.tsx new file mode 100644 index 0000000000..959378c464 --- /dev/null +++ b/src/components/shared/Banners/BannerCard.tsx @@ -0,0 +1,51 @@ +import { InfoBox } from "@/components/shared/InfoBox"; +import { UntrustedMarkdown } from "@/components/shared/Markdown/Markdown"; +import { BlockStack } from "@/components/ui/layout"; +import { Link } from "@/components/ui/link"; +import type { TangleBanner } from "@/config/banners"; + +interface BannerCardProps { + banner: TangleBanner; + bodyClassName?: string; + onDismiss?: () => void; +} + +export const BannerCard = ({ + banner, + bodyClassName, + onDismiss, +}: BannerCardProps) => { + const hasBody = banner.body.trim().length > 0; + + return ( + + + {hasBody && ( +
+ +
+ )} + {banner.action && ( + + {banner.action.text} + + )} +
+
+ ); +}; diff --git a/src/components/shared/Banners/BannerInbox.test.tsx b/src/components/shared/Banners/BannerInbox.test.tsx new file mode 100644 index 0000000000..1dbe5ab4fb --- /dev/null +++ b/src/components/shared/Banners/BannerInbox.test.tsx @@ -0,0 +1,165 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { installSource } from "@/config/bannerTestSource"; + +import { BannerInbox } from "./BannerInbox"; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal("ResizeObserver", ResizeObserverMock); + +describe("", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + delete window.__TANGLE_BANNER_SOURCE__; + }); + + it("renders nothing when there are no banners", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("counts the banners the reader has not opened yet", () => { + installSource([ + { id: "a", title: "One", body: "" }, + { id: "b", title: "Two", body: "" }, + ]); + + render(); + + expect(screen.getByTestId("banner-inbox-unread")).toHaveTextContent("2"); + expect(screen.getByTestId("banner-inbox-trigger")).toHaveAttribute( + "aria-label", + "Notices, 2 unread", + ); + }); + + it("lists every banner in full once opened", () => { + installSource([ + { id: "a", title: "One", body: "Body of **one**" }, + { id: "b", title: "Two", body: "Body of two" }, + ]); + + render(); + fireEvent.click(screen.getByTestId("banner-inbox-trigger")); + + expect(screen.getByTestId("banner-inbox")).toBeInTheDocument(); + expect(screen.getAllByTestId("info-box-title")).toHaveLength(2); + expect(screen.getByText("one").tagName).toBe("STRONG"); + expect(screen.getByText(/Body of two/)).toBeInTheDocument(); + }); + + it("clears the unread count once opened, and keeps it clear across a remount", () => { + installSource([{ id: "a", title: "One", body: "" }]); + + render(); + fireEvent.click(screen.getByTestId("banner-inbox-trigger")); + + expect(screen.queryByTestId("banner-inbox-unread")).not.toBeInTheDocument(); + + cleanup(); + render(); + + expect(screen.queryByTestId("banner-inbox-unread")).not.toBeInTheDocument(); + expect(screen.getByTestId("banner-inbox-trigger")).toHaveAttribute( + "aria-label", + "Notices, 1 active", + ); + }); + + it("lets a dismissible banner be removed from the centre for good", () => { + installSource([ + { id: "a", title: "Optional", body: "", dismissible: true }, + { id: "b", title: "Mandatory", body: "" }, + ]); + + render(); + fireEvent.click(screen.getByTestId("banner-inbox-trigger")); + + expect(screen.getAllByLabelText("Dismiss")).toHaveLength(1); + fireEvent.click(screen.getByLabelText("Dismiss")); + + expect(screen.queryByText("Optional")).not.toBeInTheDocument(); + expect(screen.getByText("Mandatory")).toBeInTheDocument(); + + cleanup(); + render(); + + expect(screen.getByTestId("banner-inbox-trigger")).toHaveAttribute( + "aria-label", + "Notices, 1 active", + ); + }); + + // A dismiss that cannot be persisted is held in memory for the lifetime of the + // module, so this case needs an id no other test reuses. + it("removes a dismissed banner even when it cannot be persisted", () => { + installSource([ + { id: "unpersistable", title: "Optional", body: "", dismissible: true }, + ]); + + render(); + fireEvent.click(screen.getByTestId("banner-inbox-trigger")); + + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("storage is unavailable"); + }); + fireEvent.click(screen.getByLabelText("Dismiss")); + setItem.mockRestore(); + + expect(screen.queryByText("Optional")).not.toBeInTheDocument(); + }); + + it("puts a hidden strip back on the page", () => { + localStorage.setItem("hidden-banners", JSON.stringify(["a"])); + installSource([{ id: "a", title: "One", body: "", dismissible: true }]); + + render(); + fireEvent.click(screen.getByTestId("banner-inbox-trigger")); + fireEvent.click(screen.getByTestId("banner-inbox-show")); + + expect(screen.queryByTestId("banner-inbox-show")).not.toBeInTheDocument(); + expect(screen.getByTestId("banner-inbox-hide")).toBeInTheDocument(); + expect(localStorage.getItem("hidden-banners")).toBe("[]"); + }); + + it("hides a showing strip from the centre, and offers to bring it back", () => { + installSource([{ id: "a", title: "One", body: "", dismissible: true }]); + + render(); + fireEvent.click(screen.getByTestId("banner-inbox-trigger")); + + expect(screen.queryByTestId("banner-inbox-show")).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId("banner-inbox-hide")); + + expect(screen.getByTestId("banner-inbox-show")).toBeInTheDocument(); + expect(localStorage.getItem("hidden-banners")).toContain("a"); + }); + + it("orders the list by severity", () => { + installSource([ + { id: "a", title: "Info", body: "", variant: "info" }, + { id: "b", title: "Error", body: "", variant: "error" }, + { id: "c", title: "Warning", body: "", variant: "warning" }, + ]); + + render(); + fireEvent.click(screen.getByTestId("banner-inbox-trigger")); + + expect( + screen.getAllByTestId("info-box-title").map((el) => el.textContent), + ).toEqual(["Error", "Warning", "Info"]); + }); +}); diff --git a/src/components/shared/Banners/BannerInbox.tsx b/src/components/shared/Banners/BannerInbox.tsx new file mode 100644 index 0000000000..113da54e3e --- /dev/null +++ b/src/components/shared/Banners/BannerInbox.tsx @@ -0,0 +1,117 @@ +import { useEffect } from "react"; + +import { BannerCard } from "@/components/shared/Banners/BannerCard"; +import TooltipButton from "@/components/shared/Buttons/TooltipButton"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Text } from "@/components/ui/typography"; +import { closeBannerInbox, useBannerInbox } from "@/hooks/useBannerInbox"; +import { tracking } from "@/utils/tracking"; + +const VIEWPORT_GUTTER = 16; + +export const BannerInbox = () => { + const { + banners, + unreadCount, + isStripHidden, + isOpen, + setOpen, + hideStrip, + showStrip, + dismiss, + } = useBannerInbox(); + + useEffect(() => closeBannerInbox, []); + + if (banners.length === 0) return null; + + const label = + unreadCount > 0 + ? `Notices, ${unreadCount} unread` + : `Notices, ${banners.length} active`; + + const stripToggle = isStripHidden + ? ({ + icon: "Eye", + label: "Show notices", + onClick: showStrip, + testId: "banner-inbox-show", + } as const) + : ({ + icon: "EyeOff", + label: "Hide notices", + onClick: hideStrip, + testId: "banner-inbox-hide", + } as const); + + return ( + + + + + {unreadCount > 0 && ( + + {unreadCount} + + )} + + + + + + + Notices + + + + {banners.map((banner) => ( + dismiss(banner) : undefined} + /> + ))} + + + + ); +}; diff --git a/src/components/shared/Banners/BannerRegion.test.tsx b/src/components/shared/Banners/BannerRegion.test.tsx new file mode 100644 index 0000000000..b18b82afd4 --- /dev/null +++ b/src/components/shared/Banners/BannerRegion.test.tsx @@ -0,0 +1,310 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { installRawSource, installSource } from "@/config/bannerTestSource"; +import { closeBannerInbox } from "@/hooks/useBannerInbox"; +import { CONTENT_OFFSET_VAR } from "@/utils/constants"; + +import { BannerInbox } from "./BannerInbox"; +import { BannerRegion } from "./BannerRegion"; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal("ResizeObserver", ResizeObserverMock); + +function contentOffset() { + return document.documentElement.style.getPropertyValue(CONTENT_OFFSET_VAR); +} + +describe("", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + closeBannerInbox(); + delete window.__TANGLE_BANNER_SOURCE__; + document.documentElement.style.removeProperty(CONTENT_OFFSET_VAR); + }); + + it("renders no DOM node when no source is installed", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByTestId("banner-region")).not.toBeInTheDocument(); + }); + + it("renders no DOM node when the source declares an unsupported version", () => { + installRawSource({ + version: 2, + getSnapshot: () => [{ id: "a", title: "Later contract", body: "" }], + subscribe: () => () => {}, + }); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders a source that rebuilds its array on every read", () => { + const banners = [{ id: "a", title: "Scheduled maintenance", body: "" }]; + installRawSource({ + version: 1, + getSnapshot: () => banners.map((banner) => ({ ...banner })), + subscribe: () => () => {}, + }); + + render(); + + expect(screen.getByText("Scheduled maintenance")).toBeInTheDocument(); + }); + + it("promotes every showing banner rather than capping the strip", () => { + installSource( + ["a", "b", "c", "d", "e", "f"].map((id) => ({ + id, + title: `Notice ${id}`, + body: "", + variant: "error" as const, + })), + ); + + render(); + + expect(screen.getAllByTestId("banner-card")).toHaveLength(6); + expect(screen.getByText("Notice f")).toBeInTheDocument(); + }); + + it("promotes the cards in severity order", () => { + installSource([ + { id: "a", title: "Nice to know", body: "", variant: "info" }, + { id: "b", title: "Worked", body: "", variant: "success" }, + { id: "c", title: "Everything is broken", body: "", variant: "error" }, + { id: "d", title: "Heads up", body: "", variant: "warning" }, + ]); + + render(); + + expect( + screen.getAllByTestId("info-box-title").map((el) => el.textContent), + ).toEqual(["Everything is broken", "Heads up", "Worked", "Nice to know"]); + }); + + it("puts the scrolling zone in the tab order so its overflow stays reachable", () => { + installSource([ + { id: "a", title: "One", body: "", variant: "error" }, + { id: "b", title: "Two", body: "", variant: "warning" }, + ]); + + render(); + + const scroller = screen.getByTestId("banner-scroller"); + expect(scroller).toHaveAttribute("tabindex", "0"); + expect(scroller).toContainElement(screen.getAllByTestId("banner-card")[1]); + }); + + it("leaves opening the full list to the header, with no button of its own", () => { + installSource([{ id: "a", title: "Only notice", body: "" }]); + + render(); + + expect(screen.getByTestId("banner-controls")).toBeInTheDocument(); + expect(screen.queryByTestId("banner-open-inbox")).not.toBeInTheDocument(); + }); + + it("renders a brief body inline as Markdown", () => { + installSource([ + { + id: "a", + title: "Scheduled maintenance", + body: "Submissions paused **09:00-11:00 UTC**.", + }, + ]); + + render(); + + expect(screen.getByText("09:00-11:00 UTC").tagName).toBe("STRONG"); + }); + + it("renders a body-only banner without reserving room for a title", () => { + installSource([{ id: "a", title: "", body: "Submissions are paused." }]); + + render(); + + expect(screen.getByText("Submissions are paused.")).toBeInTheDocument(); + expect(screen.queryByTestId("info-box-title")).not.toBeInTheDocument(); + }); + + it("renders a long body inline rather than truncating it", () => { + installSource([ + { + id: "a", + title: "Release notes", + body: `Line one\n\n${"detail ".repeat(40)}`, + }, + ]); + + render(); + + expect(screen.getByText("Line one")).toBeInTheDocument(); + }); + + it("does not render raw HTML embedded in the body", () => { + installSource([ + { + id: "a", + title: "Notice", + body: "bold", + }, + ]); + + const { container } = render(); + + expect(container.querySelector("script")).toBeNull(); + expect(document.querySelector("script")).toBeNull(); + expect(screen.queryByText("bold")).not.toBeInTheDocument(); + expect( + (window as unknown as Record).__bannerXss, + ).toBeUndefined(); + }); + + it("renders an action link with an accessible name that includes the title", () => { + installSource([ + { + id: "a", + title: "Scheduled maintenance", + body: "", + action: { url: "https://example.com/notes", text: "Read the notes" }, + }, + ]); + + render(); + + const link = screen.getByRole("link", { + name: "Read the notes: Scheduled maintenance", + }); + + expect(link).toHaveAttribute("href", "https://example.com/notes"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("leaves the banner itself with no dismiss control", () => { + installSource([ + { id: "a", title: "Scheduled maintenance", body: "", dismissible: true }, + ]); + + render(); + + expect(screen.queryByLabelText("Dismiss")).not.toBeInTheDocument(); + expect(screen.getByTestId("banner-hide-strip")).toBeInTheDocument(); + }); + + it("hides its controls while the inbox is open, without giving up their space", () => { + installSource([ + { id: "a", title: "One", body: "", variant: "error" }, + { id: "b", title: "Two", body: "", variant: "info" }, + ]); + + render( + <> + + + , + ); + expect(screen.getByTestId("banner-controls")).not.toHaveClass("invisible"); + + fireEvent.click(screen.getByTestId("banner-inbox-trigger")); + + expect(screen.getByTestId("banner-controls")).toHaveClass("invisible"); + expect(screen.getByTestId("banner-region")).toBeInTheDocument(); + }); + + it("clears the whole strip in one go rather than promoting the next banner", () => { + installSource([ + { + id: "a", + title: "First", + body: "", + variant: "error", + dismissible: true, + }, + { + id: "b", + title: "Second", + body: "", + variant: "warning", + dismissible: true, + }, + ]); + + render(); + fireEvent.click(screen.getByTestId("banner-hide-strip")); + + expect(screen.queryByTestId("banner-region")).not.toBeInTheDocument(); + }); + + it("keeps a hidden dismissible banner off the strip across a remount", () => { + installSource([ + { id: "a", title: "Scheduled maintenance", body: "", dismissible: true }, + ]); + + render(); + fireEvent.click(screen.getByTestId("banner-hide-strip")); + + cleanup(); + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + // The session-scoped half of the hide is module state, so this banner needs an + // id no other test reuses. + it("hides a non-dismissible banner without retiring it for good", () => { + installSource([{ id: "mandatory", title: "Mandatory notice", body: "" }]); + + render(); + fireEvent.click(screen.getByTestId("banner-hide-strip")); + + expect(screen.queryByTestId("banner-region")).not.toBeInTheDocument(); + expect(localStorage.getItem("hidden-banners") ?? "").not.toContain( + "mandatory", + ); + }); + + it("publishes a content offset while a banner is promoted", () => { + installSource([ + { id: "a", title: "Scheduled maintenance", body: "", dismissible: true }, + ]); + + render(); + expect(contentOffset()).not.toBe(""); + + fireEvent.click(screen.getByTestId("banner-hide-strip")); + + expect(contentOffset()).toBe(""); + }); + + it("picks up a source installed after mount", () => { + render(); + + expect(screen.queryByTestId("banner-region")).not.toBeInTheDocument(); + + act(() => { + installSource([{ id: "a", title: "Scheduled maintenance", body: "" }]); + }); + + expect(screen.getByText("Scheduled maintenance")).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/Banners/BannerRegion.tsx b/src/components/shared/Banners/BannerRegion.tsx new file mode 100644 index 0000000000..d652c57dae --- /dev/null +++ b/src/components/shared/Banners/BannerRegion.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from "react"; + +import { BannerCard } from "@/components/shared/Banners/BannerCard"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { InlineStack } from "@/components/ui/layout"; +import { useBannerInbox } from "@/hooks/useBannerInbox"; +import { cn } from "@/lib/utils"; +import { CONTENT_OFFSET_VAR, TOP_NAV_HEIGHT } from "@/utils/constants"; + +function useContentOffset(strip: HTMLElement | null) { + useEffect(() => { + const root = document.documentElement; + + if (!strip) { + root.style.removeProperty(CONTENT_OFFSET_VAR); + return; + } + + const publishOffset = () => { + root.style.setProperty( + CONTENT_OFFSET_VAR, + `${TOP_NAV_HEIGHT + strip.offsetHeight}px`, + ); + }; + + publishOffset(); + const observer = new ResizeObserver(publishOffset); + observer.observe(strip); + + return () => { + observer.disconnect(); + root.style.removeProperty(CONTENT_OFFSET_VAR); + }; + }, [strip]); +} + +export const BannerRegion = () => { + const { showing, isOpen, hideStrip } = useBannerInbox(); + const [strip, setStrip] = useState(null); + + useContentOffset(showing.length > 0 ? strip : null); + + if (showing.length === 0) return null; + + return ( + + + {showing.map((banner) => ( +
+ +
+ ))} +
+ + + +
+ ); +}; diff --git a/src/components/shared/ComponentDetail/ComponentDetail.tsx b/src/components/shared/ComponentDetail/ComponentDetail.tsx index baf1446954..286eaaeff2 100644 --- a/src/components/shared/ComponentDetail/ComponentDetail.tsx +++ b/src/components/shared/ComponentDetail/ComponentDetail.tsx @@ -16,7 +16,7 @@ import type { InputSpec, OutputSpec, } from "@/utils/componentSpec"; -import { TOP_NAV_HEIGHT } from "@/utils/constants"; +import { contentHeight } from "@/utils/constants"; import { getComponentName } from "@/utils/getComponentName"; import { buildComponentSourceUrl } from "@/utils/URL"; @@ -337,8 +337,7 @@ export const ComponentDetail = ({ } // ── Split layout (V1 default) ───────────────────────────────────────── - const splitSourceHeight = - sourcePanelHeight ?? `calc(100vh - ${TOP_NAV_HEIGHT + 48}px)`; + const splitSourceHeight = sourcePanelHeight ?? contentHeight(48); return ( diff --git a/src/components/shared/ContextPanel/ContextPanel.tsx b/src/components/shared/ContextPanel/ContextPanel.tsx index 066152e095..abf4666285 100644 --- a/src/components/shared/ContextPanel/ContextPanel.tsx +++ b/src/components/shared/ContextPanel/ContextPanel.tsx @@ -1,5 +1,5 @@ import { useContextPanel } from "@/providers/ContextPanelProvider"; -import { BOTTOM_FOOTER_HEIGHT, TOP_NAV_HEIGHT } from "@/utils/constants"; +import { BOTTOM_FOOTER_HEIGHT, contentHeight } from "@/utils/constants"; export const ContextPanel = () => { const { content } = useContextPanel(); @@ -8,7 +8,7 @@ export const ContextPanel = () => { data-testid="context-panel-container" className="h-full p-2 bg-sidebar text-sidebar-foreground overflow-y-auto" style={{ - maxHeight: `calc(100vh - ${TOP_NAV_HEIGHT}px - ${BOTTOM_FOOTER_HEIGHT}px)`, + maxHeight: contentHeight(BOTTOM_FOOTER_HEIGHT), }} > {content} diff --git a/src/components/shared/InfoBox.tsx b/src/components/shared/InfoBox.tsx index 9b0e11be48..bfe6507d5f 100644 --- a/src/components/shared/InfoBox.tsx +++ b/src/components/shared/InfoBox.tsx @@ -66,27 +66,31 @@ export const InfoBox = ({ data-testid={`info-box-${variant}`} className={cn("border rounded-md p-2", styles.container, widthClass)} > - - - {title} - - {onDismiss && ( - - )} - + {(title || onDismiss) && ( + + {title && ( + + {title} + + )} + {onDismiss && ( + + )} + + )}
diff --git a/src/components/shared/Markdown/Markdown.test.tsx b/src/components/shared/Markdown/Markdown.test.tsx new file mode 100644 index 0000000000..4d1a2d323d --- /dev/null +++ b/src/components/shared/Markdown/Markdown.test.tsx @@ -0,0 +1,138 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { Markdown, UntrustedMarkdown } from "./Markdown"; + +function fontClasses(element: HTMLElement) { + return element.className + .split(" ") + .filter((name) => name.startsWith("text-")); +} + +describe("", () => { + afterEach(cleanup); + + it("gives headings a visible hierarchy", () => { + render(); + + const [h1, h2, h3, h4] = ["One", "Two", "Three", "Four"].map((text) => + screen.getByText(text), + ); + + expect(h1.tagName).toBe("H1"); + expect(h4.tagName).toBe("H4"); + expect(fontClasses(h1)).toContain("text-lg"); + expect(fontClasses(h2)).toContain("text-base"); + expect(fontClasses(h3)).toContain("text-sm"); + expect(h1.className).toContain("font-bold"); + expect(h2.className).toContain("font-semibold"); + }); + + it("renders a fenced block as a block, not a run of inline pills", () => { + const { container } = render( + , + ); + + const pre = container.querySelector("pre"); + expect(pre).not.toBeNull(); + expect(pre?.className).toContain("overflow-x-auto"); + expect(pre?.querySelector("code")).not.toBeNull(); + expect(pre?.textContent).toContain("const b = 2;"); + }); + + it("renders GFM tables with delineated rows", () => { + const { container } = render( + , + ); + + expect(container.querySelector("table")).not.toBeNull(); + expect(container.querySelector("thead")?.className).toContain( + "bg-muted/50", + ); + expect(container.querySelector("tr")?.className).toContain("border-b"); + expect(container.querySelector("td")?.className).toContain("px-2"); + }); + + it("drops the bullet from a task list item", () => { + const { container } = render( + , + ); + + expect(container.querySelector("li")?.className).toContain("list-none"); + expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength( + 2, + ); + }); + + it("does not render raw HTML", () => { + const { container } = render( + window.__mdXss = true;bold"} />, + ); + + expect(container.querySelector("script")).toBeNull(); + expect(screen.queryByText("bold")).not.toBeInTheDocument(); + expect( + (window as unknown as Record).__mdXss, + ).toBeUndefined(); + }); + + it("renders an image and keeps a relative link", () => { + const { container } = render( + , + ); + + expect(container.querySelector("img")).toHaveAttribute("src", "/local.png"); + expect(screen.getByRole("link", { name: "runs" })).toHaveAttribute( + "href", + "/runs", + ); + }); + + it("lets a caller override a base element", () => { + render( +
{children}
, + }} + />, + ); + + expect(screen.getByTestId("custom")).toHaveTextContent("text"); + }); +}); + +describe("", () => { + afterEach(cleanup); + + it("shows an image's alt text without requesting the image", () => { + const { container } = render( + , + ); + + expect(container.querySelector("img")).toBeNull(); + expect(screen.getByText("Diagram of the outage")).toBeInTheDocument(); + }); + + it("renders an absolute http link as an external anchor", () => { + render(); + + const link = screen.getByRole("link", { name: "docs" }); + expect(link).toHaveAttribute("href", "https://example.com/docs"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("renders a rejected link as plain text rather than a dead anchor", () => { + const { container } = render( + , + ); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(container.querySelector("a")).toBeNull(); + expect(container.textContent).toBe("script and relative"); + }); +}); diff --git a/src/components/shared/Markdown/Markdown.tsx b/src/components/shared/Markdown/Markdown.tsx new file mode 100644 index 0000000000..10b07e0ea1 --- /dev/null +++ b/src/components/shared/Markdown/Markdown.tsx @@ -0,0 +1,181 @@ +import type { ComponentProps, ReactNode } from "react"; +import ReactMarkdown, { + type Components, + defaultUrlTransform, +} from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import { Separator } from "@/components/ui/separator"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { toAbsoluteHttpUrl } from "@/utils/URL"; + +interface ElementProps { + children?: ReactNode; + className?: string; +} + +export const INLINE_CODE_CLASS = + "rounded bg-muted px-1 py-0.5 text-xs font-mono"; + +type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +const HEADING_STYLES: Record< + HeadingLevel, + Omit, "level" | "children"> +> = { + 1: { size: "lg", weight: "bold", className: "mt-2 mb-1 block" }, + 2: { size: "md", weight: "semibold", className: "mt-2 mb-1 block" }, + 3: { size: "sm", weight: "semibold", className: "mt-2 mb-1 block" }, + 4: { + size: "sm", + weight: "semibold", + tone: "subdued", + className: "mt-1 block", + }, + 5: { + size: "xs", + weight: "semibold", + tone: "subdued", + className: "mt-1 block", + }, + 6: { + size: "xs", + weight: "semibold", + tone: "subdued", + className: "mt-1 block", + }, +}; + +function renderHeading(level: HeadingLevel) { + const MarkdownHeading = ({ children }: ElementProps) => ( + + {children} + + ); + + return MarkdownHeading; +} + +const baseComponents = { + p: ({ children }: ElementProps) => ( + + {children} + + ), + a: ({ href, children }: ElementProps & { href?: string }) => + href ? ( + + {children} + + ) : ( + <>{children} + ), + h1: renderHeading(1), + h2: renderHeading(2), + h3: renderHeading(3), + h4: renderHeading(4), + h5: renderHeading(5), + h6: renderHeading(6), + ul: ({ children, className }: ElementProps) => ( +
    + {children} +
+ ), + ol: ({ children }: ElementProps) => ( +
    + {children} +
+ ), + li: ({ children, className }: ElementProps) => ( +
  • input]:mr-1.5 [&>input]:align-middle", + className?.includes("task-list-item") && "list-none", + )} + > + {children} +
  • + ), + blockquote: ({ children }: ElementProps) => ( +
    + {children} +
    + ), + code: ({ children }: ElementProps) => ( + {children} + ), + pre: ({ children }: ElementProps) => ( +
    +      {children}
    +    
    + ), + table: ({ children }: ElementProps) => ( +
    + {children}
    +
    + ), + thead: ({ children }: ElementProps) => ( + {children} + ), + tr: ({ children }: ElementProps) => {children}, + th: ({ children }: ElementProps) => ( + {children} + ), + td: ({ children }: ElementProps) => ( + {children} + ), + img: ({ alt, src }: { alt?: string; src?: string }) => ( + {alt} + ), + hr: () => , +} satisfies Components; +const AltTextOnlyImage = ({ alt }: { alt?: string }) => + alt ? ( + + {alt} + + ) : null; + +interface MarkdownProps { + body: string; + components?: Components; + urlTransform?: (url: string) => string; +} + +export const Markdown = ({ + body, + components, + urlTransform = defaultUrlTransform, +}: MarkdownProps) => ( + + {body} + +); + +export const UntrustedMarkdown = ({ + body, + components, +}: Omit) => ( + toAbsoluteHttpUrl(url) ?? ""} + /> +); diff --git a/src/components/shared/ReactFlow/FlowSidebar/FlowSidebar.tsx b/src/components/shared/ReactFlow/FlowSidebar/FlowSidebar.tsx index 0cce2bde3c..1685a3b572 100644 --- a/src/components/shared/ReactFlow/FlowSidebar/FlowSidebar.tsx +++ b/src/components/shared/ReactFlow/FlowSidebar/FlowSidebar.tsx @@ -1,6 +1,6 @@ import { BlockStack } from "@/components/ui/layout"; import { VerticalResizeHandle } from "@/components/ui/resize-handle"; -import { BOTTOM_FOOTER_HEIGHT, TOP_NAV_HEIGHT } from "@/utils/constants"; +import { BOTTOM_FOOTER_HEIGHT, contentHeight } from "@/utils/constants"; import FileActions from "./sections/FileActions"; import GraphComponents from "./sections/GraphComponents"; @@ -19,7 +19,7 @@ const FlowSidebar = () => { width: `${DEFAULT_WIDTH}px`, minWidth: `${MIN_WIDTH}px`, maxWidth: `${MAX_WIDTH}px`, - maxHeight: `calc(100vh - ${TOP_NAV_HEIGHT}px - ${BOTTOM_FOOTER_HEIGHT}px)`, + maxHeight: contentHeight(BOTTOM_FOOTER_HEIGHT), }} > diff --git a/src/components/ui/layout.tsx b/src/components/ui/layout.tsx index cfa35d31a9..8f83ffa71b 100644 --- a/src/components/ui/layout.tsx +++ b/src/components/ui/layout.tsx @@ -49,6 +49,8 @@ interface BlockStackProps fill?: boolean; /** Additional CSS classes */ className?: string; + /** Focusability, for a stack that scrolls and so must be keyboard-operable */ + tabIndex?: number; } export const BlockStack = forwardRef< @@ -88,6 +90,7 @@ const inlineStackVariants = cva("flex flex-row", { align: { start: "justify-start", center: "justify-center", + "safe-center": "justify-center-safe", end: "justify-end", "space-around": "justify-around", "space-between": "justify-between", @@ -127,6 +130,8 @@ interface InlineStackProps fill?: boolean; /** Additional CSS classes */ className?: string; + /** Focusability, for a stack that scrolls and so must be keyboard-operable */ + tabIndex?: number; } export const InlineStack = forwardRef< diff --git a/src/config/announcements.ts b/src/config/announcements.ts deleted file mode 100644 index c7aa2d32f1..0000000000 --- a/src/config/announcements.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface Announcement { - id: string; - title: string; - body: string; - variant?: "warning" | "info" | "success" | "error"; - dismissible?: boolean; - expiresAt?: string; // ISO date string, e.g. "2026-04-01" -} - -declare global { - interface Window { - __TANGLE_ANNOUNCEMENTS__?: Announcement[]; - } -} diff --git a/src/config/bannerTestSource.ts b/src/config/bannerTestSource.ts new file mode 100644 index 0000000000..b17e54b3a3 --- /dev/null +++ b/src/config/bannerTestSource.ts @@ -0,0 +1,18 @@ +import { + BANNER_SOURCE_EVENT, + type TangleBanner, + type TangleBannerSource, +} from "@/config/banners"; + +export function installRawSource(source: unknown) { + window.__TANGLE_BANNER_SOURCE__ = source as TangleBannerSource; + window.dispatchEvent(new CustomEvent(BANNER_SOURCE_EVENT)); +} + +export function installSource(banners: Partial[]) { + installRawSource({ + version: 1, + getSnapshot: () => banners, + subscribe: () => () => {}, + }); +} diff --git a/src/config/banners.test.ts b/src/config/banners.test.ts new file mode 100644 index 0000000000..42780719cb --- /dev/null +++ b/src/config/banners.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { installRawSource } from "@/config/bannerTestSource"; + +import { + getBannersSnapshot, + refreshBanners, + subscribeToBanners, +} from "./banners"; + +function staticSource(getSnapshot: () => unknown, refresh?: () => void) { + return { + version: 1, + getSnapshot, + subscribe: () => () => {}, + ...(refresh ? { refresh } : {}), + }; +} + +describe("banners", () => { + afterEach(() => { + delete window.__TANGLE_BANNER_SOURCE__; + }); + + describe("getBannersSnapshot", () => { + it("is empty and reference-stable with no source installed", () => { + expect(getBannersSnapshot()).toEqual([]); + expect(getBannersSnapshot()).toBe(getBannersSnapshot()); + }); + + it("ignores a source declaring an unsupported major version", () => { + installRawSource({ + version: 2, + getSnapshot: () => [{ id: "a", title: "Later contract", body: "" }], + subscribe: () => () => {}, + }); + + expect(getBannersSnapshot()).toEqual([]); + }); + + it("ignores a malformed source", () => { + installRawSource({ + version: 1, + getSnapshot: [], + subscribe: () => () => {}, + }); + expect(getBannersSnapshot()).toEqual([]); + + installRawSource({ version: 1, getSnapshot: () => [] }); + expect(getBannersSnapshot()).toEqual([]); + + installRawSource("not a source"); + expect(getBannersSnapshot()).toEqual([]); + }); + + it("survives a source whose getSnapshot throws", () => { + installRawSource( + staticSource(() => { + throw new Error("host is broken"); + }), + ); + + expect(getBannersSnapshot()).toEqual([]); + }); + + it("returns the same reference until the content changes", () => { + let published: unknown = [{ id: "a", title: "First", body: "" }]; + installRawSource(staticSource(() => published)); + + const first = getBannersSnapshot(); + expect(first).toBe(getBannersSnapshot()); + expect(first).toHaveLength(1); + + published = [{ id: "b", title: "Second", body: "" }]; + const second = getBannersSnapshot(); + + expect(second).not.toBe(first); + expect(second[0]?.id).toBe("b"); + }); + + it("returns the same reference to a host that rebuilds its array per read", () => { + const banners = [{ id: "a", title: "First", body: "" }]; + installRawSource( + staticSource(() => banners.map((banner) => ({ ...banner }))), + ); + + const first = getBannersSnapshot(); + + expect(first).toBe(getBannersSnapshot()); + expect(first).toHaveLength(1); + }); + + it("ignores a non-array snapshot", () => { + installRawSource(staticSource(() => ({ banners: [] }))); + expect(getBannersSnapshot()).toEqual([]); + }); + + it("coerces an unrecognised variant to info", () => { + installRawSource( + staticSource(() => [ + { id: "a", title: "Notice", body: "", variant: "catastrophe" }, + ]), + ); + + expect(getBannersSnapshot()[0]?.variant).toBe("info"); + }); + + it("keeps the four known variants", () => { + installRawSource( + staticSource(() => [ + { id: "a", title: "A", body: "", variant: "info" }, + { id: "b", title: "B", body: "", variant: "warning" }, + { id: "c", title: "C", body: "", variant: "success" }, + { id: "d", title: "D", body: "", variant: "error" }, + ]), + ); + + expect(getBannersSnapshot().map((banner) => banner.variant)).toEqual([ + "info", + "warning", + "success", + "error", + ]); + }); + + it("drops entries without a usable id", () => { + installRawSource( + staticSource(() => [ + { title: "No id", body: "" }, + { id: " ", title: "Blank id", body: "" }, + { id: {}, title: "Object id", body: "" }, + { id: "kept", title: "Kept", body: "" }, + ]), + ); + + expect(getBannersSnapshot().map((banner) => banner.id)).toEqual(["kept"]); + }); + + it("drops entries with neither a title nor a non-blank body", () => { + installRawSource( + staticSource(() => [ + { id: "a", title: " ", body: " \n " }, + { id: "b", title: "Title only", body: "" }, + { id: "c", title: "", body: "Body only" }, + ]), + ); + + expect(getBannersSnapshot().map((banner) => banner.id)).toEqual([ + "b", + "c", + ]); + }); + + it("drops an action whose url is not absolute http(s)", () => { + installRawSource( + staticSource(() => [ + { + id: "a", + title: "Script url", + body: "", + action: { url: "javascript:alert(1)", text: "Run" }, + }, + { + id: "b", + title: "Relative url", + body: "", + action: { url: "/internal/page", text: "Open" }, + }, + { + id: "c", + title: "Absolute url", + body: "", + action: { url: "https://example.com/notes", text: "Read" }, + }, + ]), + ); + + const [scriptUrl, relativeUrl, absoluteUrl] = getBannersSnapshot(); + + expect(scriptUrl?.action).toBeUndefined(); + expect(relativeUrl?.action).toBeUndefined(); + expect(absoluteUrl?.action).toEqual({ + url: "https://example.com/notes", + text: "Read", + }); + }); + + it("falls back to default action text when the host omits it", () => { + installRawSource( + staticSource(() => [ + { + id: "a", + title: "Notice", + body: "", + action: { url: "https://example.com" }, + }, + ]), + ); + + expect(getBannersSnapshot()[0]?.action?.text).toBe("Learn more"); + }); + + it("only marks a banner dismissible when the host says so explicitly", () => { + installRawSource( + staticSource(() => [ + { id: "a", title: "A", body: "", dismissible: true }, + { id: "b", title: "B", body: "", dismissible: "yes" }, + { id: "c", title: "C", body: "" }, + ]), + ); + + expect(getBannersSnapshot().map((banner) => banner.dismissible)).toEqual([ + true, + undefined, + undefined, + ]); + }); + + it("preserves body whitespace, which is meaningful in Markdown", () => { + installRawSource( + staticSource(() => [ + { id: "a", title: "Notice", body: " - indented list item" }, + ]), + ); + + expect(getBannersSnapshot()[0]?.body).toBe(" - indented list item"); + }); + }); + + describe("subscribeToBanners", () => { + it("forwards host notifications and unsubscribes cleanly", () => { + const hostListeners = new Set<() => void>(); + installRawSource({ + version: 1, + getSnapshot: () => [], + subscribe: (listener: () => void) => { + hostListeners.add(listener); + return () => hostListeners.delete(listener); + }, + }); + + const listener = vi.fn(); + const unsubscribe = subscribeToBanners(listener); + + hostListeners.forEach((hostListener) => hostListener()); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + expect(hostListeners.size).toBe(0); + }); + + it("binds to a source installed after the subscription", () => { + const listener = vi.fn(); + const unsubscribe = subscribeToBanners(listener); + + expect(listener).not.toHaveBeenCalled(); + + installRawSource(staticSource(() => [])); + + expect(listener).toHaveBeenCalled(); + unsubscribe(); + }); + + it("tolerates a source that does not return an unsubscribe function", () => { + installRawSource({ + version: 1, + getSnapshot: () => [], + subscribe: () => undefined, + }); + + const unsubscribe = subscribeToBanners(vi.fn()); + expect(() => unsubscribe()).not.toThrow(); + }); + }); + + describe("refreshBanners", () => { + it("asks the host to re-fetch when it supports it", () => { + const refresh = vi.fn(); + installRawSource(staticSource(() => [], refresh)); + + refreshBanners(); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("is a no-op when refresh is absent or throws", () => { + installRawSource(staticSource(() => [])); + expect(() => refreshBanners()).not.toThrow(); + + installRawSource( + staticSource( + () => [], + () => { + throw new Error("host is broken"); + }, + ), + ); + expect(() => refreshBanners()).not.toThrow(); + }); + }); +}); diff --git a/src/config/banners.ts b/src/config/banners.ts new file mode 100644 index 0000000000..859d56bd9b --- /dev/null +++ b/src/config/banners.ts @@ -0,0 +1,172 @@ +import { isRecord } from "@/utils/typeGuards"; +import { toAbsoluteHttpUrl } from "@/utils/URL"; + +export interface TangleBannerAction { + url: string; + text: string; +} + +export interface TangleBanner { + id: string; + title: string; + body: string; + variant: "info" | "warning" | "success" | "error"; + dismissible?: boolean; + action?: TangleBannerAction; +} + +export interface TangleBannerSource { + version: 1; + getSnapshot: () => TangleBanner[]; + subscribe: (listener: () => void) => () => void; + refresh?: () => void; +} + +declare global { + interface Window { + __TANGLE_BANNER_SOURCE__?: TangleBannerSource; + } +} + +export const BANNER_SOURCE_EVENT = "tangle:banner-source"; + +const SUPPORTED_SOURCE_VERSION = 1; +const VARIANTS: TangleBanner["variant"][] = [ + "info", + "warning", + "success", + "error", +]; +const DEFAULT_ACTION_TEXT = "Learn more"; + +const EMPTY_BANNERS: readonly TangleBanner[] = Object.freeze([]); + +let lastSnapshotSignature: string | null = null; +let lastValidatedSnapshot: readonly TangleBanner[] = EMPTY_BANNERS; + +function isBannerSource(value: unknown): value is TangleBannerSource { + return ( + isRecord(value) && + value.version === SUPPORTED_SOURCE_VERSION && + typeof value.getSnapshot === "function" && + typeof value.subscribe === "function" + ); +} + +function getBannerSource(): TangleBannerSource | null { + if (typeof window === "undefined") return null; + const source: unknown = window.__TANGLE_BANNER_SOURCE__; + return isBannerSource(source) ? source : null; +} + +function readTrimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function readId(value: unknown): string { + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return readTrimmedString(value); +} + +function readVariant(value: unknown): TangleBanner["variant"] { + return VARIANTS.find((variant) => variant === value) ?? "info"; +} + +function readAction(value: unknown): TangleBannerAction | null { + if (!isRecord(value)) return null; + + const url = toAbsoluteHttpUrl(value.url); + if (!url) return null; + + return { url, text: readTrimmedString(value.text) || DEFAULT_ACTION_TEXT }; +} + +function readBanner(value: unknown): TangleBanner | null { + if (!isRecord(value)) return null; + + const id = readId(value.id); + if (!id) return null; + + const title = readTrimmedString(value.title); + const body = typeof value.body === "string" ? value.body : ""; + if (!title && !body.trim()) return null; + + const action = readAction(value.action); + + return { + id, + title, + body, + variant: readVariant(value.variant), + ...(value.dismissible === true ? { dismissible: true } : {}), + ...(action ? { action } : {}), + }; +} + +function readRawSnapshot(): unknown { + const source = getBannerSource(); + if (!source) return null; + try { + return source.getSnapshot(); + } catch { + return null; + } +} + +export function getBannersSnapshot(): readonly TangleBanner[] { + const raw = readRawSnapshot(); + if (!Array.isArray(raw)) return EMPTY_BANNERS; + + const validated = raw + .map(readBanner) + .filter((banner): banner is TangleBanner => banner !== null); + + const signature = JSON.stringify(validated); + if (signature === lastSnapshotSignature) return lastValidatedSnapshot; + + lastSnapshotSignature = signature; + lastValidatedSnapshot = + validated.length === 0 ? EMPTY_BANNERS : Object.freeze(validated); + + return lastValidatedSnapshot; +} + +function subscribeToSource(listener: () => void): (() => void) | null { + const source = getBannerSource(); + if (!source) return null; + try { + const unsubscribe = source.subscribe(listener); + return typeof unsubscribe === "function" ? unsubscribe : null; + } catch { + return null; + } +} + +export function subscribeToBanners(listener: () => void): () => void { + if (typeof window === "undefined") return () => {}; + + let unsubscribe = subscribeToSource(listener); + + const rebindToSource = () => { + unsubscribe?.(); + unsubscribe = subscribeToSource(listener); + listener(); + }; + + window.addEventListener(BANNER_SOURCE_EVENT, rebindToSource); + + return () => { + unsubscribe?.(); + window.removeEventListener(BANNER_SOURCE_EVENT, rebindToSource); + }; +} + +export function refreshBanners(): void { + const source = getBannerSource(); + if (typeof source?.refresh !== "function") return; + try { + source.refresh(); + } catch { + // A host that fails to re-fetch must not surface an error in the app. + } +} diff --git a/src/hooks/useBannerInbox.ts b/src/hooks/useBannerInbox.ts new file mode 100644 index 0000000000..c806525454 --- /dev/null +++ b/src/hooks/useBannerInbox.ts @@ -0,0 +1,169 @@ +import { useSyncExternalStore } from "react"; + +import type { TangleBanner } from "@/config/banners"; +import { useBanners } from "@/hooks/useBanners"; +import { getStorage } from "@/utils/typedStorage"; + +const STORAGE_KEYS = [ + "dismissed-banners", + "hidden-banners", + "read-banners", +] as const; + +type BannerInboxKey = (typeof STORAGE_KEYS)[number]; + +const storage = getStorage>(); + +const PROMOTION_ORDER: Record = { + error: 0, + warning: 1, + success: 2, + info: 3, +}; + +const hiddenForThisSession = new Set(); +const dismissedForThisSession = new Set(); + +let isOpen = false; +let revision = 0; +const listeners = new Set<() => void>(); + +function publish() { + revision += 1; + listeners.forEach((listener) => listener()); +} + +function handleStorage(event: StorageEvent) { + if (STORAGE_KEYS.some((key) => key === event.key)) publish(); +} + +function subscribe(listener: () => void): () => void { + if (listeners.size === 0) window.addEventListener("storage", handleStorage); + listeners.add(listener); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) + window.removeEventListener("storage", handleStorage); + }; +} + +function getRevision(): number { + return revision; +} + +function readIds(key: BannerInboxKey): string[] { + const stored = storage.getItem(key); + return Array.isArray(stored) ? stored : []; +} + +interface InboxState { + dismissedIds: Set; + hiddenIds: Set; + readIds: Set; +} + +let cachedState: InboxState | null = null; +let cachedRevision = -1; + +function getInboxState(revision: number): InboxState { + if (cachedState && cachedRevision === revision) return cachedState; + + cachedRevision = revision; + cachedState = { + dismissedIds: new Set([ + ...readIds("dismissed-banners"), + ...dismissedForThisSession, + ]), + hiddenIds: new Set([...readIds("hidden-banners"), ...hiddenForThisSession]), + readIds: new Set(readIds("read-banners")), + }; + + return cachedState; +} + +function addIds(key: BannerInboxKey, ids: string[]) { + const stored = readIds(key); + const merged = [...new Set([...stored, ...ids])]; + if (merged.length === stored.length) return; + storage.setItem(key, merged); +} + +export function closeBannerInbox() { + isOpen = false; + publish(); +} + +function openBannerInbox(banners: readonly TangleBanner[]) { + addIds( + "read-banners", + banners.map((banner) => banner.id), + ); + isOpen = true; + publish(); +} + +function hideStrip(banners: readonly TangleBanner[]) { + banners + .filter((banner) => !banner.dismissible) + .forEach((banner) => hiddenForThisSession.add(banner.id)); + + addIds( + "hidden-banners", + banners.filter((banner) => banner.dismissible).map((banner) => banner.id), + ); + publish(); +} + +function showStrip() { + hiddenForThisSession.clear(); + storage.setItem("hidden-banners", []); + publish(); +} + +function dismissBanner(banner: TangleBanner) { + addIds("dismissed-banners", [banner.id]); + if (!readIds("dismissed-banners").includes(banner.id)) + dismissedForThisSession.add(banner.id); + publish(); +} + +export interface BannerInbox { + banners: readonly TangleBanner[]; + showing: readonly TangleBanner[]; + unreadCount: number; + isStripHidden: boolean; + isOpen: boolean; + setOpen: (open: boolean) => void; + hideStrip: () => void; + showStrip: () => void; + dismiss: (banner: TangleBanner) => void; +} + +export function useBannerInbox(): BannerInbox { + const banners = useBanners(); + const revision = useSyncExternalStore(subscribe, getRevision); + const { + dismissedIds, + hiddenIds, + readIds: readIdSet, + } = getInboxState(revision); + + const listed = banners + .filter((banner) => !dismissedIds.has(banner.id)) + .sort((a, b) => PROMOTION_ORDER[a.variant] - PROMOTION_ORDER[b.variant]); + + const showing = listed.filter((banner) => !hiddenIds.has(banner.id)); + + return { + banners: listed, + showing, + unreadCount: listed.filter((banner) => !readIdSet.has(banner.id)).length, + isStripHidden: showing.length === 0 && listed.length > 0, + isOpen, + setOpen: (open) => (open ? openBannerInbox(listed) : closeBannerInbox()), + hideStrip: () => hideStrip(listed), + showStrip, + dismiss: dismissBanner, + }; +} diff --git a/src/hooks/useBanners.ts b/src/hooks/useBanners.ts new file mode 100644 index 0000000000..2886cf3df9 --- /dev/null +++ b/src/hooks/useBanners.ts @@ -0,0 +1,28 @@ +import { useEffect, useSyncExternalStore } from "react"; + +import { + getBannersSnapshot, + refreshBanners, + subscribeToBanners, + type TangleBanner, +} from "@/config/banners"; + +let isRefreshWatcherStarted = false; + +function startRefreshWatcher() { + if (isRefreshWatcherStarted) return; + isRefreshWatcherStarted = true; + + refreshBanners(); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") refreshBanners(); + }); +} + +export function useBanners(): readonly TangleBanner[] { + const banners = useSyncExternalStore(subscribeToBanners, getBannersSnapshot); + + useEffect(startRefreshWatcher, []); + + return banners; +} diff --git a/src/routes/Dashboard/DashboardComponentsV2View.tsx b/src/routes/Dashboard/DashboardComponentsV2View.tsx index 82c6aff14a..19247a1ef4 100644 --- a/src/routes/Dashboard/DashboardComponentsV2View.tsx +++ b/src/routes/Dashboard/DashboardComponentsV2View.tsx @@ -81,7 +81,7 @@ import { import type { ComponentFolder } from "@/types/componentLibrary"; import type { ComponentReference } from "@/utils/componentSpec"; import { componentMetadata } from "@/utils/componentTracking"; -import { HOURS, TOP_NAV_HEIGHT } from "@/utils/constants"; +import { contentHeight, HOURS } from "@/utils/constants"; import { getComponentName } from "@/utils/getComponentName"; import { tracking } from "@/utils/tracking"; @@ -1564,7 +1564,7 @@ export const DashboardComponentsV2View = () => { // `min-height: auto` defaults fight against that flex chain.
    {/* Header zone: page title, description, search input. shrink-0 so it never gets squeezed by the body below. */} diff --git a/src/routes/Dashboard/DashboardComponentsView.tsx b/src/routes/Dashboard/DashboardComponentsView.tsx index 9b0fb95b19..83c79f228c 100644 --- a/src/routes/Dashboard/DashboardComponentsView.tsx +++ b/src/routes/Dashboard/DashboardComponentsView.tsx @@ -31,7 +31,7 @@ import { fetchAndStoreComponentLibrary } from "@/services/componentService"; import type { ComponentFolder } from "@/types/componentLibrary"; import type { ComponentReference } from "@/utils/componentSpec"; import { componentMetadata } from "@/utils/componentTracking"; -import { TOP_NAV_HEIGHT } from "@/utils/constants"; +import { contentHeight } from "@/utils/constants"; import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; import { getComponentName } from "@/utils/getComponentName"; import { tracking } from "@/utils/tracking"; @@ -389,7 +389,7 @@ export function DashboardComponentsView() { return (
    {/* Left: component list */}
    diff --git a/src/routes/Dashboard/DashboardHomeView.tsx b/src/routes/Dashboard/DashboardHomeView.tsx index 9a0b15203d..56bb6961d1 100644 --- a/src/routes/Dashboard/DashboardHomeView.tsx +++ b/src/routes/Dashboard/DashboardHomeView.tsx @@ -1,7 +1,6 @@ import { Link } from "@tanstack/react-router"; import { RunSection } from "@/components/Home/RunSection/RunSection"; -import { AnnouncementBanners } from "@/components/shared/AnnouncementBanners"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Tooltip, @@ -184,8 +183,6 @@ const RecentComponentsPreview = () => { export function DashboardHomeView() { return ( - -
    diff --git a/src/routes/Dashboard/DashboardLayout.tsx b/src/routes/Dashboard/DashboardLayout.tsx index b96c2db15f..fffa19f012 100644 --- a/src/routes/Dashboard/DashboardLayout.tsx +++ b/src/routes/Dashboard/DashboardLayout.tsx @@ -13,12 +13,12 @@ import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider import { APP_ROUTES } from "@/routes/appRoutes"; import { ABOUT_URL, + contentHeight, DOCUMENTATION_URL, GIT_COMMIT, GIT_REPO_URL, GIVE_FEEDBACK_URL, PRIVACY_POLICY_URL, - TOP_NAV_HEIGHT, } from "@/utils/constants"; interface SidebarItem { @@ -84,7 +84,7 @@ export function DashboardLayout() { return (
    {/* Sidebar — fixed height, independent scroll */}
    diff --git a/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx b/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx index 9a2e983253..f24905c949 100644 --- a/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx +++ b/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx @@ -5,12 +5,13 @@ import { type ReactNode, useContext, } from "react"; -import Markdown, { defaultUrlTransform } from "react-markdown"; -import remarkGfm from "remark-gfm"; +import { defaultUrlTransform } from "react-markdown"; +import { + INLINE_CODE_CLASS, + Markdown, +} from "@/components/shared/Markdown/Markdown"; import { Link } from "@/components/ui/link"; -import { Separator } from "@/components/ui/separator"; -import { Heading, Paragraph } from "@/components/ui/typography"; import { getComponentQueryKey } from "@/hooks/useHydrateComponentReference"; import type { ComponentRefData } from "@/routes/v2/shared/components/AiChat/types"; import { CodeBlock } from "@/routes/v2/shared/components/CodeBlock"; @@ -22,8 +23,6 @@ import { EntityChip } from "./EntityChip"; const ENTITY_PROTOCOL = "entity://"; const COMPONENT_PROTOCOL = "component://"; -const INLINE_CODE_CLASS = "rounded bg-muted px-1 py-0.5 text-xs font-mono"; - const ComponentRefsContext = createContext< Record | undefined >(undefined); @@ -135,74 +134,13 @@ function MarkdownCode({ ); } -const markdownComponents = { - h1: ({ children }: { children?: ReactNode }) => ( - - {children} - - ), - h2: ({ children }: { children?: ReactNode }) => ( - - {children} - - ), - h3: ({ children }: { children?: ReactNode }) => ( - - {children} - - ), - h4: ({ children }: { children?: ReactNode }) => ( - - {children} - - ), - p: ({ children }: { children?: ReactNode }) => ( - - {children} - - ), - ul: ({ children }: { children?: ReactNode }) => ( -
      {children}
    - ), - ol: ({ children }: { children?: ReactNode }) => ( -
      {children}
    - ), - li: ({ children }: { children?: ReactNode }) => ( -
  • {children}
  • - ), - blockquote: ({ children }: { children?: ReactNode }) => ( -
    - {children} -
    - ), - table: ({ children }: { children?: ReactNode }) => ( -
    - {children}
    -
    - ), - thead: ({ children }: { children?: ReactNode }) => ( - {children} - ), - tbody: ({ children }: { children?: ReactNode }) => {children}, - tr: ({ children }: { children?: ReactNode }) => ( - {children} - ), - th: ({ children }: { children?: ReactNode }) => ( - {children} - ), - td: ({ children }: { children?: ReactNode }) => ( - {children} - ), - hr: () => , +const chatComponents = { a: MarkdownLink, code: MarkdownCode, + // `MarkdownCode` already renders a fenced block as a `CodeBlock`, which brings + // its own surround. pre: ({ children }: { children?: ReactNode }) => <>{children}, -} as const; +}; export function renderMarkdown( text: string, @@ -210,12 +148,10 @@ export function renderMarkdown( ): ReactNode { const markdown = ( - {text} - + /> ); if (!componentReferences) return markdown; diff --git a/src/routes/v2/shared/components/AppMenuActions.tsx b/src/routes/v2/shared/components/AppMenuActions.tsx index 20de56208f..eda5d69f8f 100644 --- a/src/routes/v2/shared/components/AppMenuActions.tsx +++ b/src/routes/v2/shared/components/AppMenuActions.tsx @@ -4,6 +4,7 @@ import { AiModelQuickSelect } from "@/components/layout/AiModelQuickSelect"; import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill"; import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers"; import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication"; +import { BannerInbox } from "@/components/shared/Banners/BannerInbox"; import TooltipButton from "@/components/shared/Buttons/TooltipButton"; import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle"; import { RunVersionToggle } from "@/components/shared/RunVersionToggle"; @@ -30,6 +31,7 @@ export function AppMenuActions() { + {tourMode ? ( { + if (typeof value !== "string" || !value.trim()) return null; + try { + const url = new URL(value.trim()); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.toString(); + } catch { + return null; + } +}; + const normalizeUrl = (url: string) => { if (url.trim() === "") { return ""; @@ -223,4 +234,5 @@ export { getIdOrTitleFromPath, isGithubUrl, normalizeUrl, + toAbsoluteHttpUrl, }; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index d2cbe36dac..445e751ba2 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -55,6 +55,15 @@ export const USER_COMPONENTS_LIST_NAME = "user_components"; export const TOP_NAV_HEIGHT = 56; // px export const BOTTOM_FOOTER_HEIGHT = 0; // px +export const CONTENT_OFFSET_VAR = "--tangle-content-offset"; + +export function contentHeight(subtractPx = 0): string { + const offset = `var(${CONTENT_OFFSET_VAR}, ${TOP_NAV_HEIGHT}px)`; + return subtractPx > 0 + ? `calc(100vh - ${offset} - ${subtractPx}px)` + : `calc(100vh - ${offset})`; +} + export const DEFAULT_NODE_DIMENSIONS = { w: 300, h: undefined }; export const FONT_SIZE_MD = 12;