diff --git a/packages/app/cypress/e2e/embed-scatter.cy.ts b/packages/app/cypress/e2e/embed-scatter.cy.ts new file mode 100644 index 00000000..5db0a2a8 --- /dev/null +++ b/packages/app/cypress/e2e/embed-scatter.cy.ts @@ -0,0 +1,54 @@ +describe('Embed — Scatter Chart', () => { + describe('default URL', () => { + before(() => { + cy.visit('/embed/scatter'); + }); + + it('renders the embed root container', () => { + cy.get('[data-testid="embed-root"]').should('exist'); + }); + + it('does not render the site header or footer', () => { + cy.get('[data-testid="header"]').should('not.exist'); + cy.get('[data-testid="footer"]').should('not.exist'); + }); + + it('renders an SVG chart with real data', () => { + // Wait for chart to render — skeleton or figure + cy.get('[data-testid="embed-scatter-figure"]', { timeout: 15000 }).should('exist'); + cy.get('[data-testid="embed-scatter-figure"]').find('svg').should('exist'); + cy.contains('No data available').should('not.exist'); + }); + + it('shows the SemiAnalysis InferenceX attribution link', () => { + cy.get('[data-testid="embed-attribution"]') + .should('exist') + .should('contain.text', 'SemiAnalysis InferenceX'); + }); + + it('attribution link points to the canonical /inference URL with seeded params', () => { + cy.get('[data-testid="embed-attribution"]') + .should('have.attr', 'href') + .and('include', '/inference?') + .and('include', 'g_model=DeepSeek-R1-0528') + .and('include', 'i_metric=y_tpPerGpu'); + }); + }); + + describe('custom params', () => { + before(() => { + cy.visit('/embed/scatter?model=dsr1&isl=8192&osl=1024&precisions=fp4&y=costh'); + }); + + it('renders chart with the custom y metric', () => { + cy.get('[data-testid="embed-scatter-figure"]', { timeout: 15000 }).should('exist'); + cy.contains('No data available').should('not.exist'); + }); + + it('canonical link reflects the y metric override', () => { + cy.get('[data-testid="embed-attribution"]') + .should('have.attr', 'href') + .and('include', 'i_metric=y_costh'); + }); + }); +}); diff --git a/packages/app/next.config.ts b/packages/app/next.config.ts index 39ab4487..69d6972a 100644 --- a/packages/app/next.config.ts +++ b/packages/app/next.config.ts @@ -15,6 +15,27 @@ const nextConfig: NextConfig = { { hostname: 'substack-post-media.s3.amazonaws.com' }, ], }, + // /embed/* routes are explicitly framable from any origin so partner sites + // can iframe them. All other routes are non-framable (frame-ancestors + // 'self' + X-Frame-Options: SAMEORIGIN). Order matters: the more specific + // /embed/* match comes first. + headers() { + return Promise.resolve([ + { + source: '/embed/:path*', + headers: [{ key: 'Content-Security-Policy', value: 'frame-ancestors *' }], + }, + { + // Negative lookahead excludes /embed/* so its CSP isn't overridden + // by the more general non-framable headers. + source: '/((?!embed/).*)', + headers: [ + { key: 'Content-Security-Policy', value: "frame-ancestors 'self'" }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + ], + }, + ]); + }, }; const hasPostHogKeys = Boolean( diff --git a/packages/app/src/app/embed/layout.tsx b/packages/app/src/app/embed/layout.tsx new file mode 100644 index 00000000..169b3878 --- /dev/null +++ b/packages/app/src/app/embed/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; + +export default function EmbedLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/app/src/app/embed/scatter/embed-scatter-client.tsx b/packages/app/src/app/embed/scatter/embed-scatter-client.tsx new file mode 100644 index 00000000..83426f2c --- /dev/null +++ b/packages/app/src/app/embed/scatter/embed-scatter-client.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +import EmbedScatterDisplay from '@/components/embed/embed-scatter-display'; +import { EmbedAttribution } from '@/components/embed/embed-attribution'; +import { GlobalFilterProvider } from '@/components/GlobalFilterContext'; +import { InferenceProvider } from '@/components/inference/InferenceContext'; +import { UnofficialRunProvider } from '@/components/unofficial-run-provider'; +import { track } from '@/lib/analytics'; +import { type EmbedParams, embedParamsToUrlState } from '@/lib/embed-params'; +import { seedUrlState } from '@/lib/url-state'; + +interface Props { + params: EmbedParams; + canonicalHref: string; +} + +/** + * Client component for `/embed/scatter`. Seeds the internal URL-state cache + * synchronously before any provider mounts so the first render of the chart + * already reflects the requested embed params, then wraps the providers and + * the chart display. + * + * Lives outside the `(dashboard)` route group, so we re-establish the + * provider stack here (`UnofficialRunProvider` → `GlobalFilterProvider` → + * `InferenceProvider`). `QueryProvider` is in the root layout and inherits. + */ +export default function EmbedScatterClient({ params, canonicalHref }: Props) { + const seededRef = useRef(false); + if (!seededRef.current) { + seedUrlState(embedParamsToUrlState(params)); + seededRef.current = true; + } + + // Fire `embed_view` once on mount with referrer + host so external embed + // traffic is attributable. Strict mode in dev double-fires effects, but + // that's only in dev — production fires once. + const trackedRef = useRef(false); + useEffect(() => { + if (trackedRef.current) return; + trackedRef.current = true; + const referrer = typeof document !== 'undefined' ? document.referrer : ''; + let embedHost: string; + try { + embedHost = referrer ? new URL(referrer).host : ''; + } catch { + embedHost = ''; + } + const gpus = params.gpus ? params.gpus.split(',').filter(Boolean) : []; + track('embed_view', { + embed_chart: 'scatter', + chart_type: params.chart, + model: params.model, + sequence: `${params.isl}/${params.osl}`, + precisions: params.precisions, + gpus, + gpu_count: gpus.length, + y_metric: params.y, + referrer, + embed_host: embedHost, + }); + }, []); + + return ( + + + +
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/packages/app/src/app/embed/scatter/page.tsx b/packages/app/src/app/embed/scatter/page.tsx new file mode 100644 index 00000000..df7e14cd --- /dev/null +++ b/packages/app/src/app/embed/scatter/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from 'next'; + +import { SITE_URL } from '@semianalysisai/inferencex-constants'; +import { buildCanonicalHref, readEmbedParams } from '@/lib/embed-params'; + +import EmbedScatterClient from './embed-scatter-client'; + +export const metadata: Metadata = { + title: 'InferenceX — Embedded Chart', + robots: { index: false, follow: false }, +}; + +export default async function EmbedScatterPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const sp = await searchParams; + const flat: Record = {}; + for (const [k, v] of Object.entries(sp)) { + flat[k] = Array.isArray(v) ? v[0] : v; + } + const params = readEmbedParams(flat); + const canonicalHref = buildCanonicalHref(params, SITE_URL); + return ; +} diff --git a/packages/app/src/app/layout.tsx b/packages/app/src/app/layout.tsx index 93088655..25449ae3 100644 --- a/packages/app/src/app/layout.tsx +++ b/packages/app/src/app/layout.tsx @@ -7,6 +7,7 @@ import type { Metadata } from 'next'; import { DM_Sans } from 'next/font/google'; import localFont from 'next/font/local'; +import { ChromeGate } from '@/components/chrome-gate'; import { Footer } from '@/components/footer/footer'; import { Header } from '@/components/header/header'; import { CircuitBackground } from '@/components/circuit-background'; @@ -191,9 +192,13 @@ export default async function RootLayout({ disableTransitionOnChange > -
+ +
+
{children}
-