diff --git a/.builderrules b/.builderrules index aee7329cce..cbdc1f6b69 100644 --- a/.builderrules +++ b/.builderrules @@ -1 +1 @@ -Always verify if a changeset is required \ No newline at end of file +Always verify if a changeset is required diff --git a/packages/docs/actions/crawl-design-reference.test.ts b/packages/docs/actions/crawl-design-reference.test.ts new file mode 100644 index 0000000000..a2f095e3c6 --- /dev/null +++ b/packages/docs/actions/crawl-design-reference.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ssrfSafeFetch = vi.hoisted(() => vi.fn()); +const isBlockedExtensionUrlWithDns = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core/extensions/url-safety", () => ({ + isBlockedExtensionUrlWithDns, + ssrfSafeFetch, +})); + +import crawlDesignReference from "./crawl-design-reference"; + +function extractionPayload(overrides: Record = {}) { + return { + url: "https://example.com/", + signals: { + title: "Example Studio", + description: + "Design software that helps teams build better products together every day worldwide with confidence", + }, + designSystemData: { + colors: { + primary: "#1E3346", + secondary: "", + accent: "#0DA0A0", + }, + typography: { + headingFont: "Sora", + bodyFont: "Poppins", + }, + }, + screenshotDataUrl: "data:image/png;base64,discarded", + ...overrides, + }; +} + +describe("crawl-design-reference", () => { + beforeEach(() => { + ssrfSafeFetch.mockReset(); + isBlockedExtensionUrlWithDns.mockReset(); + isBlockedExtensionUrlWithDns.mockResolvedValue(false); + }); + + it("maps hosted design extraction into the Slides contract", async () => { + ssrfSafeFetch + .mockResolvedValueOnce( + new Response(JSON.stringify(extractionPayload()), { + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + colors: [ + { name: "Blue Whale", requestedHex: "#1e3346" }, + { name: "Persian Green", requestedHex: "#0da0a0" }, + ], + }), + { headers: { "content-type": "application/json" } }, + ), + ); + + const output = await crawlDesignReference.run({ + url: "https://example.com", + }); + + const extractionUrl = new URL(ssrfSafeFetch.mock.calls[0][0]); + expect(extractionUrl.origin).toBe("https://freedesign.md"); + expect(extractionUrl.pathname).toBe("/api/extract"); + expect(extractionUrl.searchParams.get("url")).toBe("https://example.com/"); + expect(extractionUrl.searchParams.get("format")).toBe("json"); + expect(output).toEqual({ + title: "Example Studio", + description: + "Design software that helps teams build better products together every day worldwide with confidence", + primaryColor: "#1e3346", + primaryColorName: "Blue Whale", + accentColor: "#0da0a0", + accentColorName: "Persian Green", + headingFont: "Sora", + bodyFont: "Poppins", + }); + }); + + it("normalizes bare HSL channel values", async () => { + ssrfSafeFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify( + extractionPayload({ + designSystemData: { + colors: { primary: "20 90% 48%", accent: "47 100% 96%" }, + typography: {}, + }, + }), + ), + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ colors: [] }))); + + const output = await crawlDesignReference.run({ + url: "https://saltandwisdom.com", + }); + + expect(output).toMatchObject({ + primaryColor: "#e9560c", + accentColor: "#fffbeb", + }); + }); + + it("uses secondary color when no accent is returned", async () => { + ssrfSafeFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify( + extractionPayload({ + designSystemData: { + colors: { primary: "#145AB4", secondary: "#F0781E" }, + typography: {}, + }, + }), + ), + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ colors: [] }))); + + const output = await crawlDesignReference.run({ + url: "https://example.com", + }); + + expect(output).toMatchObject({ + primaryColor: "#145ab4", + accentColor: "#f0781e", + headingFont: null, + bodyFont: null, + }); + }); + + it("rejects unresolved challenge pages", async () => { + ssrfSafeFetch.mockResolvedValueOnce( + new Response( + JSON.stringify( + extractionPayload({ + signals: { title: "Just a moment...", description: "" }, + }), + ), + ), + ); + + await expect( + crawlDesignReference.run({ url: "https://example.com" }), + ).rejects.toThrow("blocked automated browser inspection"); + }); + + it("rejects private hosts before calling the extraction service", async () => { + isBlockedExtensionUrlWithDns.mockResolvedValueOnce(true); + + await expect( + crawlDesignReference.run({ url: "https://private.example.com" }), + ).rejects.toThrow("Private or internal"); + expect(ssrfSafeFetch).not.toHaveBeenCalled(); + }); + + it("rejects credential-bearing URLs before calling the extraction service", async () => { + await expect( + crawlDesignReference.run({ url: "https://user:secret@example.com" }), + ).rejects.toThrow("embedded credentials"); + expect(ssrfSafeFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/docs/actions/crawl-design-reference.ts b/packages/docs/actions/crawl-design-reference.ts new file mode 100644 index 0000000000..ec5787c2a2 --- /dev/null +++ b/packages/docs/actions/crawl-design-reference.ts @@ -0,0 +1,242 @@ +import { defineAction } from "@agent-native/core/action"; +import { + isBlockedExtensionUrlWithDns, + ssrfSafeFetch, +} from "@agent-native/core/extensions/url-safety"; +import { z } from "zod"; + +const MAX_EXTRACTION_BYTES = 12 * 1024 * 1024; +const MAX_COLOR_NAME_BYTES = 64_000; + +type ExtractionPayload = { + url?: string; + signals?: { + title?: string; + description?: string; + }; + designSystemData?: { + colors?: { + primary?: string; + secondary?: string; + accent?: string; + }; + typography?: { + headingFont?: string; + bodyFont?: string; + }; + }; +}; + +async function readBoundedText(response: Response, maxBytes: number) { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + await response.body?.cancel(); + throw new Error("That response is too large to inspect."); + } + + const reader = response.body?.getReader(); + if (!reader) return ""; + + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + bytes += value.byteLength; + if (bytes > maxBytes) { + await reader.cancel(); + throw new Error("That response is too large to inspect."); + } + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); +} + +function truncateWords(value: string, maxWords: number) { + return value + .replace(/\s+/g, " ") + .trim() + .split(" ") + .slice(0, maxWords) + .join(" "); +} + +function colorToHex(value: string | null | undefined) { + if (!value) return null; + const trimmed = value.trim(); + const hex = trimmed.match(/^#([\da-f]{3,8})$/i)?.[1]; + if (hex) { + const rgb = + hex.length === 3 || hex.length === 4 + ? hex + .slice(0, 3) + .split("") + .map((character) => character.repeat(2)) + .join("") + : hex.slice(0, 6); + return rgb.toLowerCase(); + } + const rgb = trimmed.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i); + if (rgb) { + return rgb + .slice(1, 4) + .map((channel) => + Math.max(0, Math.min(255, Math.round(Number(channel)))) + .toString(16) + .padStart(2, "0"), + ) + .join(""); + } + + const hsl = trimmed.match(/^(-?[\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/); + if (!hsl) return null; + const hue = ((Number(hsl[1]) % 360) + 360) % 360; + const saturation = Math.max(0, Math.min(100, Number(hsl[2]))) / 100; + const lightness = Math.max(0, Math.min(100, Number(hsl[3]))) / 100; + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const hueSegment = hue / 60; + const secondary = chroma * (1 - Math.abs((hueSegment % 2) - 1)); + const [red, green, blue] = + hueSegment < 1 + ? [chroma, secondary, 0] + : hueSegment < 2 + ? [secondary, chroma, 0] + : hueSegment < 3 + ? [0, chroma, secondary] + : hueSegment < 4 + ? [0, secondary, chroma] + : hueSegment < 5 + ? [secondary, 0, chroma] + : [chroma, 0, secondary]; + const offset = lightness - chroma / 2; + return [red, green, blue] + .map((channel) => + Math.round((channel + offset) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join(""); +} + +function normalizedColor(value: string | null | undefined) { + const hex = colorToHex(value); + return hex ? `#${hex}` : null; +} + +async function extractDesignReference(url: URL) { + if (await isBlockedExtensionUrlWithDns(url.href)) { + throw new Error("Private or internal website URLs are not supported."); + } + const endpoint = new URL("https://freedesign.md/api/extract"); + endpoint.searchParams.set("url", url.href); + endpoint.searchParams.set("format", "json"); + const response = await ssrfSafeFetch( + endpoint.href, + { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(60_000), + }, + { maxRedirects: 2 }, + ); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`Design extraction returned ${response.status}.`); + } + return JSON.parse( + await readBoundedText(response, MAX_EXTRACTION_BYTES), + ) as ExtractionPayload; +} + +async function getColorNames(colors: Array) { + const values = colors + .map(colorToHex) + .filter((value): value is string => Boolean(value)); + if (!values.length) return new Map(); + const endpoint = new URL("https://api.color.pizza/v1/"); + endpoint.searchParams.set("values", values.join(",")); + endpoint.searchParams.set("goodnamesonly", "true"); + const response = await ssrfSafeFetch( + endpoint.href, + { signal: AbortSignal.timeout(8_000) }, + { maxRedirects: 3 }, + ); + if (!response.ok) + throw new Error(`Color lookup returned ${response.status}.`); + const payload = JSON.parse( + await readBoundedText(response, MAX_COLOR_NAME_BYTES), + ) as { + colors?: Array<{ name?: string; requestedHex?: string }>; + }; + return new Map( + (payload.colors ?? []).flatMap((color) => { + const requestedHex = color.requestedHex?.replace(/^#/, "").toLowerCase(); + return requestedHex && color.name ? [[requestedHex, color.name]] : []; + }), + ); +} + +export default defineAction({ + description: + "Inspect a public website and return bounded visual style metadata for a slide-deck prompt.", + schema: z.object({ + url: z.string().url().max(2_048), + }), + outputSchema: z.object({ + title: z.string(), + description: z.string(), + primaryColor: z.string().nullable(), + primaryColorName: z.string().nullable(), + accentColor: z.string().nullable(), + accentColorName: z.string().nullable(), + headingFont: z.string().nullable(), + bodyFont: z.string().nullable(), + }), + outputErrorStrategy: "strict", + http: { method: "GET" }, + readOnly: true, + requiresAuth: false, + agentTool: false, + run: async ({ url }) => { + const parsedUrl = new URL(url); + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + throw new Error("Enter a public HTTP or HTTPS URL."); + } + if (parsedUrl.username || parsedUrl.password) { + throw new Error("URLs with embedded credentials are not supported."); + } + + const extracted = await extractDesignReference(parsedUrl); + const title = extracted.signals?.title?.trim() || parsedUrl.hostname; + if (/just a moment|attention required|security verification/i.test(title)) { + throw new Error("That site blocked automated browser inspection."); + } + const primaryColor = normalizedColor( + extracted.designSystemData?.colors?.primary, + ); + const accentColor = normalizedColor( + extracted.designSystemData?.colors?.accent || + extracted.designSystemData?.colors?.secondary, + ); + let colorNames = new Map(); + try { + colorNames = await getColorNames([primaryColor, accentColor]); + } catch { + colorNames = new Map(); + } + + return { + title: title.slice(0, 120), + description: truncateWords(extracted.signals?.description ?? "", 14), + primaryColor, + primaryColorName: colorNames.get(colorToHex(primaryColor) ?? "") ?? null, + accentColor, + accentColorName: colorNames.get(colorToHex(accentColor) ?? "") ?? null, + headingFont: + extracted.designSystemData?.typography?.headingFont?.trim() || null, + bodyFont: + extracted.designSystemData?.typography?.bodyFont?.trim() || null, + }; + }, +}); diff --git a/packages/docs/app/components/ClipsQuickStart.tsx b/packages/docs/app/components/ClipsQuickStart.tsx new file mode 100644 index 0000000000..8893f9efbb --- /dev/null +++ b/packages/docs/app/components/ClipsQuickStart.tsx @@ -0,0 +1,187 @@ +import { useT } from "@agent-native/core/client/i18n"; +import { + IconBrowser, + IconCamera, + IconDeviceDesktop, + IconDeviceScreen, + IconMicrophone, + IconPlayerRecord, + IconVideo, +} from "@tabler/icons-react"; +import { useState } from "react"; + +import { trackEvent } from "./TemplateCard"; + +type RecordingMode = "screen+camera" | "screen" | "camera"; +type CaptureSource = "window" | "browser" | "monitor"; + +export function ClipsQuickStart() { + const t = useT(); + const [mode, setMode] = useState("screen+camera"); + const [surface, setSurface] = useState("browser"); + const [microphoneEnabled, setMicrophoneEnabled] = useState(true); + const tq = (key: string) => t(`templateLanding.clips.quickStart.${key}`); + const recordUrl = new URL("https://clips.agent-native.com/record"); + recordUrl.searchParams.set("mode", mode); + if (mode !== "camera") recordUrl.searchParams.set("surface", surface); + + const modes = [ + { + value: "screen+camera" as const, + label: tq("modeScreenCamera"), + Icon: IconVideo, + }, + { + value: "screen" as const, + label: tq("modeScreenOnly"), + Icon: IconDeviceScreen, + }, + { value: "camera" as const, label: tq("modeCameraOnly"), Icon: IconCamera }, + ]; + const sources = [ + { + value: "window" as const, + label: tq("surfaceWindow"), + Icon: IconDeviceDesktop, + }, + { + value: "browser" as const, + label: tq("surfaceBrowser"), + Icon: IconBrowser, + }, + { + value: "monitor" as const, + label: tq("surfaceScreen"), + Icon: IconDeviceScreen, + }, + ]; + + const optionClass = (active: boolean) => + `flex min-h-24 flex-col items-start justify-between rounded-xl border p-4 text-start transition ${ + active + ? "border-[var(--docs-accent)] bg-[var(--bg)] ring-1 ring-[var(--docs-accent)]" + : "border-[var(--docs-border)] bg-[var(--bg)] hover:border-[var(--fg-secondary)]" + }`; + + return ( +
+
+
+ {tq("recordingMode")} +
+
+ {modes.map(({ value, label, Icon }) => ( + + ))} +
+
+ + {mode !== "camera" && ( +
+
+ {tq("captureSource")} +
+
+ {sources.map(({ value, label, Icon }) => ( + + ))} +
+
+ )} + +
+
+ {tq("audioSource")} +
+
+ {[ + { + label: tq("defaultMicrophone"), + Icon: IconMicrophone, + enabled: microphoneEnabled, + setEnabled: setMicrophoneEnabled, + }, + ].map(({ label, Icon, enabled, setEnabled }) => ( + + ))} +
+
+ + +
+ ); +} diff --git a/packages/docs/app/components/Footer.tsx b/packages/docs/app/components/Footer.tsx index 4a32242e59..8f15e1ae7e 100644 --- a/packages/docs/app/components/Footer.tsx +++ b/packages/docs/app/components/Footer.tsx @@ -26,6 +26,12 @@ export default function Footer() { > {t("header.skills")} + + {t("footer.pricing")} + - import("./SearchModal").then((m) => ({ default: m.SearchModal })), -); +const feedbackTriggerClassName = "secondary-button"; -const feedbackTriggerClassName = - "h-8 items-center rounded-md border border-[var(--docs-border)] bg-transparent px-3 text-sm text-[var(--fg-secondary)] transition hover:border-[var(--fg-secondary)] hover:text-[var(--fg)]"; - -function SearchTrigger({ - onClick, - label, - placeholder, -}: { - onClick: () => void; - label: string; - placeholder: string; -}) { - return ( - - ); -} +const TRY_NOW_CLASSNAME = "primary-button"; function HamburgerIcon() { return ( @@ -98,37 +58,11 @@ function CloseIcon() { ); } -function useSearchModal() { - const [open, setOpen] = useState(false); - const [everOpened, setEverOpened] = useState(false); - - useEffect(() => { - function onKey(e: KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - setEverOpened(true); - setOpen(true); - } - } - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, []); - - const openModal = () => { - setEverOpened(true); - setOpen(true); - }; - - return { open, setOpen, everOpened, openModal }; -} - export default function Header() { - const { open, setOpen, everOpened, openModal } = useSearchModal(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const location = useLocation(); const navigate = useNavigate(); const { locale } = useLocale(); - const { theme } = useDocsTheme(); const isHome = sitePathForLocale(location.pathname, DEFAULT_DOCS_LOCALE) === "/"; const [scrolled, setScrolled] = useState(false); @@ -141,19 +75,13 @@ export default function Header() { if (!response.ok) return; const svg = await response.text(); await navigator.clipboard.writeText(svg); - } catch { - // Network or clipboard-permission failures have nothing actionable to - // surface here; just avoid copying an error response or throwing an - // unhandled rejection. + } catch (error) { + console.error("Failed to copy SVG to clipboard", error); } }; useEffect(() => { if (!isHome) return; - // AgentSidebar wraps content in an overflow-auto div, so the window - // typically doesn't scroll. Listening on document with capture: true - // catches scroll events from any descendant scroll container, regardless - // of when AgentSidebar mounts or which element is actually scrolling. const onScroll = (e: Event) => { const target = e.target; let top = 0; @@ -180,243 +108,248 @@ export default function Header() { const feedbackLabel = t("feedback.label"); const feedbackPlaceholder = t("feedback.placeholder"); - return ( - <> -
- - - {/* Mobile dropdown menu */} - {mobileMenuOpen && ( -
-
- { - closeMobileMenu(); - openModal(); - }} - label={t("header.searchAria")} - placeholder={t("header.searchPlaceholder")} + - - -
- - isActive ? "header-link is-active" : "header-link" + + Agent-Native + Agent-Native + + + + + void copySvgToClipboard("/agent-native-icon-dark.svg") } - onClick={closeMobileMenu} > - {t("header.docs")} - - - isActive ? "header-link is-active" : "header-link" + {t("header.copyLogoSvg")} + + + void copySvgToClipboard("/agent-native-logo-dark.svg") } - onClick={closeMobileMenu} - > - {t("header.templates")} - - - GitHub - - ↗ - - - - Discord - - ↗ - - + {t("header.copyWordmark")} + + navigate(localizedPath("/brand"))}> + {t("header.brandAssets")} + + + + + {/* Desktop nav links */} +
+ + `px-2 py-1 rounded-md transition ${ + isActive + ? "text-[var(--fg)] font-medium" + : "text-[#9a9997] hover:text-[var(--fg)]" + }` + } + > + {t("header.docs")} + + + `px-2 py-1 rounded-md transition ${ + isActive + ? "text-[var(--fg)] font-medium" + : "text-[#9a9997] hover:text-[var(--fg)]" + }` + } + > + {t("header.templates")} + + + GitHub + + + Discord + +
+ + {/* Right actions */} +
+
+ + + {/* Mobile dropdown menu */} + {mobileMenuOpen && ( +
+ + `text-base font-medium transition ${ + isActive + ? "text-[var(--fg)]" + : "text-[#9a9997] hover:text-[var(--fg)]" + }` + } + onClick={closeMobileMenu} + > + {t("header.docs")} + + + `text-base font-medium transition ${ + isActive + ? "text-[var(--fg)]" + : "text-[#9a9997] hover:text-[var(--fg)]" + }` + } + onClick={closeMobileMenu} + > + {t("header.templates")} + + + GitHub + + + Discord + +
{feedbackLabel} @@ -433,14 +366,35 @@ export default function Header() { align="start" side="bottom" /> + {activeTemplate ? ( + { + handleTryNowClick(e); + closeMobileMenu(); + }} + > + {tryNowLabel} + + ) : ( + { + handleTryNowClick(e); + closeMobileMenu(); + }} + > + {tryNowLabel} + + )}
- )} -
- {everOpened && ( - - setOpen(false)} /> - + )} - + ); } diff --git a/packages/docs/app/components/SectionDivider.tsx b/packages/docs/app/components/SectionDivider.tsx new file mode 100644 index 0000000000..5bb4017e42 --- /dev/null +++ b/packages/docs/app/components/SectionDivider.tsx @@ -0,0 +1,24 @@ +type SectionDividerProps = { + className?: string; + showOnSmallScreens?: boolean; +}; + +export function SectionDivider({ + className = "", + showOnSmallScreens = false, +}: SectionDividerProps) { + const responsiveSizeClassName = showOnSmallScreens + ? "grid h-12 sm:h-20 lg:h-[120px]" + : "hidden lg:grid lg:h-[120px]"; + + return ( +