diff --git a/src/server/middleware.ts b/src/server/middleware.ts
index 5a5805a..c414539 100644
--- a/src/server/middleware.ts
+++ b/src/server/middleware.ts
@@ -107,6 +107,9 @@ export function securityHeaders(
`frame-ancestors 'none'`,
`object-src 'none'`,
`script-src 'self' https://crawlproof.com`,
+ // The one ad unit is a cross-origin CrawlProof frame (/api/ads/frame), so
+ // it costs this entry and nothing else: no script, no style, no storage.
+ `frame-src https://crawlproof.com`,
`style-src 'self' 'unsafe-inline'`,
// Employer logos and avatars come from wherever the employer hosts them.
// 'self' has to be listed explicitly: omitting it works in production and
diff --git a/src/views/ad-unit.tsx b/src/views/ad-unit.tsx
new file mode 100644
index 0000000..a61b4d7
--- /dev/null
+++ b/src/views/ad-unit.tsx
@@ -0,0 +1,66 @@
+/**
+ * One CrawlProof ad unit, as a script-free cross-origin iframe.
+ *
+ * `ad.js` fills a placeholder with a `srcdoc` iframe, and a srcdoc document
+ * inherits this page's Content-Security-Policy: the creative's inline styles
+ * and remote images would only render with a far looser policy than this
+ * board sends. `/api/ads/frame` serves the same creative as a real
+ * cross-origin document, so the cost here is one `frame-src` entry, no
+ * third-party JavaScript and nothing written to this origin's storage.
+ * Impressions are metered server-side exactly as on the script path.
+ *
+ * The format is `text_link`, the one fluid creative: 40px tall at any width,
+ * with its own "Sponsored" mark. Banners are laid out at a fixed pixel width
+ * and would be cropped in a narrow column, and choosing by viewport needs a
+ * script. No `&theme=`: the frame answers prefers-color-scheme itself.
+ */
+import type { FC } from 'hono/jsx';
+
+export const AD_ORIGIN = 'https://crawlproof.com';
+export const AD_SLOT = '9c3d6e3d-ae1f-43ff-b6d9-4f42f11725ce';
+
+/** First path segments that never carry an ad: flows, accounts, dashboards, admin. */
+const NO_AD_SEGMENTS = new Set([
+ 'tracker',
+ 'fleets',
+ 'post',
+ 'post-a-job',
+ 'me',
+ 'inbox',
+ 'notifications',
+ 'applications',
+ 'admin',
+ 'login',
+ 'auth',
+ 'device',
+ 'invoices',
+ 'coinpay',
+ 'revisions',
+]);
+
+/** Public, indexable pages only. A noindex page is a dashboard or a one-off flow. */
+export function showsAd(path: string, noindex?: boolean): boolean {
+ if (noindex) return false;
+ const first = path.split(/[/?#]/)[1] ?? '';
+ return !NO_AD_SEGMENTS.has(first);
+}
+
+export const AdUnit: FC<{ format?: 'text_link' }> = ({ format = 'text_link' }) => {
+ const src = `${AD_ORIGIN}/api/ads/frame?slot=${encodeURIComponent(AD_SLOT)}&format=${encodeURIComponent(format)}`;
+ return (
+
+ );
+};
diff --git a/src/views/layout.tsx b/src/views/layout.tsx
index 11f3f1c..305c33f 100644
--- a/src/views/layout.tsx
+++ b/src/views/layout.tsx
@@ -16,6 +16,7 @@ import type { SkillCount } from '../core/skills.ts';
import { pathForQuery } from '../core/landing.ts';
import { EMPTY_QUERY } from '../schema/query.ts';
import { pfsFooter } from './pfs-footer.ts';
+import { AdUnit, showsAd } from './ad-unit.tsx';
export interface PageProps {
title: string;
@@ -268,6 +269,7 @@ export const Layout: FC> = (props) => {