diff --git a/internal/docscheck/docs_test.go b/internal/docscheck/docs_test.go index 0806ef2a..564a6036 100644 --- a/internal/docscheck/docs_test.go +++ b/internal/docscheck/docs_test.go @@ -116,7 +116,7 @@ func TestPublicDocumentationContractsStayAligned(t *testing.T) { "patterns/shared-web-roost/INSTRUCTIONS.md": {"WT_ROOST_ALLOWED_EMAILS", "OAuth by itself proves"}, "patterns/hosted-browser-wing/INSTRUCTIONS.md": {"Account access and wing policy are independent", "wt login", "wt start", "self-hosted roost", "application-encrypted"}, "internal/relay/templates/base.html": {`href="/install" class="nav-cta">install locally`, `href="/login">login`, ">open app"}, - "internal/relay/templates/home.html": {"local-first control for coding agents", "wt mcp stdio", "wt egg", "wt mcp connect", "wt serve --local --https", "optional hosted browser"}, + "internal/relay/templates/home.html": {"local-first control for coding agents", "Give an agent typed control of local coding agents", `href="/patterns">choose a route`}, "internal/relay/templates/docs.html": {"WT_BASE_URL=https://roost.example.com", "WT_ROOST_ALLOWED_EMAILS", "wt roost start --addr :8080", "wt serve --addr :8080", "never silently changes", "gateway database contains", "does not let an account grant itself relay access", "no request happens until you choose load or open", "200,000 serialized terminal characters", "a wing binary from before", "stop or isolate the wing", "Remote execution through direct MCP or the hosted browser needs a running wing", "do not require a separate wing daemon", "connector token", "wt login", "wt start"}, "internal/relay/templates/patterns.html": {"/patterns/hosted-browser-wing/INSTRUCTIONS.md", "account with hosted-relay access", "hosted browser -> encrypted relay -> selected wing", "authorization for the connector account", "parent/connector needs"}, "internal/relay/templates/privacy.html": {"gateway database contains", "embedded wing separately keeps", "200,000 serialized terminal characters", "Clearing the site's browser data removes"}, @@ -178,6 +178,29 @@ func TestPublicDocumentationContractsStayAligned(t *testing.T) { } } +func TestPublicHomeStaysConciseAndVideoFirst(t *testing.T) { + root := repositoryRoot(t) + data, err := os.ReadFile(filepath.Join(root, "internal/relay/templates/home.html")) + if err != nil { + t.Fatal(err) + } + home := string(data) + + const intro = `

Give an agent typed control of local coding agents, using the code and provider login already on this machine.

` + if got := strings.Count(home, intro); got != 1 { + t.Fatalf("homepage contains %d concise agent-first intros, want 1", got) + } + if !strings.Contains(home, intro+"\n"+`{{if .HeroVideo}}
`) { + t.Fatal("homepage install/app prompt does not immediately follow the hero video") + } + if strings.Contains(home, `data-route=`) { + t.Fatal("homepage must not contain the detailed data-route map") + } +} + func TestPublicEntryPointsKeepLocalAgentFirstHierarchy(t *testing.T) { root := repositoryRoot(t) read := func(rel string) string { @@ -213,7 +236,6 @@ func TestPublicEntryPointsKeepLocalAgentFirstHierarchy(t *testing.T) { }, } for _, rel := range []string{ - "internal/relay/templates/home.html", "internal/relay/templates/docs.html", "internal/relay/templates/install.html", "internal/relay/templates/patterns.html", @@ -251,13 +273,6 @@ func TestPublicEntryPointsKeepLocalAgentFirstHierarchy(t *testing.T) { "run the browser portal locally first", "already has hosted-relay access", }, - "internal/relay/templates/home.html": { - "wt mcp stdio · no account or daemon", - "wt egg · sandboxed and attachable", - "direct remote MCP to an explicit wing", - "wt serve --local --https · self-host first", - "requires hosted-relay entitlement and an allowing wing", - }, "internal/relay/templates/docs.html": { "existing project directories and the current OS user's existing provider logins", "connector token", diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index 17c547fc..9273ec80 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -496,8 +496,9 @@ func TestSignedInHomeUsesConfiguredAppURLForLinkAndShortcut(t *testing.T) { } } -func TestPublicHomeLeadsWithLocalAgentFirstRoutes(t *testing.T) { - _, ts := testServer(t) +func TestPublicHomeLeadsWithConciseLocalAgentIntro(t *testing.T) { + server, ts := testServer(t) + server.Config.HeroVideo = "hero.mp4" resp, err := http.Get(ts.URL + "/") if err != nil { t.Fatalf("GET /: %v", err) @@ -508,32 +509,11 @@ func TestPublicHomeLeadsWithLocalAgentFirstRoutes(t *testing.T) { t.Fatalf("read /: %v", err) } page := body.String() - previous := -1 - for _, route := range []string{ - `href="/patterns/local-subagents/INSTRUCTIONS.md" data-route="local-agent"`, - `href="/patterns/local-sandbox/INSTRUCTIONS.md" data-route="local-human"`, - `href="/patterns/remote-orchestration/INSTRUCTIONS.md" data-route="direct-remote"`, - `href="/patterns/personal-remote-wing/INSTRUCTIONS.md" data-route="self-hosted-browser"`, - `href="/patterns/hosted-browser-wing/INSTRUCTIONS.md" data-route="hosted-relay"`, - } { - index := strings.Index(page, route) - if index < 0 { - t.Fatalf("home is missing route %q", route) - } - if index <= previous { - t.Fatalf("home route %q is out of local-agent-first order", route) - } - previous = index + if strings.Contains(page, `data-route=`) { + t.Fatal("home includes detailed route-map links") } for _, contract := range []string{ - "wt mcp stdio", - "no account or daemon", - "wt egg", - "wt mcp connect", - "direct remote MCP to an explicit wing", - "wt serve --local --https", - "self-host first", - "requires hosted-relay entitlement and an allowing wing", + `href="/patterns">choose a route`, `href="/install" class="nav-cta">install locally`, `href="/login">login`, `href="/install" class="prompt-line" id="prompt-app"`, @@ -542,6 +522,18 @@ func TestPublicHomeLeadsWithLocalAgentFirstRoutes(t *testing.T) { t.Errorf("home does not contain hierarchy contract %q", contract) } } + const intro = `

Give an agent typed control of local coding agents, using the code and provider login already on this machine.

` + if got := strings.Count(page, intro); got != 1 { + t.Fatalf("home contains %d concise agent-first intros, want 1", got) + } + if !strings.Contains(page, intro+"\n"+`
`) + if video < 0 || install <= video { + t.Fatalf("home hero order video=%d install=%d, want video then install", video, install) + } for _, forbidden := range []string{">hosted app", ">hosted login"} { if strings.Contains(page, forbidden) { t.Errorf("home nav uses deployment-specific label %q", forbidden) diff --git a/internal/relay/templates/home.html b/internal/relay/templates/home.html index bfb4b778..569bff42 100644 --- a/internal/relay/templates/home.html +++ b/internal/relay/templates/home.html @@ -4,13 +4,6 @@ .hero{padding:40px 0 24px;text-align:center} .hero h1{font-size:28px;font-weight:700;letter-spacing:-0.03em;margin-bottom:8px} .hero .tagline{color:var(--text-dim);font-size:15px;line-height:1.6;max-width:720px;margin:0 auto 24px} -.route-map{max-width:900px;margin:0 auto 22px;text-align:left;display:grid;gap:8px} -.route-map a{display:grid;grid-template-columns:34px 220px 1fr;gap:12px;align-items:baseline;background:var(--bg-card);border:1px solid var(--accent);border-radius:6px;padding:10px 14px;text-decoration:none;color:var(--text)} -.route-map a:hover{border-color:var(--action)} -.route-num{font-family:var(--mono);font-size:11px;color:var(--action)} -.route-title{font-weight:600;font-size:13px} -.route-detail{font-size:12px;color:var(--text-dim)} -.route-detail code{color:var(--text)} .prompt-flow{font-family:var(--mono);font-size:18px;margin-top:8px} .prompt-line{display:flex;align-items:center;justify-content:center;gap:6px;padding:8px 0} .prompt-dim{color:var(--text-dim)} @@ -81,8 +74,6 @@ .sb-tip{font-family:var(--mono);font-size:11px;color:var(--text-dim);line-height:1.6;margin-top:12px;padding:0 4px} @media(max-width:700px){ .hero h1{font-size:22px} - .route-map a{grid-template-columns:28px 1fr;gap:4px 8px} - .route-detail{grid-column:2} .how-grid{grid-template-columns:1fr} .sb-layout{grid-template-columns:1fr} .install{font-size:11px;padding:12px 14px} @@ -108,21 +99,14 @@

local-first control for coding agents

-

Give an agent typed control of local agents on the code and provider login already on this machine. Move to direct remote MCP only when execution moves to another machine, and add a browser only when a person needs one.

- +

Give an agent typed control of local coding agents, using the code and provider login already on this machine.

+{{if .HeroVideo}}
{{end}} {{if .User}} {{else}}{{end}} -{{if .HeroVideo}}
{{end}}
diff --git a/test/web/direct-only.mjs b/test/web/direct-only.mjs index f4dfb89d..af96c78d 100644 --- a/test/web/direct-only.mjs +++ b/test/web/direct-only.mjs @@ -31,13 +31,33 @@ try { const publicPage = await publicContext.newPage(); await publicPage.goto(BASE + '/', { waitUntil: 'domcontentloaded' }); const publicState = await publicPage.evaluate(() => { + const text = (element) => element ? element.textContent.trim().replace(/\s+/g, ' ') : ''; + const isVisible = (element) => { + if (!element) return false; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; + }; + const comesBefore = (first, second) => Boolean(first && second && + (first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING)); const nav = document.querySelector('nav.site-nav'); const logo = nav.querySelector('.logo').getBoundingClientRect(); const links = nav.querySelector('.nav-links'); const linkElements = Array.from(links.querySelectorAll('a')); const linkRects = linkElements.map((link) => link.getBoundingClientRect()); - const routeElements = Array.from(document.querySelectorAll('.route-map [data-route]')); - const selfHosted = routeElements.find((route) => route.dataset.route === 'self-hosted-browser'); + const hero = document.querySelector('.container > .hero'); + const heroLinks = hero ? Array.from(hero.querySelectorAll('a[href]')) : []; + const heroVideo = hero ? hero.querySelector('video, iframe[title*="video" i], [data-hero-video]') : null; + const installCTA = heroLinks.find((link) => /\binstall\b/i.test(text(link))); + const patternsLink = Array.from(document.querySelectorAll('a[href]')).find((link) => { + const url = new URL(link.getAttribute('href'), location.href); + return url.origin === location.origin && url.pathname.replace(/\/$/, '') === '/patterns' && + text(link).toLowerCase() === 'patterns'; + }); + const introElements = hero ? Array.from(hero.children) + .filter((element) => element.matches('h1, p.tagline')) : []; + const introEnd = introElements[introElements.length - 1]; + const introText = introElements.map(text).join(' '); return { navLabels: linkElements.map((link) => link.textContent.trim()), ctaLabels: linkElements.filter((link) => link.classList.contains('nav-cta')).map((link) => link.textContent.trim()), @@ -45,9 +65,20 @@ try { linksBelowLogo: links.getBoundingClientRect().top >= logo.bottom, linksInsideViewport: linkRects.every((rect) => rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth + 0.5), noHorizontalOverflow: document.documentElement.scrollWidth <= innerWidth, - routes: routeElements.map((route) => route.dataset.route), - selfHostedHref: selfHosted ? selfHosted.getAttribute('href') : '', - selfHostedText: selfHosted ? selfHosted.textContent : '', + headingCount: introElements.filter((element) => element.matches('h1')).length, + introText, + routeMapCount: document.querySelectorAll('.route-map').length, + dataRouteCount: document.querySelectorAll('[data-route]').length, + patternsHref: patternsLink ? new URL(patternsLink.getAttribute('href'), location.href).pathname : '', + patternsVisible: isVisible(patternsLink), + heroConfigured: Boolean(heroVideo), + heroVisible: isVisible(heroVideo), + introBeforeHero: comesBefore(introEnd, heroVideo), + heroBeforeInstall: comesBefore(heroVideo, installCTA), + introBeforeInstall: comesBefore(introEnd, installCTA), + installCTAText: text(installCTA), + installCTAHref: installCTA ? installCTA.getAttribute('href') : '', + installCTAVisible: isVisible(installCTA), }; }); record('public mobile: nav wraps below the logo without clipping and keeps neutral actions', @@ -56,14 +87,19 @@ try { publicState.flexWrap === 'wrap' && publicState.linksBelowLogo && publicState.linksInsideViewport && publicState.noHorizontalOverflow, JSON.stringify(publicState)); - record('public mobile: home renders the required hierarchy with a coherent self-hosted recipe', - JSON.stringify(publicState.routes) === JSON.stringify([ - 'local-agent', 'local-human', 'direct-remote', 'self-hosted-browser', 'hosted-relay', - ]) && - publicState.selfHostedHref === '/patterns/personal-remote-wing/INSTRUCTIONS.md' && - publicState.selfHostedText.includes('wt serve --local --https') && - !publicState.selfHostedText.includes('wt roost start --https'), + record('public mobile: home keeps the concise local-agent hierarchy without the detailed route map', + publicState.headingCount === 1 && + /\blocal\b/i.test(publicState.introText) && /\bagents?\b/i.test(publicState.introText) && + publicState.routeMapCount === 0 && publicState.dataRouteCount === 0 && + publicState.patternsHref === '/patterns' && publicState.patternsVisible && + (!publicState.heroConfigured || (publicState.heroVisible && publicState.introBeforeHero && publicState.heroBeforeInstall)) && + publicState.introBeforeInstall && publicState.installCTAVisible && + /\binstall\b/i.test(publicState.installCTAText) && Boolean(publicState.installCTAHref), JSON.stringify(publicState)); + const patternsResponse = await publicContext.request.get(BASE + publicState.patternsHref); + record('public mobile: patterns remains the reachable detailed-route destination', + publicState.patternsHref === '/patterns' && patternsResponse.ok(), + JSON.stringify({ href: publicState.patternsHref, status: patternsResponse.status() })); await publicPage.screenshot({ path: `${OUT}/public-mobile-nav.png`, fullPage: false }); await publicContext.close();