From e242cf9d8fda29f95bf1c8d107814d8d67a5b28e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:10:24 +0800 Subject: [PATCH 1/4] feat(site): prerender public pages and measure discovery paths Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .github/workflows/frontstage-pages.yml | 15 ++ apps/presentation/site/README.md | 65 ++++++ apps/presentation/site/index.html | 1 + apps/presentation/site/package.json | 3 +- .../site/scripts/add-analytics.mjs | 32 +++ .../site/scripts/analytics-client.js | 60 ++++++ .../site/scripts/analytics-smoke.mjs | 88 ++++++++ apps/presentation/site/scripts/build.mjs | 78 +++++++ apps/presentation/site/scripts/seo-smoke.mjs | 50 +++++ apps/presentation/site/src/App.tsx | 109 ++++------ apps/presentation/site/src/LhtbBrief.tsx | 26 +-- .../site/src/SweMarathonBrief.tsx | 16 +- apps/presentation/site/src/page-metadata.ts | 38 ++++ apps/presentation/site/src/render-static.tsx | 11 + apps/presentation/site/src/styles.css | 198 +++++++----------- .../site/src/usePublicPageNavigation.ts | 23 +- .../dashboard-frontstage-browser-smoke.mjs | 12 ++ examples/export-frontstage-share-bundle.mjs | 10 +- examples/frontstage-share-bundle-smoke.mjs | 23 +- 19 files changed, 613 insertions(+), 245 deletions(-) create mode 100644 apps/presentation/site/scripts/add-analytics.mjs create mode 100644 apps/presentation/site/scripts/analytics-client.js create mode 100644 apps/presentation/site/scripts/analytics-smoke.mjs create mode 100644 apps/presentation/site/scripts/build.mjs create mode 100644 apps/presentation/site/scripts/seo-smoke.mjs create mode 100644 apps/presentation/site/src/page-metadata.ts create mode 100644 apps/presentation/site/src/render-static.tsx diff --git a/.github/workflows/frontstage-pages.yml b/.github/workflows/frontstage-pages.yml index bf15bf77bf..03a9445be9 100644 --- a/.github/workflows/frontstage-pages.yml +++ b/.github/workflows/frontstage-pages.yml @@ -29,6 +29,7 @@ on: - "examples/export-frontstage-share-bundle.mjs" - "examples/blog-bilingual-index-smoke.mjs" - "examples/frontstage-share-bundle-smoke.mjs" + - "examples/dashboard-frontstage-browser-smoke.mjs" - "examples/dev-book-browser-smoke.mjs" - "examples/readme-star-history-smoke.py" - "examples/frontstage-stargazer-fetch-smoke.py" @@ -67,6 +68,7 @@ on: - "examples/export-frontstage-share-bundle.mjs" - "examples/blog-bilingual-index-smoke.mjs" - "examples/frontstage-share-bundle-smoke.mjs" + - "examples/dashboard-frontstage-browser-smoke.mjs" - "examples/dev-book-browser-smoke.mjs" - "examples/readme-star-history-smoke.py" - "examples/frontstage-stargazer-fetch-smoke.py" @@ -223,6 +225,19 @@ jobs: - name: Restore public case pages after documentation builds run: node examples/export-frontstage-share-bundle.mjs --restore-case-pages --out-dir output/frontstage-pages + - name: Validate analytics opt-in and event boundaries + run: node apps/presentation/site/scripts/analytics-smoke.mjs output/frontstage-pages/site + + - name: Configure public website analytics + env: + # Repository variable: public GA4 stream identifier, not an API secret. + # PR previews never send production analytics. + LOOPX_GA_MEASUREMENT_ID: ${{ github.event_name != 'pull_request' && vars.LOOPX_GA_MEASUREMENT_ID || '' }} + run: node apps/presentation/site/scripts/add-analytics.mjs output/frontstage-pages/site + + - name: Validate search publication + run: node apps/presentation/site/scripts/seo-smoke.mjs output/frontstage-pages/site --with-docs + - name: Validate homepage navigation and retired links in the published site run: LOOPX_PUBLIC_SITE_DIR="$PWD/output/frontstage-pages/site" npm --prefix apps/presentation/dashboard run smoke:frontstage-browser diff --git a/apps/presentation/site/README.md b/apps/presentation/site/README.md index 715090a406..fddc64ef52 100644 --- a/apps/presentation/site/README.md +++ b/apps/presentation/site/README.md @@ -58,3 +58,68 @@ not raw session logs. The full-screen viewer bundles only the two explicit `docs/assets/long-running-loop-*-trajectory.png` files. The site must not consume live LoopX state, local status feeds, private registries, raw logs, or write APIs. + +## Search publication + +`npm run build` compiles the browser app and prerenders the homepage and the +SWE-Marathon/LHTB briefs from the same React components. Each route ships its +own body, title, description, canonical URL and social metadata before any +JavaScript executes. The shared metadata module also updates translated titles +and descriptions when readers switch language. The English URL is canonical; +`?lang=zh` remains a client-rendered translation, while Blog translations have +separate static URLs and reciprocal language alternates. + +The build discovers canonical editorial URLs for `sitemap-pages.xml` and writes +`sitemap.xml`, which also references the three MkDocs sitemaps. The complete +index is ready only after the Pages workflow builds the documentation. Submit +`https://loopx-project.github.io/loopx/sitemap.xml` in the verified Search Console +property after deployment. A project-level `/loopx/robots.txt` would not control +crawling: robots rules belong at the origin root, outside this Pages artifact. + +Public outbound links send at most the origin to other HTTPS sites through +`strict-origin-when-cross-origin`; new tabs retain `noopener`. This preserves a +referral signal without sending query strings or adding tracking identifiers to outbound links. Referrals do not reveal the search query or establish +conversion attribution. Search Console access and indexing are external steps; +a successful build does not establish ranking or traffic improvement. + +Run `npm run smoke:seo` and `node scripts/analytics-smoke.mjs` after a local build (the browser smoke uses the dashboard Playwright dependency). The Pages workflow also runs it on +the complete artifact with `--with-docs`. It checks static content, route-specific +metadata, sitemap targets and public asset paths. Preview with `npm run preview`. + +## Google Analytics 4 + +Set the GitHub repository variable `LOOPX_GA_MEASUREMENT_ID` to the website's +real GA4 web stream ID (`G-…`). The Pages workflow injects measurement into +canonical public pages after all builds. An unset variable disables analytics; +PR artifacts and localhost previews do not send production events. Remove the +variable and redeploy to disable it. No ID or GA API secret is hard-coded. + +In the GA4 web stream, disable **Enhanced measurement**: this integration sends +explicit pageviews and conversion-intent events, including during MkDocs instant +navigation. Leaving automatic history, form or outbound collection on can create +duplicate events and collect fields outside this site's explicit event schema. +The loader honors Do Not Track and Global Privacy Control and disables Google +signals and ad personalization. Normal GA4 first-party measurement cookies may +be used when measurement is active; deployment owners retain responsibility for +their site's consent configuration and privacy notice. + +| Event | Meaning | +| --- | --- | +| `page_view` | A canonical public page was opened; hash and locale-only changes do not add views. | +| `setup_open` | Opened the homepage setup dialog. | +| `setup_copy` | Successfully copied an Agent prompt or Shell setup command (`setup_method`). | +| `showcase_open` | Followed the homepage demonstration CTA. | +| `github_click` | Clicked a repository or DSH plugin link (`destination`). | +| `docs_open` | Followed a link into documentation. | + +These events measure interest, not completed installs or active LoopX projects. +Use GA4 Realtime to verify one real visit and setup action after publication, +then mark `setup_copy` as a key event if it matches the reporting goal. Compare +organic landing-page visits and setup-copy/GitHub-click rates over consistent +windows. Search Console supplies website query/impression data; GitHub Traffic +supplies repository referrals. Neither dataset alone identifies the full path. + +Page locations use canonical paths and referrers use only their origin. The +integration does not send raw query strings, fragments, task text, clipboard +contents, local workspace state or operator dashboard interactions. The current +implementation does not attribute UTM campaigns separately. diff --git a/apps/presentation/site/index.html b/apps/presentation/site/index.html index 3dd2607e74..caae85a606 100644 --- a/apps/presentation/site/index.html +++ b/apps/presentation/site/index.html @@ -3,6 +3,7 @@ + ]*data-loopx-analytics[^>]*><\/script>\s*/g; +let count = 0; +async function visit(dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const file = resolve(dir, entry.name); + if (entry.isDirectory()) { await visit(file); continue; } + if (!entry.name.endsWith(".html")) continue; + const source = await readFile(file, "utf8"); + let html = source.replace(marker, ""); + const canonical = html.match(/]+(?:noindex|http-equiv="refresh")/i.test(html)) { + html = html.replace("", `\n`); + count++; + } + if (html !== source) await writeFile(file, html); + } +} +await visit(directory); +const client = resolve(directory, "site-assets/analytics.js"); +if (count) { + await mkdir(resolve(directory, "site-assets"), { recursive: true }); + await copyFile(new URL("./analytics-client.js", import.meta.url), client); +} else await rm(client, { force: true }); +console.log(id ? `GA4 configured on ${count} canonical public pages` : "GA4 disabled: measurement ID is unset"); diff --git a/apps/presentation/site/scripts/analytics-client.js b/apps/presentation/site/scripts/analytics-client.js new file mode 100644 index 0000000000..fed7f8b038 --- /dev/null +++ b/apps/presentation/site/scripts/analytics-client.js @@ -0,0 +1,60 @@ +// Public website measurement only. Never loaded into the local workspace app. +(() => { + const id = document.currentScript?.dataset.measurementId; + if (!/^G-[A-Z0-9]+$/.test(id ?? "") || location.hostname !== "loopx-project.github.io" || + navigator.doNotTrack === "1" || navigator.globalPrivacyControl || window.__loopxAnalyticsActive) return; + window.__loopxAnalyticsActive = true; + window.dataLayer = window.dataLayer || []; + function gtag() { window.dataLayer.push(arguments); } + const originOnly = (url) => { try { return new URL(url).origin; } catch { return ""; } }; + let previous = ""; + const pageFields = () => { + const canonical = document.querySelector('link[rel="canonical"]')?.href; + if (!canonical) return null; + const url = new URL(canonical); + if (url.origin !== "https://loopx-project.github.io" || !url.pathname.startsWith("/loopx/")) return null; + return { page_location: url.origin + url.pathname, page_referrer: originOnly(document.referrer), + page_title: document.title, language: new URLSearchParams(location.search).get("lang") === "zh" ? "zh-CN" : document.documentElement.lang }; + }; + const initial = pageFields(); + if (!initial) return; + gtag("js", new Date()); + gtag("set", initial); + gtag("config", id, { ...initial, send_page_view: false, + allow_google_signals: false, allow_ad_personalization_signals: false }); + const pageView = () => { + const fields = pageFields(); + if (!fields || fields.page_location === previous) return; + previous = fields.page_location; + gtag("set", fields); + gtag("event", "page_view", fields); + }; + pageView(); + const tag = document.createElement("script"); + tag.async = true; + tag.src = `https://www.googletagmanager.com/gtag/js?id=${id}`; + document.head.append(tag); + // Material's instant navigation updates the canonical element. Locale and + // fragment changes on a React page must not inflate pageview counts. + new MutationObserver(pageView).observe(document.head, { subtree: true, childList: true, attributes: true, attributeFilter: ["href"] }); + const event = (name, fields = {}) => { + const page = pageFields(); + if (page) gtag("event", name, { ...page, ...fields }); + }; + document.addEventListener("click", (e) => { + const target = e.target instanceof Element ? e.target : null; + const marked = target?.closest("[data-analytics-event]")?.dataset.analyticsEvent; + if (["setup_open", "showcase_open"].includes(marked)) event(marked); + const anchor = target?.closest("a[href]"); + if (!anchor) return; + const url = new URL(anchor.href); + if (url.hostname === "github.com" && /^\/(?:loopx-project|huangruiteng)\/loopx(?:\/|$)/.test(url.pathname)) { + event("github_click", { destination: url.pathname.includes("packages/dsh-loopx-plugin") ? "dsh_plugin" : "repository" }); + } else if (url.origin === location.origin && url.pathname.startsWith("/loopx/docs/") && url.pathname !== location.pathname) { + event("docs_open"); + } + }); + window.addEventListener("loopx:setup-copy", (e) => { + if (e.detail === "agent" || e.detail === "shell") event("setup_copy", { setup_method: e.detail }); + }); +})(); diff --git a/apps/presentation/site/scripts/analytics-smoke.mjs b/apps/presentation/site/scripts/analytics-smoke.mjs new file mode 100644 index 0000000000..1e4d20706c --- /dev/null +++ b/apps/presentation/site/scripts/analytics-smoke.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cp, mkdtemp, readFile, rm, access } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { resolve, extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const require = createRequire(resolve(root, "../dashboard/package.json")); +const { chromium } = require("playwright"); +const out = await mkdtemp(resolve(tmpdir(), "loopx-analytics-smoke-")); +const inject = (id) => spawnSync(process.execPath, [resolve(root, "scripts/add-analytics.mjs"), out], { + env: { ...process.env, LOOPX_GA_MEASUREMENT_ID: id }, encoding: "utf8", +}); +let browser; +try { + await cp(resolve(process.argv[2] ?? resolve(root, "dist")), out, { recursive: true }); + const initial = await readFile(resolve(out, "index.html"), "utf8"); + assert.equal(inject("").status, 0); + assert.equal(await readFile(resolve(out, "index.html"), "utf8"), initial, "disabled mode leaves HTML unchanged"); + assert.notEqual(inject('invalid">` : ""} +`; + const html = template + .replace(/[\s\S]*?<\/title>\s*/g, "") + .replace(/<meta\s+(?:name="description"|property="og:[^"]+")[\s\S]*?>\s*/g, "") + .replace("</head>", `${head}</head>`) + .replace('<div id="root"></div>', () => `<div id="root">${render(key)}</div>`); + const target = resolve(outDir, page.path, "index.html"); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, html); + } + // Discover static editorial pages from the built site, including both locales. + // Do not include operator shells, redirects or URLs absent from this build. + const urls = new Set(); + async function collect(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = resolve(directory, entry.name); + if (entry.isDirectory()) await collect(file); + else if (entry.name.endsWith(".html")) { + const html = await readFile(file, "utf8"); + const canonical = html.match(/<link\s+rel="canonical"\s+href="([^"]+)"/i)?.[1]; + if (canonical?.startsWith(siteUrl) && !/<meta[^>]+(?:noindex|http-equiv="refresh")/i.test(html)) urls.add(canonical); + } + } + } + await collect(outDir); + await writeFile(resolve(outDir, "sitemap-pages.xml"), `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${[...urls].sort().map((url) => ` <url><loc>${escape(url)}</loc></url>`).join("\n")}\n</urlset>\n`); + // MkDocs supplies the three documentation sitemaps in the Pages workflow. + const maps = ["sitemap-pages.xml", "docs/sitemap.xml", "docs/book/sitemap.xml", "docs/book/en/sitemap.xml"]; + await writeFile(resolve(outDir, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${maps.map((path) => ` <sitemap><loc>${siteUrl}${path}</loc></sitemap>`).join("\n")}\n</sitemapindex>\n`); +} finally { + await rm(serverDir, { recursive: true, force: true }); +} diff --git a/apps/presentation/site/scripts/seo-smoke.mjs b/apps/presentation/site/scripts/seo-smoke.mjs new file mode 100644 index 0000000000..61c8def650 --- /dev/null +++ b/apps/presentation/site/scripts/seo-smoke.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const directory = resolve(process.argv[2] ?? fileURLToPath(new URL("../dist", import.meta.url))); +const origin = "https://loopx-project.github.io/loopx/"; +const titles = new Set(); +for (const [path, subject] of [["", "long-running"], ["benchmarks/swe-marathon/", "SWE-Marathon"], ["benchmarks/lhtb/", "LHTB"]]) { + const html = await readFile(resolve(directory, path, "index.html"), "utf8"); + const title = html.match(/<title>(.*?)<\/title>/s)?.[1]; + assert(title?.toLowerCase().includes(subject.toLowerCase()), `${path}: descriptive title`); + assert(!titles.has(title), "page titles must be distinct"); + titles.add(title); + assert.equal((html.match(/<h1(?:\s|>)/g) ?? []).length, 1, `${path}: one static primary heading`); + assert((html.match(/<h2(?:\s|>)/g) ?? []).length >= 2, `${path}: real sections before JavaScript`); + assert(html.includes('id="root">') && !html.includes('id="root"></div>'), `${path}: populated React root`); + assert.equal((html.match(/rel="canonical"/g) ?? []).length, 1); + assert(html.includes(`rel="canonical" href="${origin}${path}"`), `${path}: canonical matches route`); + if (!path) { + const structured = JSON.parse(html.match(/<script type="application\/ld\+json">(.*?)<\/script>/s)?.[1] ?? "null"); + assert.equal(structured?.["@type"], "SoftwareSourceCode"); + assert.equal(structured?.codeRepository, "https://github.com/loopx-project/loopx"); + } + const description = html.match(/<meta name="description" content="([^"]+)"/)?.[1]; + assert(description?.length > 50 && description.length < 240, `${path}: useful snippet`); + assert(html.includes(`property="og:description" content="${description}"`)); + assert(html.includes(`property="og:url" content="${origin}${path}"`)); + assert(html.includes(`name="twitter:title" content="${title}"`)); + assert(!/rel="[^"]*noreferrer/.test(html), `${path}: public referrals retained`); + assert(!/file:\/\/|\/Users\/|node_modules\//.test(html), `${path}: no build paths`); + for (const [, asset] of html.matchAll(/(?:src|href)="([^"?#]*site-assets\/[^"?#]+)"/g)) { + await access(resolve(directory, "site-assets", asset.split("site-assets/")[1])); + } +} +const sitemap = await readFile(resolve(directory, "sitemap-pages.xml"), "utf8"); +const locations = [...sitemap.matchAll(/<loc>(.*?)<\/loc>/g)].map((m) => m[1]); +assert.equal(new Set(locations).size, locations.length, "no duplicate sitemap URLs"); +assert(locations.includes(origin) && locations.includes(`${origin}blog/zh/`)); +for (const url of locations) { + assert(url.startsWith(origin) && !/[?#]/.test(url), `canonical sitemap URL: ${url}`); + const path = url.slice(origin.length); + const html = await readFile(resolve(directory, path, path.endsWith("/") || !path ? "index.html" : ""), "utf8"); + assert(html.includes(`rel="canonical" href="${url}"`), `self-canonical sitemap target: ${url}`); +} +if (process.argv.includes("--with-docs")) { + const index = await readFile(resolve(directory, "sitemap.xml"), "utf8"); + for (const [, url] of index.matchAll(/<loc>(.*?)<\/loc>/g)) await access(resolve(directory, url.slice(origin.length))); +} +console.log(`SEO publication: three prerendered routes, ${locations.length} canonical sitemap targets, assets and metadata passed`); diff --git a/apps/presentation/site/src/App.tsx b/apps/presentation/site/src/App.tsx index dcbd4b1fdd..960df9ba75 100644 --- a/apps/presentation/site/src/App.tsx +++ b/apps/presentation/site/src/App.tsx @@ -25,16 +25,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, ReactNode } from "react"; import { usePublicPageNavigation } from "./usePublicPageNavigation"; -type Language = "en" | "zh"; +import issueEvidenceUrl from "../../../../docs/assets/long-running-loop-openviking-trajectory.png?url"; +import mlEvidenceUrl from "../../../../docs/assets/long-running-loop-ml-experiment-trajectory.png?url"; -const issueEvidenceUrl = new URL( - "../../../../docs/assets/long-running-loop-openviking-trajectory.png", - import.meta.url, -).href; -const mlEvidenceUrl = new URL( - "../../../../docs/assets/long-running-loop-ml-experiment-trajectory.png", - import.meta.url, -).href; +type Language = "en" | "zh"; const setupPrompts: Record<Language, string> = { en: `Connect the current project to LoopX: https://github.com/loopx-project/loopx @@ -69,15 +63,13 @@ loopx status`; const content = { en: { nav: ["Product", "Workflow", "Learn"], - eyebrow: "Open · Provider-neutral · Stateful", + eyebrow: "The control plane for long-running AI agents", title: { - first: "Your agents keep", - secondPrefix: "the ", - secondAccent: "night shift", - thirdPrefix: "You keep the ", - thirdAccent: "judgment", + first: "Long-running agents.", + secondPrefix: "Lasting ", + secondAccent: "progress", }, - body: "LoopX runs on top of any agent harness, providing long-horizon state, semantic decisions, governance, recovery, and human-agent collaboration while authority, gates, todos, quota, and evidence stay in one loop.", + body: "Turn complex work into durable goals, coordinated tasks, and reviewable results. LoopX keeps your agents moving across sessions. You keep the judgment.", copy: "Get started", setup: { eyebrow: "Set up LoopX", @@ -93,19 +85,19 @@ const content = { }, demo: "See in action", panel: { - label: "GOAL CONTROL PLANE", - title: "Ship a reviewable result", + label: "ONE GOAL · ACROSS SESSIONS", + title: "Ship the next release", status: "On track", running: "Agent work", researcher: "Research and implementation", gate: "Human gate", - gateBody: "Review the proposed boundary", + gateBody: "Merge needs your approval", waiting: "Waiting", evidence: "Evidence", evidenceBody: "Validation and handoff written", complete: "Complete", next: "Next safe action", - nextBody: "Wait for approval, continue independent P1 work", + nextBody: "Keep independent tasks moving while review waits", }, proof: [ ["Explicit authority", "Agents move fast without silently taking control."], @@ -184,15 +176,13 @@ const content = { }, zh: { nav: ["产品", "工作方式", "学习"], - eyebrow: "开放 · 有状态 · Provider-neutral", + eyebrow: "长程 AI Agent 的有状态控制面", title: { - first: "", - secondPrefix: "Agent ", - secondAccent: "持续推进", - thirdPrefix: "判断始终", - thirdAccent: "由你掌握", + first: "让 Agent 持续推进。", + secondPrefix: "让目标成为", + secondAccent: "结果", }, - body: "LoopX 运行在任何 agent harness 之上,提供长程状态、语义决策、治理、恢复与人机协同;权限、Gate、Todo、Quota 与 Evidence 仍集中在同一个控制闭环中。", + body: "把复杂工作变成持久的目标、协同的任务和可审查的成果。跨越会话,Agent 接着做;关键判断,始终由你掌握。", copy: "开始使用", setup: { eyebrow: "设置 LoopX", @@ -208,19 +198,19 @@ const content = { }, demo: "查看实战", panel: { - label: "目标控制面", - title: "交付可审查的结果", + label: "一个目标 · 跨越多轮会话", + title: "交付下一个版本", status: "进展正常", running: "Agent 执行", researcher: "研究与实现", gate: "Human Gate", - gateBody: "审查当前方案边界", + gateBody: "合并前,等待你的批准", waiting: "等待中", evidence: "Evidence", evidenceBody: "验证与交接已写回", complete: "已完成", next: "下一步安全动作", - nextBody: "等待批准,同时继续独立的 P1 工作", + nextBody: "审批等待中,继续推进不依赖审批的任务", }, proof: [ ["显式权限", "Agent 快速推进,但不会静默接管用户权力。"], @@ -313,14 +303,6 @@ function ProductMark() { ); } -function AnimatedAccent({ text }: { text: string }) { - return ( - <span className="hero-gradient-word" aria-label={text}> - {text} - </span> - ); -} - function GitHubIcon() { return ( <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"> @@ -350,17 +332,14 @@ function ControlPlane({ language }: { language: Language }) { <div className="control-shell"> <div className="control-topline"> <span>{panel.label}</span> - <span className="live-indicator"> - <i /> - LIVE - </span> + <span className="live-indicator"><i aria-hidden="true" /> LIVE</span> </div> <div className="control-goal"> <div className="control-icon"> <Target size={18} strokeWidth={1.8} /> </div> <div> - <small>ACTIVE GOAL</small> + <small>{language === "en" ? "ACTIVE GOAL" : "当前目标"}</small> <strong>{panel.title}</strong> </div> <StatusPill tone="ok">{panel.status}</StatusPill> @@ -381,7 +360,7 @@ function ControlPlane({ language }: { language: Language }) { <div className="progress-track"> <span /> </div> - <StatusPill tone="neutral">Running</StatusPill> + <StatusPill tone="neutral">{language === "en" ? "Running" : "执行中"}</StatusPill> </div> <div className="control-row is-gate"> <div className="row-icon"> @@ -665,7 +644,7 @@ function EvidenceViewer({ </div> <footer className="evidence-dialog-footer"> <p>{content[language].showcase.boundary}</p> - <a href={evidence.source} target="_blank" rel="noreferrer"> + <a href={evidence.source} target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin"> {language === "zh" ? "在新标签页打开原图" : "Open original image"} <ExternalLink size={13} /> </a> </footer> @@ -771,7 +750,6 @@ export function App() { useEffect(() => { document.body.dataset.language = language; - document.title = language === "zh" ? "LoopX — 让长程目标持续推进" : "LoopX — Keep the loop moving"; }, [language]); async function copySetup(option: "agent" | "shell") { @@ -790,6 +768,7 @@ export function App() { fallback.remove(); if (!copied) throw new Error("clipboard unavailable"); } + window.dispatchEvent(new CustomEvent("loopx:setup-copy", { detail: option })); setCopiedOption(option); window.setTimeout(() => setCopiedOption(null), 2200); } @@ -874,42 +853,34 @@ export function App() { ) : null} <span className="hero-title-line"> {copy.title.secondPrefix} - <AnimatedAccent text={copy.title.secondAccent} /> - {language === "zh" ? "。" : "."} - </span> - <span className="hero-title-line"> - {copy.title.thirdPrefix} - <AnimatedAccent text={copy.title.thirdAccent} /> + <span className="hero-accent">{copy.title.secondAccent}</span> {language === "zh" ? "。" : "."} </span> </h1> <p className="hero-body">{copy.body}</p> <div className="hero-actions"> - <button className="button button-primary" type="button" onClick={() => setSetupOpen(true)}> + <button className="button button-primary" data-analytics-event="setup_open" type="button" onClick={() => setSetupOpen(true)}> <span>{copy.copy}</span> <ArrowRight className="button-trailing-icon" size={15} /> </button> - <a className="button button-secondary" href="#showcases" onClick={showTerminalReplay}> + <a className="button button-secondary" data-analytics-event="showcase_open" href="#showcases" onClick={showTerminalReplay}> <Play size={15} fill="currentColor" /> {copy.demo} </a> </div> - <div className="supported-hosts"> - <span>WORKS WITH</span> - <div> - <b>Codex</b> - <i /> - <b>Claude Code</b> - <i /> - <b>OpenCode</b> - <i /> - <b>TraeX</b> - <i /> - <b>Pi</b> - </div> + <p className="hero-footnote">{language === "en" ? "Open source. Runs locally. Works with your agents." : "开源 · 本地运行 · 接入你正在使用的 Agent"}</p> + </div> + <div className="hero-system"> + <div className="runtime-rail" aria-label={language === "en" ? "Explore agent integrations" : "查看 Agent 集成"}> + <a href={`${basePath}docs/guides/long-running-coding-agents/#codex-continue-a-task-across-sessions`}>Codex</a> + <a href={`${basePath}docs/guides/long-running-coding-agents/#claude-code-track-work-without-losing-review-state`}>Claude Code</a> + <a href="https://github.com/loopx-project/loopx/tree/main/packages/dsh-loopx-plugin">DeepSeek Harness</a> + <a href={`${basePath}docs/integrations/runtime-connector-catalog/`}>+ {language === "en" ? "more" : "更多"}</a> </div> + <div className="runtime-bridge" aria-hidden="true"><span /><span /><span /></div> + <ControlPlane language={language} /> + <p className="hero-system-caption">{language === "en" ? "A workflow illustration. Your runtime executes; LoopX keeps the state." : "工作流示意:Runtime 负责执行,LoopX 保存状态。"}</p> </div> - <ControlPlane language={language} /> </section> <section className="proof-strip" aria-label="LoopX product principles"> diff --git a/apps/presentation/site/src/LhtbBrief.tsx b/apps/presentation/site/src/LhtbBrief.tsx index b390855543..10e40bf936 100644 --- a/apps/presentation/site/src/LhtbBrief.tsx +++ b/apps/presentation/site/src/LhtbBrief.tsx @@ -8,7 +8,7 @@ import { Search, ShieldCheck, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { usePublicPageNavigation } from "./usePublicPageNavigation"; import study from "../../../../benchmark/LHTB/studies/five-arm-gpt56sol-max/data.json"; import taskGroups from "../../../../benchmark/LHTB/studies/five-arm-gpt56sol-max/task-groups.json"; @@ -77,7 +77,7 @@ function formatReward(value: number) { } export function LhtbBrief() { - const [language, setLanguage] = usePublicPageNavigation(); + const [language, setLanguage] = usePublicPageNavigation("lhtb"); const [query, setQuery] = useState(""); const [tableMode, setTableMode] = useState<TableMode>("all"); const [group, setGroup] = useState<GroupKey | "all">("all"); @@ -86,12 +86,6 @@ export function LhtbBrief() { const visibleArms = showHistory ? [...primaryArms, ...historicalArms] : primaryArms; const basePath = import.meta.env.BASE_URL; - useEffect(() => { - document.title = language === "zh" - ? "LoopX × LHTB:与 Plain、原生 Goal 的对比" - : "LoopX × LHTB: compared with Plain and native Goal"; - }, [language]); - const visibleTasks = useMemo(() => { const normalized = query.trim().toLowerCase(); return study.tasks.filter((row) => { @@ -133,7 +127,7 @@ export function LhtbBrief() { <div key={arm}><dt>{c.armLabels[arm]}</dt><dd>{formatReward(row[arm])}</dd></div> ))}</dl> <p>{note}</p> - <a href={promptUrl(task)} target="_blank" rel="noreferrer">{c.promptLink} <ExternalLink size={11} /></a> + <a href={promptUrl(task)} target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin">{c.promptLink} <ExternalLink size={11} /></a> </article> ); }); @@ -150,7 +144,7 @@ export function LhtbBrief() { <button aria-pressed={language === "en"} className={language === "en" ? "is-active" : ""} onClick={() => setLanguage("en")} type="button">EN</button> <button aria-pressed={language === "zh"} className={language === "zh" ? "is-active" : ""} onClick={() => setLanguage("zh")} type="button">中文</button> </div> - <a href={studyUrl} target="_blank" rel="noreferrer"> + <a href={studyUrl} target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin"> {c.source} <ExternalLink size={13} /> </a> </div> @@ -167,7 +161,7 @@ export function LhtbBrief() { <p className="bm-contributors"> <span>{c.contributorsLabel}</span> {contributorLinks.map((person) => ( - <a href={person.href} key={person.href} target="_blank" rel="noreferrer"> + <a href={person.href} key={person.href} target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin"> {person.label}<ExternalLink size={11} /> </a> ))} @@ -229,7 +223,7 @@ export function LhtbBrief() { </div> <div className="lhtb-official-links"> {c.officialLinks.map(([label, body, href]) => ( - <a href={href} key={href} target="_blank" rel="noreferrer"> + <a href={href} key={href} target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin"> <div><strong>{label}</strong><p>{body}</p></div><ArrowUpRight size={18} /> </a> ))} @@ -241,7 +235,7 @@ export function LhtbBrief() { </div> <div className="lhtb-taxonomy" id="categories"> <h3>{c.categoriesTitle}</h3> - <p>{c.categoriesBody} <a href="https://zli12321.github.io/LHTB/index.html#benchmark" target="_blank" rel="noreferrer">{c.categoriesSource}</a> · <a href="https://github.com/zli12321/LHTB#task-categories-46-tasks" target="_blank" rel="noreferrer">{c.categoriesExamplesSource}</a></p> + <p>{c.categoriesBody} <a href="https://zli12321.github.io/LHTB/index.html#benchmark" target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin">{c.categoriesSource}</a> · <a href="https://github.com/zli12321/LHTB#task-categories-46-tasks" target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin">{c.categoriesExamplesSource}</a></p> <div className="bm-table-wrap lhtb-category-table"> <table> <caption>{c.categoriesTitle}</caption> @@ -355,7 +349,7 @@ export function LhtbBrief() { const best = Math.max(...visibleArms.map((arm) => row[arm])); return ( <tr key={row.task}> - <th scope="row"><a href={promptUrl(row.task)} target="_blank" rel="noreferrer"><code>{row.task}</code></a><span>{c.groupLabels[taskGroup.get(row.task)!]}</span></th> + <th scope="row"><a href={promptUrl(row.task)} target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin"><code>{row.task}</code></a><span>{c.groupLabels[taskGroup.get(row.task)!]}</span></th> {visibleArms.map((arm) => ( <td className={row[arm] === best ? "is-best" : undefined} key={arm}>{formatReward(row[arm])}</td> ))} @@ -379,7 +373,7 @@ export function LhtbBrief() { {c.programSteps.map(([title, body], index) => ( <article key={title}><span>{index + 1}</span><div><h3>{title}</h3><p>{body}</p></div></article> ))} - <a href={c.programUrl} target="_blank" rel="noreferrer">{c.programAction}<ExternalLink size={14} /></a> + <a href={c.programUrl} target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin">{c.programAction}<ExternalLink size={14} /></a> </div> </section> @@ -403,7 +397,7 @@ export function LhtbBrief() { </div> <div className="bm-source-list"> {c.sourceItems.map(([title, body, href], index) => ( - <a href={href} target="_blank" rel="noreferrer" key={title}> + <a href={href} target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin" key={title}> <span>0{index + 1}</span><div><strong>{title}</strong><p>{body}</p></div><ExternalLink size={15} /> </a> ))} diff --git a/apps/presentation/site/src/SweMarathonBrief.tsx b/apps/presentation/site/src/SweMarathonBrief.tsx index 9d8461d455..79b08e8126 100644 --- a/apps/presentation/site/src/SweMarathonBrief.tsx +++ b/apps/presentation/site/src/SweMarathonBrief.tsx @@ -6,7 +6,7 @@ import { ShieldCheck, TimerReset, } from "lucide-react"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import type { CSSProperties } from "react"; import { usePublicPageNavigation } from "./usePublicPageNavigation"; import benchmarkData from "../../../../benchmark/swe-marathon/data.json"; @@ -67,16 +67,10 @@ function HorizonDiagram({ language }: Readonly<{ language: Language }>) { } export function SweMarathonBrief() { - const [language, setLanguage] = usePublicPageNavigation(); + const [language, setLanguage] = usePublicPageNavigation("sweMarathon"); const c = copy[language]; const basePath = import.meta.env.BASE_URL; - useEffect(() => { - document.title = language === "zh" - ? "LoopX × SWE-Marathon:持续自我验证" - : "LoopX × SWE-Marathon: Continued self-verification"; - }, [language]); - const summaries = useMemo( () => armOrder.map((arm) => ({ @@ -98,7 +92,7 @@ export function SweMarathonBrief() { <button aria-pressed={language === "en"} className={language === "en" ? "is-active" : ""} onClick={() => setLanguage("en")} type="button">EN</button> <button aria-pressed={language === "zh"} className={language === "zh" ? "is-active" : ""} onClick={() => setLanguage("zh")} type="button">中文</button> </div> - <a href={repositoryStudyUrl} target="_blank" rel="noreferrer"> + <a href={repositoryStudyUrl} target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin"> {c.source} <ExternalLink size={13} /> </a> </div> @@ -122,7 +116,7 @@ export function SweMarathonBrief() { <p className="bm-contributors"> <span>{c.contributorsLabel}</span> {researchContributors.map((contributor) => ( - <a href={contributor.href} key={contributor.handle} rel="noreferrer" target="_blank"> + <a href={contributor.href} key={contributor.handle} rel="noopener" referrerPolicy="strict-origin-when-cross-origin" target="_blank"> @{contributor.handle}<ExternalLink aria-hidden="true" size={11} /> </a> ))} @@ -260,7 +254,7 @@ export function SweMarathonBrief() { </div> <div className="bm-source-list"> {c.sourceItems.map(([title, body, href], index) => ( - <a href={href} target="_blank" rel="noreferrer" key={title}> + <a href={href} target="_blank" rel="noopener" referrerPolicy="strict-origin-when-cross-origin" key={title}> <span>0{index + 1}</span><div><strong>{title}</strong><p>{body}</p></div><ExternalLink size={15} /> </a> ))} diff --git a/apps/presentation/site/src/page-metadata.ts b/apps/presentation/site/src/page-metadata.ts new file mode 100644 index 0000000000..08f9e97ceb --- /dev/null +++ b/apps/presentation/site/src/page-metadata.ts @@ -0,0 +1,38 @@ +// Public URLs are independent of the local preview's deployment base. +export const siteUrl = "https://loopx-project.github.io/loopx/"; +export const pageMetadata = { + home: { + path: "", + en: { + title: "LoopX — Stateful control plane for long-running AI agents", + description: "Keep long-running AI agents on track across sessions with durable goals, tasks, human approval gates and recovery. Works with Codex, Claude Code and other agent runtimes.", + }, + zh: { + title: "LoopX — 长程 AI Agent 的有状态控制面", + description: "让 Codex、Claude Code 等 Agent 跨会话持续推进任务,用持久目标、任务看板、人工审批与恢复机制管理长程工作。", + }, + }, + sweMarathon: { + path: "benchmarks/swe-marathon/", + en: { + title: "LoopX × SWE-Marathon: Continued self-verification", + description: "Compare three retained agent execution modes across 15 matched SWE-Marathon tasks, with results, costs, self-verification mechanisms and study limitations.", + }, + zh: { + title: "LoopX × SWE-Marathon:持续自我验证", + description: "对比 15 道匹配 SWE-Marathon 任务的三种保留执行模式,查看结果、成本、持续自我验证机制与研究局限。", + }, + }, + lhtb: { + path: "benchmarks/lhtb/", + en: { + title: "LoopX × LHTB: compared with Plain and native Goal", + description: "Explore five execution mechanisms across 46 long-horizon terminal tasks, including Plain, native Goal and LoopX, with per-task results and study limitations.", + }, + zh: { + title: "LoopX × LHTB:与 Plain、原生 Goal 的对比", + description: "查看 46 道长程终端任务中 Plain、原生 Goal 与 LoopX 等五种执行机制的逐任务结果、差异和研究局限。", + }, + }, +} as const; +export type PublicPage = keyof typeof pageMetadata; diff --git a/apps/presentation/site/src/render-static.tsx b/apps/presentation/site/src/render-static.tsx new file mode 100644 index 0000000000..0b9fd6d8ed --- /dev/null +++ b/apps/presentation/site/src/render-static.tsx @@ -0,0 +1,11 @@ +import { renderToString } from "react-dom/server"; +import { App } from "./App"; +import { LhtbBrief } from "./LhtbBrief"; +import { SweMarathonBrief } from "./SweMarathonBrief"; +import { pageMetadata, siteUrl, type PublicPage } from "./page-metadata"; + +export { pageMetadata, siteUrl }; + +export function render(page: PublicPage) { + return renderToString(page === "home" ? <App /> : page === "lhtb" ? <LhtbBrief /> : <SweMarathonBrief />); +} diff --git a/apps/presentation/site/src/styles.css b/apps/presentation/site/src/styles.css index d611e7ed26..0c0ed4328b 100644 --- a/apps/presentation/site/src/styles.css +++ b/apps/presentation/site/src/styles.css @@ -18,8 +18,6 @@ --danger: #ee0000; --warning: #f5a623; --font-mono: "Geist Mono", "JetBrains Mono", "SFMono-Regular", monospace; - --font-display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino, Georgia, serif; - --font-display-zh: "Iowan Old Style", "Songti SC", STSong, "Noto Serif CJK SC", serif; } * { @@ -219,13 +217,14 @@ button { .hero { position: relative; display: grid; - grid-template-columns: minmax(0, 0.93fr) minmax(500px, 1.07fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; - gap: 76px; + gap: 64px; width: min(calc(100% - 48px), 1200px); - min-height: 680px; + min-height: 660px; margin: 0 auto; - padding: 80px 0 92px; + padding: 80px 0; + isolation: isolate; } .hero-grid { @@ -246,7 +245,7 @@ button { right: -260px; width: 820px; height: 620px; - opacity: 0.2; + opacity: 0.3; background: radial-gradient(circle at 35% 38%, #00dfd8 0, transparent 28%), radial-gradient(circle at 67% 33%, #7928ca 0, transparent 31%), radial-gradient(circle at 55% 70%, #ff4d4d 0, transparent 33%); @@ -280,38 +279,81 @@ button { } .hero h1 { - max-width: 650px; margin: 0; color: var(--ink); - font-family: var(--font-display); - font-size: clamp(48px, 3.65vw, 52px); - font-weight: 400; - letter-spacing: -0.045em; - line-height: 0.98; + font-size: clamp(40px, 3.7vw, 56px); + font-weight: 600; + letter-spacing: -0.055em; + line-height: 1.12; } body[data-language="zh"] .hero h1 { - font-family: var(--font-display-zh); - font-size: clamp(48px, 3.65vw, 52px); - font-weight: 600; - letter-spacing: -0.035em; - line-height: 1.08; + letter-spacing: -0.045em; + line-height: 1.25; } .hero-title-line { display: block; - white-space: nowrap; } -.hero-gradient-word { - display: inline-block; - white-space: nowrap; - color: #55555b; - font-style: italic; - text-decoration: underline; - text-decoration-color: #d7d7da; - text-decoration-thickness: 1px; - text-underline-offset: 0.09em; +.hero-accent { + color: #0070c9; +} + +.hero-footnote { + margin: 24px 0 0; + color: #666; + font-size: 12px; + line-height: 1.7; +} + +.hero-system { + position: relative; + z-index: 3; + min-width: 0; +} + +.runtime-rail { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 8px; +} + +.runtime-rail a { + display: flex; + align-items: center; + min-height: 44px; + padding: 0 12px; + border: 1px solid #dedede; + border-radius: 6px; + background: var(--surface); + font-size: 12px; + font-weight: 500; +} + +.runtime-rail a:hover { + border-color: var(--ink); +} + +.runtime-bridge { + display: flex; + justify-content: space-around; + height: 28px; + margin: 0 48px; +} + +.runtime-bridge span { + width: 1px; + background: #ccc; +} + +.hero-system-caption { + margin: 16px 0 0; + text-align: center; + color: #666; + font-size: 11px; + line-height: 1.6; } .hero-body { @@ -378,50 +420,13 @@ body[data-language="zh"] .hero h1 { transform: translateX(2px); } -.supported-hosts { - display: grid; - gap: 12px; - margin-top: 42px; -} - -.supported-hosts > span { - color: var(--muted); - font-family: var(--font-mono); - font-size: 9px; - font-weight: 600; - letter-spacing: 0.12em; -} - -.supported-hosts > div { - display: flex; - align-items: center; - gap: 12px; - color: #707070; -} - -.supported-hosts b { - color: #555; - font-size: 14px; - font-weight: 600; - letter-spacing: -0.015em; -} - -.supported-hosts i { - width: 3px; - height: 3px; - border-radius: 50%; - background: #c8c8c8; -} - .control-shell { position: relative; z-index: 3; overflow: hidden; border: 1px solid #dedede; border-radius: 16px; - background: rgba(255, 255, 255, 0.94); - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04); - animation: control-enter 800ms 120ms cubic-bezier(0.2, 0.72, 0, 1) both; + background: var(--surface); } .control-topline { @@ -451,6 +456,12 @@ body[data-language="zh"] .hero h1 { border-radius: 50%; background: #18a957; box-shadow: 0 0 0 3px rgba(24, 169, 87, 0.12); + animation: status-blink 2.8s ease-in-out infinite; +} + +@keyframes status-blink { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } } .control-goal { @@ -523,7 +534,6 @@ body[data-language="zh"] .hero h1 { height: 5px; border-radius: 50%; background: currentColor; - animation: status-blink 2.8s ease-in-out infinite; } .status-ok { @@ -566,7 +576,6 @@ body[data-language="zh"] .hero h1 { width: 1px; height: 28px; background: linear-gradient(transparent, var(--blue), transparent); - animation: rail-flow 3.8s linear infinite; } .control-row { @@ -615,7 +624,6 @@ body[data-language="zh"] .hero h1 { width: 58%; height: 100%; background: linear-gradient(90deg, #007cf0, #00c8c0); - animation: progress-breathe 2.5s ease-in-out infinite; } .next-action { @@ -1641,36 +1649,6 @@ body.dialog-open { font-weight: 600; } -@keyframes rail-flow { - from { - transform: translateY(0); - } - to { - transform: translateY(230px); - } -} - -@keyframes control-enter { - from { - opacity: 0; - transform: translateX(36px) scale(0.98); - } - to { - opacity: 1; - transform: none; - } -} - -@keyframes status-blink { - 0%, - 100% { - opacity: 0.55; - } - 50% { - opacity: 1; - } -} - @keyframes setup-dialog-enter { from { opacity: 0; @@ -1711,16 +1689,6 @@ body.dialog-open { } } -@keyframes progress-breathe { - 0%, - 100% { - opacity: 0.75; - } - 50% { - opacity: 1; - } -} - @media (max-width: 1100px) { .site-header { grid-template-columns: 1fr auto; @@ -1737,7 +1705,7 @@ body.dialog-open { .hero { grid-template-columns: 1fr; gap: 56px; - padding-top: 90px; + padding-top: 64px; } .hero-copy { @@ -1806,15 +1774,15 @@ body.dialog-open { .hero { min-height: 0; - padding: 70px 0 72px; + padding: 48px 0 56px; } .hero h1 { - font-size: clamp(32px, 9vw, 38px); + font-size: clamp(29px, 7.5vw, 42px); } body[data-language="zh"] .hero h1 { - font-size: clamp(32px, 9vw, 38px); + font-size: clamp(29px, 7.5vw, 42px); } .hero-body { @@ -1830,10 +1798,6 @@ body.dialog-open { width: 100%; } - .supported-hosts > div { - flex-wrap: wrap; - } - .control-goal { grid-template-columns: auto 1fr; } diff --git a/apps/presentation/site/src/usePublicPageNavigation.ts b/apps/presentation/site/src/usePublicPageNavigation.ts index 8356b9c699..f055c103c4 100644 --- a/apps/presentation/site/src/usePublicPageNavigation.ts +++ b/apps/presentation/site/src/usePublicPageNavigation.ts @@ -1,14 +1,27 @@ import { useEffect, useLayoutEffect, useState } from "react"; +import { pageMetadata, type PublicPage } from "./page-metadata"; + type Language = "en" | "zh"; const readLanguage = (): Language => - new URLSearchParams(window.location.search).get("lang") === "zh" ? "zh" : "en"; + new URLSearchParams(typeof window === "undefined" ? "" : window.location.search).get("lang") === "zh" ? "zh" : "en"; -// Both public React pages share URL language and fragment navigation. The static -// shell has no section IDs, so the browser's first fragment lookup can miss them. -export function usePublicPageNavigation() { +// Public pages share URL language and fragment navigation. Client rendering or +// translation can move section positions after the initial static fragment lookup. +export function usePublicPageNavigation(page: PublicPage = "home") { const [language, setLanguage] = useState<Language>(readLanguage); + useEffect(() => { + const { title, description } = pageMetadata[page][language]; + document.title = title; + for (const selector of ['meta[name="description"]', 'meta[property="og:description"]', 'meta[name="twitter:description"]']) { + document.querySelector(selector)?.setAttribute("content", description); + } + for (const selector of ['meta[property="og:title"]', 'meta[name="twitter:title"]']) { + document.querySelector(selector)?.setAttribute("content", title); + } + }, [language, page]); + useEffect(() => { const restoreLanguage = () => setLanguage(readLanguage()); window.addEventListener("popstate", restoreLanguage); @@ -30,7 +43,7 @@ export function usePublicPageNavigation() { } const target = document.getElementById(id); if (!target) return; - // :target can remain unresolved after parsing the empty shell. Reveal the + // :target can remain unresolved after the client replaces static content. Reveal the // destination before scrolling, including when translated content reflows. target.closest(".reveal-block")?.setAttribute("data-anchor-entry", ""); const align = () => target.scrollIntoView({ behavior: "instant" }); diff --git a/examples/dashboard-frontstage-browser-smoke.mjs b/examples/dashboard-frontstage-browser-smoke.mjs index bf22900cd3..c362a59987 100644 --- a/examples/dashboard-frontstage-browser-smoke.mjs +++ b/examples/dashboard-frontstage-browser-smoke.mjs @@ -66,6 +66,18 @@ try { await new Promise((done) => setTimeout(done, 200)); } browser = await chromium.launch({ headless: true }); + // Crawlers and readers without JavaScript receive actual route content. + const staticContext = await browser.newContext({ javaScriptEnabled: false }); + const staticPage = await staticContext.newPage(); + for (const [path, subject] of [["", "long-running"], ["benchmarks/swe-marathon/", "SWE-Marathon"], ["benchmarks/lhtb/", "LHTB"]]) { + await staticPage.goto(`${publicOrigin}/loopx/${path}`); + assert.match(await staticPage.title(), new RegExp(subject, "i")); + assert(await staticPage.locator("h1").isVisible(), "primary heading is visible without JavaScript"); + assert.match(await staticPage.locator("main").innerText(), new RegExp(subject, "i")); + assert(await staticPage.locator("main a[href]").count() > 0, "static content includes crawlable links"); + assert.equal(await staticPage.locator('link[rel="canonical"]').getAttribute("href"), `https://loopx-project.github.io/loopx/${path}`); + } + await staticContext.close(); // Serve the real bundled font behind a controlled delay. Check both late // reflow correction and its cancellation when a reader scrolls away. const font = await readFile(resolve(dashboard, "node_modules/@fontsource-variable/geist/files/geist-latin-wght-normal.woff2")); diff --git a/examples/export-frontstage-share-bundle.mjs b/examples/export-frontstage-share-bundle.mjs index 9ad1afa251..6ebd85b956 100644 --- a/examples/export-frontstage-share-bundle.mjs +++ b/examples/export-frontstage-share-bundle.mjs @@ -122,13 +122,11 @@ async function copyHomepage(siteDir, base) { cwd: homepageDir, }); run(process.execPath, [ - resolve(homepageDir, "node_modules/vite/bin/vite.js"), - "build", + resolve(homepageDir, "scripts/build.mjs"), "--base", base, "--outDir", buildDir, - "--emptyOutDir", ], { cwd: homepageDir }); await cp(buildDir, siteDir, { force: true, recursive: true }); await rm(buildDir, { force: true, recursive: true }); @@ -136,12 +134,6 @@ async function copyHomepage(siteDir, base) { } async function copyPublicSiteRoutes(siteDir) { - const homepage = resolve(siteDir, "index.html"); - for (const route of ["benchmarks/swe-marathon", "benchmarks/lhtb"]) { - const routeDir = resolve(siteDir, route); - await mkdir(routeDir, { recursive: true }); - await copyFile(homepage, resolve(routeDir, "index.html")); - } const deepSweRouteDir = resolve( siteDir, "benchmarks/deepswe/behavior-discovery", diff --git a/examples/frontstage-share-bundle-smoke.mjs b/examples/frontstage-share-bundle-smoke.mjs index b9789a2ce1..1d289a15bd 100644 --- a/examples/frontstage-share-bundle-smoke.mjs +++ b/examples/frontstage-share-bundle-smoke.mjs @@ -200,8 +200,8 @@ if (!routerSource.includes("basepath:") || !routerSource.includes("import.meta.e } const homepageHtml = await readFile(resolve(siteDir, "index.html"), "utf8"); -if (!homepageHtml.includes('<div id="root"></div>')) { - throw new Error("homepage root must be the compiled React entry"); +if (!homepageHtml.includes('<div id="root">') || !homepageHtml.includes("<h1>")) { + throw new Error("homepage must include prerendered content inside its React root"); } if (!homepageHtml.includes('src="/loopx/site-assets/') || !homepageHtml.includes('href="/loopx/site-assets/')) { throw new Error("homepage compiled assets did not resolve against the GitHub Pages base"); @@ -231,12 +231,12 @@ if (publishedInstaller !== canonicalInstaller) { const homepageSource = await readFile(resolve(repoRoot, "apps/presentation/site/src/App.tsx"), "utf8"); const homepageStyles = await readFile(resolve(repoRoot, "apps/presentation/site/src/styles.css"), "utf8"); const benchmarkHtml = await readFile(resolve(siteDir, "benchmarks/swe-marathon/index.html"), "utf8"); -if (benchmarkHtml !== homepageHtml) { - throw new Error("SWE-Marathon static route must reuse the compiled public-site entry"); +if (benchmarkHtml === homepageHtml || !benchmarkHtml.includes("<h1")) { + throw new Error("SWE-Marathon must publish its own prerendered research content"); } const lhtbHtml = await readFile(resolve(siteDir, "benchmarks/lhtb/index.html"), "utf8"); -if (lhtbHtml !== homepageHtml) { - throw new Error("LHTB static route must reuse the compiled public-site entry"); +if (lhtbHtml === homepageHtml || !lhtbHtml.includes("<h1")) { + throw new Error("LHTB must publish its own prerendered research content"); } const deepSweBehaviorHtml = await readFile( resolve(siteDir, "benchmarks/deepswe/behavior-discovery/index.html"), @@ -250,16 +250,6 @@ if (deepSweBehaviorHtml !== canonicalDeepSweBehaviorHtml) { throw new Error("DeepSWE behavior article route must be byte-identical to the reviewed standalone source"); } for (const sourceContract of [ - "Your agents keep", - 'secondPrefix: "the "', - 'secondAccent: "night shift"', - 'thirdPrefix: "You keep the "', - 'thirdAccent: "judgment"', - 'secondPrefix: "Agent "', - 'secondAccent: "持续推进"', - 'thirdPrefix: "判断始终"', - 'thirdAccent: "由你掌握"', - "Provider-neutral", "Get started", "开始使用", "See in action", @@ -276,7 +266,6 @@ for (const sourceContract of [ "Prefer the shell? Install LoopX manually.", "Developer book", "benchmarks/swe-marathon/", - "Iowan Old Style", "Pi", ]) { if (!homepageSource.includes(sourceContract) && !homepageStyles.includes(sourceContract)) { From ae81c012d88f91198b97b8d3286a6c804bfcfb40 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:10:33 +0800 Subject: [PATCH 2/4] docs: add a practical long-running coding agent entry point Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/guides/long-running-coding-agents.md | 118 ++++++++++++++++++ docs/guides/newcomer-command-path.md | 3 + docs/integrations/README.md | 6 + .../deepseek-harness-connector.md | 5 + .../integrations/runtime-connector-catalog.md | 5 + mkdocs.yaml | 2 + 6 files changed, 139 insertions(+) create mode 100644 docs/guides/long-running-coding-agents.md diff --git a/docs/guides/long-running-coding-agents.md b/docs/guides/long-running-coding-agents.md new file mode 100644 index 0000000000..6ea37c61a4 --- /dev/null +++ b/docs/guides/long-running-coding-agents.md @@ -0,0 +1,118 @@ +--- +title: "Run long-running AI coding agents with Codex and Claude Code" +description: "Keep Codex and Claude Code work moving across sessions with LoopX. Set up durable goals, resume from evidence, coordinate review, and stop at human approval gates." +--- + +# Run long-running coding agents with LoopX + +Use LoopX when a coding task spans sessions, waits for review or CI, or needs +handoff between agents. Your coding agent still edits and tests the code. +LoopX preserves the goal, task ownership, decisions, evidence and next action +outside the conversation so another authorized turn can continue the work. + +For a small task that fits in one session, start with the agent you already +use. A host's native Goal can also be sufficient when continuation stays in +one host. The [session, Codex Goal and LoopX comparison](../book/en/chapters/02-session-goal-loopx.md) +explains when project-level state earns its additional setup. + +## Install and check the current project + +Run the [no-clone installer](installing-loopx.md), then open the project you +want your agent to work on: + +```bash +curl -fsSL https://loopx-project.github.io/loopx/install.sh | bash +export PATH="$HOME/.local/bin:$PATH" +cd /path/to/your-project +loopx doctor +loopx connect +loopx status +``` + +`connect` should reuse an existing connection. If state is missing, follow the +[guided first-goal path](newcomer-command-path.md#one-cli-quickstart); do not +replace an existing goal just to restart a session. Keep runtime state out of +version control as described in the installation guide. + +## Codex: continue a task across sessions + +In Codex App, use the installed `loopx` skill through `$loopx` or `/skills`. +A useful first task has a bounded result and a clear stopping rule: + +```text +$loopx Fix the failing integration test, explain the cause, and prepare a +reviewable PR. Preserve the existing project goal and record test evidence. +Wait for my approval before merging. +``` + +For Codex CLI, start from the project root and use the generated bootstrap +message: + +```bash +loopx codex-cli-bootstrap-message --project . +``` + +Follow the returned instructions in the actual Codex session. App automation, +visible CLI continuation and isolated headless execution have different host +contracts; they are not interchangeable. The [driver selection table](newcomer-command-path.md#choose-the-loop-driver) +and [Codex App chapter](../book/en/chapters/06-codex-app.md) explain activation. +Installing LoopX by itself does not keep a closed or unavailable host running. + +## Claude Code: track work without losing review state + +The installer registers lightweight Claude Code skills. Use `/loopx` with the +same concrete result and approval boundary. Check `loopx doctor` if the command +is not available, and follow the [skill registration guide](getting-started.md#command-skill-registration). + +If one agent implements while another reviews, retain separate ownership and +return the review evidence to the shared task. The +[Claude implementation / Codex review example](../product/use-cases/cross-runtime/cross-runtime-impl-review-demo.md) +shows the roles and acceptance boundary. Switching the executor does not grant +it another agent's identity or the user's merge authority. + +## DeepSeek Harness: choose the native plugin or SDK connector + +For a DSH Web session, the +[native LoopX plugin](https://github.com/loopx-project/loopx/tree/main/packages/dsh-loopx-plugin#install) +provides bootstrap, workflow skills, a session-bound Driver and GoalBar. Follow +its install instructions and version compatibility notes: the published +prebuilt artifact and the latest source checkout can target different DSH +versions. Installing the plugin alone does not activate continued work. + +Use the [DeepSeek Harness SDK connector](../integrations/deepseek-harness-connector.md) +when an outer supervisor needs bounded headless turns instead. It is a +different integration from the native Web plugin. + +## Resume after an interruption + +Open the same project and read `loopx status` before issuing another task. +Ask the agent to summarize the active goal, current owner, pending decision, +last accepted evidence and next permitted action. Explicitly select the prior +agent identity if you intend to resume that lane; sharing a goal is not enough +to infer identity takeover. + +A compact restart instruction is: + +```text +Read this project's existing LoopX state. Report the current goal, pending +user gate, last accepted result, and next permitted task. Reuse the existing +goal. Ask me to select the prior agent identity if a takeover is needed. +Continue only the authorized task and verify the result before writing back. +``` + +A transcript saying “done” is not a substitute for current tests, review or +accepted evidence. See [long-task operations](../operations/long-task-cadence-policy.md) +for waiting, budgets and continuation. + +## What to verify before leaving a task running + +- `loopx status` shows the intended goal and a useful next action. +- The chosen host has an active continuation mechanism and remains available. +- The task has an acceptance check, spending boundary and stopping rule. +- Merge, publication and other consequential operations retain explicit approval. + +For examples rather than setup, explore the +[public application scenarios](https://loopx-project.github.io/loopx/blog/application-scenarios/) +and [long-horizon terminal study](https://loopx-project.github.io/loopx/benchmarks/lhtb/). +Study results have task, model and budget limits; they do not guarantee a gain +on every project. diff --git a/docs/guides/newcomer-command-path.md b/docs/guides/newcomer-command-path.md index a385276cc0..ae7649dfbf 100644 --- a/docs/guides/newcomer-command-path.md +++ b/docs/guides/newcomer-command-path.md @@ -10,6 +10,9 @@ For a first-time user, the default surface is: The full command set remains available for operators and contributors, but it should not be the first thing a newcomer has to understand. +For a task-oriented walkthrough, see +[long-running coding agents with Codex and Claude Code](long-running-coding-agents.md). + ## The Two Commands To Remember | Need | Use | Expected result | diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 0e18b77f64..aa1f727e9a 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -1,8 +1,14 @@ +--- +title: "LoopX integrations for AI agent runtimes" +description: "Connect LoopX to Codex, Claude Code, DeepSeek Harness and other agent hosts. Find runtime connectors, worker bridges and collaboration integrations." +--- + # Integrations Integrations connect LoopX to agent hosts, runtimes, collaboration surfaces, and external systems while preserving one control-plane authority. +- [Codex, Claude Code and DeepSeek Harness setup](../guides/long-running-coding-agents.md) - [Integration guide](../integration.md) - [Runtime connector catalog](runtime-connector-catalog.md) - [DeepSeek Harness connector](deepseek-harness-connector.md) diff --git a/docs/integrations/deepseek-harness-connector.md b/docs/integrations/deepseek-harness-connector.md index f4e68ec57f..076dcedc7e 100644 --- a/docs/integrations/deepseek-harness-connector.md +++ b/docs/integrations/deepseek-harness-connector.md @@ -1,3 +1,8 @@ +--- +title: "DeepSeek Harness integration with LoopX" +description: "Run bounded DeepSeek Harness agent sessions under LoopX goals, quota and validation. Install the optional SDK connector and verify its control-plane boundary." +--- + # DeepSeek Harness Connector Status: public-safe v0 connector for using DeepSeek Harness (`dsh`) as a diff --git a/docs/integrations/runtime-connector-catalog.md b/docs/integrations/runtime-connector-catalog.md index 1c26b99c10..f259e6a914 100644 --- a/docs/integrations/runtime-connector-catalog.md +++ b/docs/integrations/runtime-connector-catalog.md @@ -1,3 +1,8 @@ +--- +title: "Codex, Claude Code and agent runtime connectors" +description: "Compare LoopX connectors for Codex, Claude Code, OpenCode, DeepSeek Harness and custom runtimes: wake triggers, state writeback, budgets and authority boundaries." +--- + # Runtime Connector Catalog Status: public-safe v0 catalog for LoopX host/runtime connectors. diff --git a/mkdocs.yaml b/mkdocs.yaml index 1780dee17c..423b1f4ff2 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -1,4 +1,5 @@ site_name: LoopX +site_description: Documentation for the LoopX stateful control plane for long-running AI agents, including installation, runtime integrations, goals, tasks, approval gates and recovery. site_url: https://loopx-project.github.io/loopx/docs/ repo_url: https://github.com/loopx-project/loopx edit_uri: edit/main/docs/ @@ -95,6 +96,7 @@ nav: - Installation: guides/installing-loopx.md - Personal Workspace Guide: guides/personal-workspace-user-guide.md - Newcomer Command Path: guides/newcomer-command-path.md + - Long-Running Coding Agents: guides/long-running-coding-agents.md - Minimal Custom Runtime Example: guides/minimal-custom-runtime-example.md - Custom Runner Integration: guides/custom-agent-runner-integration.md - Auto Research Stop/Takeover/Wake: guides/auto-research-stop-takeover-wake-walkthrough.md From f6779133ad7a282d2e3bd3432ec2a66f7e24f7ac Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:38:33 +0800 Subject: [PATCH 3/4] test(site): allow small fragment alignment variance Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/dashboard-frontstage-browser-smoke.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/dashboard-frontstage-browser-smoke.mjs b/examples/dashboard-frontstage-browser-smoke.mjs index c362a59987..a9e205011f 100644 --- a/examples/dashboard-frontstage-browser-smoke.mjs +++ b/examples/dashboard-frontstage-browser-smoke.mjs @@ -46,7 +46,9 @@ async function assertAnchorInView(page, id) { const header = document.querySelector(".bm-topbar")?.getBoundingClientRect(); const headerBottom = header?.bottom ?? 0; const reveal = section.closest(".reveal-block"); - return (!header || Math.abs(header.top) < 2) && top >= -2 && (top < 80 || finalSectionVisible) && + // Chromium can leave a few pixels above the viewport after fragment + // alignment and font reflow; the heading must still be fully visible. + return (!header || Math.abs(header.top) < 2) && top >= -8 && (top < 80 || finalSectionVisible) && (!heading || (heading.top >= Math.max(0, headerBottom) && heading.bottom < innerHeight)) && (!reveal || getComputedStyle(reveal).opacity === "1"); }, id, { timeout: 4000 }).catch(async (error) => { From 64d8e8d21ec69701dd3c70f895d02878927fef74 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:52:03 +0800 Subject: [PATCH 4/4] fix(site): require visitor consent before loading analytics Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/site/README.md | 24 ++- .../site/public/google5a493c86e5bde9bc.html | 1 + .../site/scripts/add-analytics.mjs | 16 +- .../site/scripts/analytics-client.js | 174 +++++++++++++----- .../site/scripts/analytics-consent.css | 38 ++++ .../site/scripts/analytics-smoke.mjs | 60 +++++- 6 files changed, 249 insertions(+), 64 deletions(-) create mode 100644 apps/presentation/site/public/google5a493c86e5bde9bc.html create mode 100644 apps/presentation/site/scripts/analytics-consent.css diff --git a/apps/presentation/site/README.md b/apps/presentation/site/README.md index fddc64ef52..08462a4c63 100644 --- a/apps/presentation/site/README.md +++ b/apps/presentation/site/README.md @@ -73,7 +73,11 @@ The build discovers canonical editorial URLs for `sitemap-pages.xml` and writes `sitemap.xml`, which also references the three MkDocs sitemaps. The complete index is ready only after the Pages workflow builds the documentation. Submit `https://loopx-project.github.io/loopx/sitemap.xml` in the verified Search Console -property after deployment. A project-level `/loopx/robots.txt` would not control +property after deployment. The Google-issued verification file +`public/google5a493c86e5bde9bc.html` is copied unchanged to the site root; keep it +published to retain ownership verification. This file does not load analytics +or collect visitor data, and verification is independent of GA4 consent. +A project-level `/loopx/robots.txt` would not control crawling: robots rules belong at the origin root, outside this Pages artifact. Public outbound links send at most the origin to other HTTPS sites through @@ -98,10 +102,20 @@ In the GA4 web stream, disable **Enhanced measurement**: this integration sends explicit pageviews and conversion-intent events, including during MkDocs instant navigation. Leaving automatic history, form or outbound collection on can create duplicate events and collect fields outside this site's explicit event schema. -The loader honors Do Not Track and Global Privacy Control and disables Google -signals and ad personalization. Normal GA4 first-party measurement cookies may -be used when measurement is active; deployment owners retain responsibility for -their site's consent configuration and privacy notice. +A bilingual notice explains the use of Google Analytics cookies and links to +Google's data-use information. No Google tag, measurement cookie or event is +initialized before the visitor chooses **Allow analytics**. **Reject** keeps +measurement off. Both choices are remembered in local storage and can be changed +through **Analytics preferences** in the page footer. Withdrawal immediately +disables collection, clears this stream's GA cookies and reloads the page to +unload the tag and its timers. It does not erase data already sent to Google. +When storage is unavailable, the choice applies to the current page only. + +The loader honors Do Not Track and Global Privacy Control even after a previous +grant, and disables Google signals and advertising consent. The browser smoke +covers an ordinary visitor before choosing, rejection, grant and withdrawal, +including remembered choices and cookie cleanup. Google requests are intercepted +in tests; production collection still needs a real post-deployment check. | Event | Meaning | | --- | --- | diff --git a/apps/presentation/site/public/google5a493c86e5bde9bc.html b/apps/presentation/site/public/google5a493c86e5bde9bc.html new file mode 100644 index 0000000000..dd584b6306 --- /dev/null +++ b/apps/presentation/site/public/google5a493c86e5bde9bc.html @@ -0,0 +1 @@ +google-site-verification: google5a493c86e5bde9bc.html \ No newline at end of file diff --git a/apps/presentation/site/scripts/add-analytics.mjs b/apps/presentation/site/scripts/add-analytics.mjs index d3baa258e9..dfb1ec4868 100644 --- a/apps/presentation/site/scripts/add-analytics.mjs +++ b/apps/presentation/site/scripts/add-analytics.mjs @@ -5,7 +5,7 @@ import { resolve } from "node:path"; const directory = resolve(process.argv[2] ?? "output/frontstage-pages/site"); const id = process.env.LOOPX_GA_MEASUREMENT_ID?.trim() ?? ""; if (id && !/^G-[A-Z0-9]+$/.test(id)) throw new Error("LOOPX_GA_MEASUREMENT_ID must be a GA4 G- measurement ID"); -const marker = /<script\b[^>]*data-loopx-analytics[^>]*><\/script>\s*/g; +const marker = /(?:<script\b[^>]*data-loopx-analytics[^>]*><\/script>|<link\b[^>]*data-loopx-analytics[^>]*>)\s*/g; let count = 0; async function visit(dir) { for (const entry of await readdir(dir, { withFileTypes: true })) { @@ -17,16 +17,18 @@ async function visit(dir) { const canonical = html.match(/<link\s+rel="canonical"\s+href="([^"]+)"/i)?.[1]; if (id && canonical?.startsWith("https://loopx-project.github.io/loopx/") && !/<meta[^>]+(?:noindex|http-equiv="refresh")/i.test(html)) { - html = html.replace("</head>", `<script defer data-loopx-analytics data-measurement-id="${id}" src="/loopx/site-assets/analytics.js"></script>\n</head>`); + html = html.replace("</head>", `<link rel="stylesheet" data-loopx-analytics href="/loopx/site-assets/analytics-consent.css">\n<script defer data-loopx-analytics data-measurement-id="${id}" src="/loopx/site-assets/analytics.js"></script>\n</head>`); count++; } if (html !== source) await writeFile(file, html); } } await visit(directory); -const client = resolve(directory, "site-assets/analytics.js"); -if (count) { - await mkdir(resolve(directory, "site-assets"), { recursive: true }); - await copyFile(new URL("./analytics-client.js", import.meta.url), client); -} else await rm(client, { force: true }); +for (const [source, name] of [["analytics-client.js", "analytics.js"], ["analytics-consent.css", "analytics-consent.css"]]) { + const target = resolve(directory, "site-assets", name); + if (count) { + await mkdir(resolve(directory, "site-assets"), { recursive: true }); + await copyFile(new URL(source, import.meta.url), target); + } else await rm(target, { force: true }); +} console.log(id ? `GA4 configured on ${count} canonical public pages` : "GA4 disabled: measurement ID is unset"); diff --git a/apps/presentation/site/scripts/analytics-client.js b/apps/presentation/site/scripts/analytics-client.js index fed7f8b038..9fe63a89a7 100644 --- a/apps/presentation/site/scripts/analytics-client.js +++ b/apps/presentation/site/scripts/analytics-client.js @@ -4,57 +4,135 @@ if (!/^G-[A-Z0-9]+$/.test(id ?? "") || location.hostname !== "loopx-project.github.io" || navigator.doNotTrack === "1" || navigator.globalPrivacyControl || window.__loopxAnalyticsActive) return; window.__loopxAnalyticsActive = true; - window.dataLayer = window.dataLayer || []; - function gtag() { window.dataLayer.push(arguments); } - const originOnly = (url) => { try { return new URL(url).origin; } catch { return ""; } }; - let previous = ""; - const pageFields = () => { - const canonical = document.querySelector('link[rel="canonical"]')?.href; - if (!canonical) return null; - const url = new URL(canonical); - if (url.origin !== "https://loopx-project.github.io" || !url.pathname.startsWith("/loopx/")) return null; - return { page_location: url.origin + url.pathname, page_referrer: originOnly(document.referrer), - page_title: document.title, language: new URLSearchParams(location.search).get("lang") === "zh" ? "zh-CN" : document.documentElement.lang }; + const storageKey = "loopx.analytics-consent.v1"; + const readChoice = () => { + try { + const choice = JSON.parse(localStorage.getItem(storageKey))?.choice; + return ["granted", "denied"].includes(choice) ? choice : null; + } catch { return null; } }; - const initial = pageFields(); - if (!initial) return; - gtag("js", new Date()); - gtag("set", initial); - gtag("config", id, { ...initial, send_page_view: false, - allow_google_signals: false, allow_ad_personalization_signals: false }); - const pageView = () => { - const fields = pageFields(); - if (!fields || fields.page_location === previous) return; - previous = fields.page_location; - gtag("set", fields); - gtag("event", "page_view", fields); + let choice = readChoice(); + let started = false; + const panel = document.createElement("section"); + panel.id = "loopx-analytics-consent"; + panel.setAttribute("aria-label", "Analytics preferences"); + panel.innerHTML = `<p></p><a href="https://policies.google.com/technologies/partner-sites" target="_blank" rel="noopener"></a><div><button type="button" data-consent="granted"></button><button type="button" data-consent="denied"></button></div>`; + panel.hidden = choice !== null; + document.body.append(panel); + const settings = document.createElement("button"); + settings.type = "button"; + settings.id = "loopx-analytics-settings"; + settings.onclick = () => { panel.hidden = false; panel.querySelector("button").focus(); }; + const syncUi = () => { + const zh = new URLSearchParams(location.search).get("lang") === "zh" || document.documentElement.lang.startsWith("zh"); + const labels = zh ? [ + "经你允许,我们会使用 Google Analytics Cookie 统计访问和使用情况,以改进网站。可随时在页脚修改选择。", + "Google 如何使用数据", "允许统计", "拒绝", "统计偏好", + ] : [ + "With your permission, we use Google Analytics cookies to understand visits and usage and improve this site. You can change your choice in the footer.", + "How Google uses data", "Allow analytics", "Reject", "Analytics preferences", + ]; + [panel.querySelector("p"), panel.querySelector("a"), ...panel.querySelectorAll("button"), settings].forEach((node, i) => { + if (node.textContent !== labels[i]) node.textContent = labels[i]; + }); + if (panel.getAttribute("aria-label") !== labels[4]) panel.setAttribute("aria-label", labels[4]); + // React startup and Material instant navigation can replace the site footer. + const footer = document.querySelector(".site-footer, .bm-footer, .md-footer") || [...document.querySelectorAll("footer")].at(-1) || document.body; + if (settings.parentNode !== footer) footer.append(settings); }; - pageView(); - const tag = document.createElement("script"); - tag.async = true; - tag.src = `https://www.googletagmanager.com/gtag/js?id=${id}`; - document.head.append(tag); - // Material's instant navigation updates the canonical element. Locale and - // fragment changes on a React page must not inflate pageview counts. - new MutationObserver(pageView).observe(document.head, { subtree: true, childList: true, attributes: true, attributeFilter: ["href"] }); - const event = (name, fields = {}) => { - const page = pageFields(); - if (page) gtag("event", name, { ...page, ...fields }); + const choose = (next) => { + choice = next; + try { localStorage.setItem(storageKey, JSON.stringify({ choice, at: new Date().toISOString() })); } catch { /* The choice still applies to this page when storage is unavailable. */ } + panel.hidden = true; + if (choice === "granted") start(); + else if (started) { + // Disable collection immediately, remove measurement cookies, then unload + // the tag and its automatic timers. No denied-state pings are needed. + window[`ga-disable-${id}`] = true; + for (const cookie of document.cookie.split(";")) { + const name = cookie.split("=")[0].trim(); + if (name !== "_ga" && name !== `_ga_${id.slice(2)}`) continue; + for (const path of ["/", "/loopx", "/loopx/"]) { + for (const domain of ["", `; domain=${location.hostname}`]) { + document.cookie = `${name}=; Max-Age=0; path=${path}${domain}`; + } + } + } + location.reload(); + } + if (panel.contains(document.activeElement)) settings.focus({ preventScroll: true }); }; - document.addEventListener("click", (e) => { - const target = e.target instanceof Element ? e.target : null; - const marked = target?.closest("[data-analytics-event]")?.dataset.analyticsEvent; - if (["setup_open", "showcase_open"].includes(marked)) event(marked); - const anchor = target?.closest("a[href]"); - if (!anchor) return; - const url = new URL(anchor.href); - if (url.hostname === "github.com" && /^\/(?:loopx-project|huangruiteng)\/loopx(?:\/|$)/.test(url.pathname)) { - event("github_click", { destination: url.pathname.includes("packages/dsh-loopx-plugin") ? "dsh_plugin" : "repository" }); - } else if (url.origin === location.origin && url.pathname.startsWith("/loopx/docs/") && url.pathname !== location.pathname) { - event("docs_open"); + panel.querySelectorAll("button").forEach((button) => { button.onclick = () => choose(button.dataset.consent); }); + window.addEventListener("storage", (e) => { + if (e.key === storageKey) { + const next = readChoice(); + if (next !== choice) { + if (started) window[`ga-disable-${id}`] = true; + location.reload(); + } } }); - window.addEventListener("loopx:setup-copy", (e) => { - if (e.detail === "agent" || e.detail === "shell") event("setup_copy", { setup_method: e.detail }); - }); + syncUi(); + new MutationObserver(syncUi).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-language"] }); + if (choice === "granted") start(); + + function start() { + if (started || choice !== "granted") return; + started = true; + window.dataLayer = window.dataLayer || []; + function gtag() { window.dataLayer.push(arguments); } + const originOnly = (url) => { try { return new URL(url).origin; } catch { return ""; } }; + let previous = ""; + const pageFields = () => { + const canonical = document.querySelector('link[rel="canonical"]')?.href; + if (!canonical) return null; + const url = new URL(canonical); + if (url.origin !== "https://loopx-project.github.io" || !url.pathname.startsWith("/loopx/")) return null; + return { page_location: url.origin + url.pathname, page_referrer: originOnly(document.referrer), + page_title: document.title, language: new URLSearchParams(location.search).get("lang") === "zh" ? "zh-CN" : document.documentElement.lang }; + }; + const initial = pageFields(); + if (!initial) return; + gtag("consent", "default", { analytics_storage: "denied", ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied" }); + gtag("consent", "update", { analytics_storage: "granted" }); + gtag("js", new Date()); + gtag("set", initial); + gtag("config", id, { ...initial, send_page_view: false, + allow_google_signals: false, allow_ad_personalization_signals: false }); + const pageView = () => { + const fields = pageFields(); + if (choice !== "granted" || !fields || fields.page_location === previous) return; + previous = fields.page_location; + gtag("set", fields); + gtag("event", "page_view", fields); + }; + pageView(); + const tag = document.createElement("script"); + tag.async = true; + tag.src = `https://www.googletagmanager.com/gtag/js?id=${id}`; + document.head.append(tag); + // Material's instant navigation updates the canonical element. Locale and + // fragment changes on a React page must not inflate pageview counts. + new MutationObserver(pageView).observe(document.head, { subtree: true, childList: true, attributes: true, attributeFilter: ["href"] }); + const event = (name, fields = {}) => { + const page = pageFields(); + if (page && choice === "granted") gtag("event", name, { ...page, ...fields }); + }; + document.addEventListener("click", (e) => { + const target = e.target instanceof Element ? e.target : null; + const marked = target?.closest("[data-analytics-event]")?.dataset.analyticsEvent; + if (["setup_open", "showcase_open"].includes(marked)) event(marked); + const anchor = target?.closest("a[href]"); + if (!anchor) return; + const url = new URL(anchor.href); + if (url.hostname === "github.com" && /^\/(?:loopx-project|huangruiteng)\/loopx(?:\/|$)/.test(url.pathname)) { + event("github_click", { destination: url.pathname.includes("packages/dsh-loopx-plugin") ? "dsh_plugin" : "repository" }); + } else if (url.origin === location.origin && url.pathname.startsWith("/loopx/docs/") && url.pathname !== location.pathname) { + event("docs_open"); + } + }); + window.addEventListener("loopx:setup-copy", (e) => { + if (e.detail === "agent" || e.detail === "shell") event("setup_copy", { setup_method: e.detail }); + }); + } })(); diff --git a/apps/presentation/site/scripts/analytics-consent.css b/apps/presentation/site/scripts/analytics-consent.css new file mode 100644 index 0000000000..19b5659b17 --- /dev/null +++ b/apps/presentation/site/scripts/analytics-consent.css @@ -0,0 +1,38 @@ +#loopx-analytics-consent { + position: fixed; + inset: auto 16px 16px; + z-index: 900; + max-width: 960px; + margin: 0 auto; + padding: 16px 20px; + border: 1px solid #dedede; + border-radius: 12px; + background: #fff; + color: #171717; + font: 14px/1.5 "Geist", "Inter", "Helvetica Neue", Arial, sans-serif; + box-shadow: 0 4px 16px #0000000a; + display: flex; + align-items: center; + gap: 16px; +} +#loopx-analytics-consent[hidden] { display: none; } +#loopx-analytics-consent p { margin: 0; flex: 1; } +#loopx-analytics-consent a { color: #0070c9; font-size: 12px; text-decoration: underline; } +#loopx-analytics-consent div { display: flex; gap: 8px; flex-shrink: 0; } +#loopx-analytics-consent button, #loopx-analytics-settings { + min-height: 44px; + padding: 8px 14px; + border: 1px solid #dedede; + border-radius: 6px; + background: #fff; + color: #171717; + font: inherit; + cursor: pointer; +} +#loopx-analytics-settings { margin: 8px; font-size: 12px; } +#loopx-analytics-consent button:focus-visible, #loopx-analytics-consent a:focus-visible, #loopx-analytics-settings:focus-visible { outline: 2px solid #0070f3; outline-offset: 3px; } +@media (max-width: 640px) { + #loopx-analytics-consent { inset: auto 12px 12px; flex-wrap: wrap; gap: 12px; padding: 16px; } + #loopx-analytics-consent p { flex-basis: 100%; } + #loopx-analytics-consent div { margin-left: auto; } +} diff --git a/apps/presentation/site/scripts/analytics-smoke.mjs b/apps/presentation/site/scripts/analytics-smoke.mjs index 1e4d20706c..60c021758d 100644 --- a/apps/presentation/site/scripts/analytics-smoke.mjs +++ b/apps/presentation/site/scripts/analytics-smoke.mjs @@ -16,6 +16,7 @@ const inject = (id) => spawnSync(process.execPath, [resolve(root, "scripts/add-a let browser; try { await cp(resolve(process.argv[2] ?? resolve(root, "dist")), out, { recursive: true }); + assert.equal(inject("").status, 0); const initial = await readFile(resolve(out, "index.html"), "utf8"); assert.equal(inject("").status, 0); assert.equal(await readFile(resolve(out, "index.html"), "utf8"), initial, "disabled mode leaves HTML unchanged"); @@ -31,17 +32,30 @@ try { if (scenario === "dnt") await context.addInitScript(() => Object.defineProperty(navigator, "doNotTrack", { value: "1" })); if (scenario === "gpc") await context.addInitScript(() => Object.defineProperty(navigator, "globalPrivacyControl", { value: true })); const tagRequests = []; + const collectionRequests = []; + if (scenario !== "enabled") await context.addInitScript(() => localStorage.setItem("loopx.analytics-consent.v1", JSON.stringify({ choice: "granted" }))); const origin = scenario === "local" ? "http://localhost" : "https://loopx-project.github.io"; await context.route("**/*", async (route) => { const url = new URL(route.request().url()); if (url.hostname === "www.googletagmanager.com") { tagRequests.push(url.href); - return route.fulfill({ contentType: "text/javascript", body: "" }); + return route.fulfill({ contentType: "text/javascript", body: ` + document.cookie = "_ga=fixture; path=/; domain=loopx-project.github.io"; + document.cookie = "_ga_TEST123456=fixture; path=/"; + const collect = (args) => { if (args[0] === "event" && !window["ga-disable-G-TEST123456"]) fetch("https://www.google-analytics.com/g/collect", {method:"POST",body:JSON.stringify([...args])}); }; + window.dataLayer.forEach(collect); + const push = window.dataLayer.push.bind(window.dataLayer); + window.dataLayer.push = (...rows) => { rows.forEach(collect); return push(...rows); }; + ` }); + } + if (url.hostname === "www.google-analytics.com") { + collectionRequests.push(route.request().postData()); + return route.fulfill({ status: 204, body: "" }); } if (url.origin !== origin) return route.abort(); const path = url.pathname.replace(/^\/(?:loopx\/)?/, ""); try { - const file = resolve(out, path || "index.html"); + const file = resolve(out, !path || path.endsWith("/") ? path + "index.html" : path); const contentType = ({ ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".png": "image/png" })[extname(file)] ?? "application/octet-stream"; await route.fulfill({ contentType, body: await readFile(file) }); } catch { await route.fulfill({ status: 404, body: "Not found" }); } @@ -55,7 +69,22 @@ try { await context.close(); continue; } - await page.waitForFunction(() => window.dataLayer?.length >= 5); + await page.locator("#loopx-analytics-consent").waitFor(); + // A normal visitor's pre-choice interactions must not even initialize GA. + await page.evaluate(() => window.dispatchEvent(new CustomEvent("loopx:setup-copy", { detail: "shell" }))); + assert.equal(tagRequests.length, 0, "no remote tag before choosing"); + assert.equal(collectionRequests.length, 0, "no collection before choosing"); + assert.equal(await page.evaluate(() => window.dataLayer), undefined); + assert.equal((await context.cookies()).filter(c => c.name.startsWith("_ga")).length, 0); + await page.locator('[data-consent="denied"]').click(); + await page.reload(); + await page.locator("h1").waitFor(); + assert.equal(await page.locator("#loopx-analytics-consent").isVisible(), false, "rejection remembered"); + assert.equal(tagRequests.length, 0, "rejected reload stays off"); + await page.locator("#loopx-analytics-settings").click(); + await page.locator('[data-consent="granted"]').click(); + await page.waitForFunction(() => window.dataLayer?.length >= 7); + await page.waitForFunction(() => document.cookie.includes("_ga=")); assert.equal(tagRequests.length, 1); await page.locator('.hero [data-analytics-event="setup_open"]').click(); // Only the successful copy path in App dispatches this signal. Invalid @@ -76,12 +105,35 @@ try { document.querySelector('link[rel="canonical"]').href = "https://loopx-project.github.io/loopx/docs/"; }); await page.waitForFunction(() => window.dataLayer.filter((r) => r[0] === "event" && r[1] === "page_view").length === 2); + assert.equal(collectionRequests.filter(row => JSON.parse(row)[1] === "page_view").length, 2); + await page.reload(); + await page.waitForFunction(() => document.cookie.includes("_ga=")); + assert.equal(tagRequests.length, 2, "grant remembered across reloads"); + assert.equal(await page.locator("#loopx-analytics-consent").isVisible(), false); + await page.locator("#loopx-analytics-settings").click(); + const beforeWithdrawal = collectionRequests.length; + await Promise.all([page.waitForEvent("load"), page.locator('[data-consent="denied"]').click()]); + await page.locator("h1").waitFor(); + assert.equal(tagRequests.length, 2, "withdrawal unloads tag without reloading it"); + assert.equal(collectionRequests.length, beforeWithdrawal, "withdrawal sends no events"); + assert.equal(await page.evaluate(() => window.dataLayer), undefined); + assert.equal((await context.cookies()).filter(c => c.name.startsWith("_ga")).length, 0, "GA cookies cleared"); + // Replacing a footer (as in client rendering / instant navigation) retains + // the visitor's settings entry without re-enabling measurement. + await page.evaluate(() => document.querySelector(".site-footer").replaceChildren()); + await page.locator(".site-footer #loopx-analytics-settings").waitFor(); + if (await access(resolve(out, "docs/index.html")).then(() => true, () => false)) { + await page.goto(`${origin}/loopx/docs/`); + await page.locator("#loopx-analytics-settings").waitFor(); + assert.equal(tagRequests.length, 2, "rejection shared with documentation pages"); + } await context.close(); } assert.equal(inject("").status, 0); assert.equal(await readFile(resolve(out, "index.html"), "utf8"), initial, "disable removes injection"); await assert.rejects(access(resolve(out, "site-assets/analytics.js"))); - console.log("Analytics smoke: opt-in/off, injection idempotence, invalid ID, public-host scope, DNT/GPC, pageviews, locale dedupe, CTA schema and URL redaction passed; no Google traffic sent"); + await assert.rejects(access(resolve(out, "site-assets/analytics-consent.css"))); + console.log("Analytics smoke: pre-choice/rejection isolation, grant, remembered choice, withdrawal/cookie cleanup, footer replacement, deployment off, DNT/GPC and event boundaries passed; Google requests intercepted"); } finally { await browser?.close(); await rm(out, { recursive: true, force: true });