diff --git a/docs/open-lab-banner/screenshots/banner-dark.png b/docs/open-lab-banner/screenshots/banner-dark.png
new file mode 100644
index 00000000..3cd4c78e
Binary files /dev/null and b/docs/open-lab-banner/screenshots/banner-dark.png differ
diff --git a/docs/open-lab-banner/screenshots/banner-desktop.png b/docs/open-lab-banner/screenshots/banner-desktop.png
new file mode 100644
index 00000000..9b0ee8a5
Binary files /dev/null and b/docs/open-lab-banner/screenshots/banner-desktop.png differ
diff --git a/docs/open-lab-banner/screenshots/banner-mobile.png b/docs/open-lab-banner/screenshots/banner-mobile.png
new file mode 100644
index 00000000..3e961024
Binary files /dev/null and b/docs/open-lab-banner/screenshots/banner-mobile.png differ
diff --git a/docs/open-lab-banner/verification.md b/docs/open-lab-banner/verification.md
new file mode 100644
index 00000000..7380eafb
--- /dev/null
+++ b/docs/open-lab-banner/verification.md
@@ -0,0 +1,20 @@
+# Open Lab homepage invitation
+
+A small website-only follow-up to the old embedded prototype in PR #162. The existing approved invitation copy and placement (after the hero, before Focus Areas) are preserved. The destination is now exactly **https://open-lab-two.vercel.app/**, the standalone app's stable Vercel origin.
+
+The link is a normal native anchor: same-tab navigation, keyboard Enter, browser modifiers/new-tab and no-JavaScript fallback work without an app integration. This change does not bring `/lab/` routes, OAuth, app dependencies, DNS or infrastructure into the website. The optional earlier circular departure animation is not included.
+
+## Verification
+
+Runtime source: `a88b10e54ecbf36b7e96f71fb482aaf329d75ae3` (including the final banner-label contrast correction).
+
+- pnpm 10 frozen install passed. No package/lockfile changes.
+- Full standard suite: **116 passed, 0 failed, 0 skipped**, including a new RED/GREEN homepage placement, exact destination and native-anchor test.
+- TypeScript passed.
+- Production build passed. Local verification temporarily capped Next's build worker count at 1; that operational cap was restored before commit and is not in the PR. Existing upstream data-cache/closed-round snapshot warnings occurred; this banner does not repair those independent sources.
+- Actual production-built localhost page: one banner with exact destination, correct placement; 1440px light/dark and 390px checks and screenshots. The dark capture was taken after the existing theme transition settled.
+- Native keyboard Enter from the banner reached `https://open-lab-two.vercel.app/lab/` in the headed browser. Direct HTTPS root also returned HTTP 200 after redirecting there; the real hosted sign-in form was visible. No account sign-in or public write was attempted.
+
+- Independent candidate review found one blocker: the original 12px light-mode label had 3.54:1 contrast. Fixed using the existing banner-local `text-dark-blue` token while retaining `dark:text-blue`; RED/GREEN regression now checks actual palette tokens. Final fresh-skeptical review of that finding and recaptured production-browser screenshots passed: measured 6.38:1 light and 5.40:1 dark, readable mobile wrapping, no clipping or destination change. Full suite and build were repeated after the correction.
+
+Screenshots in `screenshots/` are the actual locally served website at the runtime revision above, not hosted deploy-preview screenshots. No production website merge is authorized by this PR preparation.
diff --git a/scripts/homepage-order.test.mjs b/scripts/homepage-order.test.mjs
index b4bebb18..b2fc0e31 100644
--- a/scripts/homepage-order.test.mjs
+++ b/scripts/homepage-order.test.mjs
@@ -18,6 +18,34 @@ function text(node) {
return node && typeof node === 'object' ? text(node.props?.children) : ''
}
+test('homepage invites visitors to the standalone Open Lab between the hero and focus areas', async (t) => {
+ // Only the remote editable-copy boundary is mocked; inspect the real homepage.
+ t.mock.method(globalThis, 'fetch', async () => Response.json({
+ data: { orgPlresearchPage: { edges: [] } },
+ }))
+ const nodes = elements(await HomePage())
+ const banners = nodes.filter((node) => node.type === 'a' && text(node).includes('Introducing Open Lab'))
+ assert.equal(banners.length, 1, 'render exactly one native Open Lab invitation link')
+ const banner = banners[0]
+ assert.equal(banner.props.href, 'https://open-lab-two.vercel.app/', 'navigate directly to the separate app origin')
+ assert.equal(banner.props.onClick, undefined, 'preserve native navigation without JavaScript interception')
+ assert.equal(banner.props.target, undefined, 'navigate in the current tab by default')
+ assert.ok(text(banner).includes('Made something that makes science easier?'))
+ assert.ok(text(banner).includes('Explore the lab →'))
+ const label = elements(banner).find(node => node.type === 'span' && text(node) === 'Introducing Open Lab')
+ const { readFileSync } = await import('node:fs')
+ const css = readFileSync('src/app/globals.css', 'utf8')
+ const token = name => css.match(new RegExp(`--color-${name}:\\s*(#[0-9a-fA-F]{6})`))[1]
+ const luminance = hex => { const rgb = hex.slice(1).match(/../g).map(value => parseInt(value, 16) / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4); return rgb[0] * .2126 + rgb[1] * .7152 + rgb[2] * .0722 }
+ const color = label.props.className.split(' ').find(value => /^text-(?:dark-blue|blue)$/.test(value)).slice(5)
+ const a = luminance(token(color)), b = luminance(token('gray-100'))
+ assert.ok((Math.max(a, b) + .05) / (Math.min(a, b) + .05) >= 4.5, '12px invitation label must meet normal-text AA contrast')
+ const heroIndex = nodes.findIndex((node) => node.type === 'h1')
+ const focusIndex = nodes.findIndex((node) => node.props?.id === 'focus-areas')
+ assert.ok(heroIndex >= 0 && nodes.indexOf(banner) > heroIndex, 'invitation follows the hero')
+ assert.ok(focusIndex > nodes.indexOf(banner), 'invitation precedes focus areas')
+})
+
test('homepage places the existing latest carousel between focus areas and the innovation-chasm graphic', async (t) => {
// Only the remote editable-copy boundary is mocked; render the real homepage.
t.mock.method(globalThis, 'fetch', async () => Response.json({
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 6f1a1883..94586025 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -173,6 +173,19 @@ export default async function HomePage() {
+
+