feat: offer the setup prompt on the homepage and track copies server-side - #540
Draft
claude[bot] wants to merge 6 commits into
Draft
feat: offer the setup prompt on the homepage and track copies server-side#540claude[bot] wants to merge 6 commits into
claude[bot] wants to merge 6 commits into
Conversation
Adds POST /api/track/prompt-copy, which captures `homepage:prompt_copy` and answers 204. A clipboard copy never reaches the server and the homepage stays cookieless (no posthog-js), so a beacon the server turns into a capture is the only way to count one. The route takes no body and reads nothing user-controlled, so it cannot be used as an open data sink, and it exports POST only so link checkers, unfurl bots and crawlers cannot record copies no human performed. The capture mirrors `homepage:cta_click` exactly: daily-rotating cookieless visitor hash, no person profile, queued rather than flushed. Also regenerates the homepage source manifest, which picks up the two new files along with some drift that was already on main.
The hero chip now offers two snippets behind small tabs: Terminal (the `npx @docs.page/cli init` command, unchanged and still the default) and Agent (a prompt that points a coding agent at the setup instructions). The prompt is much longer than the command, so the chip caps the width of its scroll area and keeps the text on one scrolling line instead of growing the CTA row; below `sm` the tabs take a line of their own so the snippet keeps the full width. Copying the agent prompt — and only the agent prompt — beacons /api/track/prompt-copy, fire-and-forget, so a blocked or failed request can never break the copy itself.
The root llms.txt described how to consume an existing docs.page site but not how to create one. Adds a Markdown setup pointer so an agent reading it can go straight to the quickstart and the docs.json contract.
|
|
|
🚅 Deployed to the docs.page-pr-540 environment in docs.page
|
railway-app
Bot
temporarily deployed
to
docs.page / docs.page-pr-540
August 27, 2026 09:46
Destroyed
The two snippet tabs move out of the copy chip and onto a line of their own directly above it, relabelled For humans (the CLI command, still the default) and For agents (the setup prompt). They read as plain text toggles rather than buttons: the active label takes the foreground colour, the inactive one is muted with a hover, and a 1px border-coloured rule separates the two. Moving the labels above the chip makes the snippet a two-row column, taller than the Get started button beside it, so the CTA row can no longer align on stretch — the spare height would show as a gap under the button. The row aligns on its end edge instead, and the chip keeps its own py-2.5 at every width rather than borrowing its height from the stretched row: around the icon-sm copy button that lands at exactly the height of the button next to it, so the two boxes share both edges with the labels above them. Below sm, where the row stacks, the row is now full width so the chip still spans the hero column and shrinks its snippet instead of pushing past the gutter. The snippets, the per-tab reset of the copied tick, and the single fire-and-forget beacon on an agent-prompt copy are all unchanged, and so is the Get started button.
railway-app
Bot
temporarily deployed
to
docs.page / docs.page-pr-540
August 27, 2026 11:10
Destroyed
The hero copy chip beaconed `/api/track/prompt-copy` only from the `For agents` tab, and the beacon carried nothing to say which tab it came from. So the `For humans` copy was unmeasurable — a clipboard copy makes no request of its own, and the stale comment claiming CLI copies "already show up in the /get-started funnel" was simply wrong — and the two copies could not have been told apart even if both had fired. Both tabs now beacon, and the beacon names the snippet: `terminal` for the CLI command, `agent` for the setup prompt. It rides the URL as a query param so `navigator.sendBeacon`'s single-argument, bodyless form still works, with the `keepalive` fetch fallback and the swallow-everything error handling untouched — a failed beacon must never break the copy. The page's own utm params are forwarded on the same URL, which is what makes a copy attributable to a campaign at all; the route already reads utm off the request URL it is handed, so that side needed no change. The route adds the id as a `snippet` property on the existing `homepage:prompt_copy` event, validated against the closed set first: anything that is not one of the two ids is dropped rather than echoed, so no caller-chosen string reaches PostHog. Event name, `$raw_user_agent`, `$process_person_profile: false`, the utm spread, POST-only and the queued (never flushed) capture all stay as they were. Copies are deliberately NOT folded into `homepage:cta_click`. Insights count that event unfiltered, as both the numerator of an acquisition-rate metric and the denominator of an activation-rate metric, so a new `cta` value there would silently corrupt both. `/get-started` is untouched. The param name and the two ids live in `lib/prompt-copy.ts` because both ends of the beacon need them and neither can own them: importing the hero from the route handler would pull React, `next/link` and the icon set into the server module. `UTM_KEYS` moves out of `utmProperties` for the same reason — the hero now needs the same five keys the server attributes on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DVqUkMoWYpxWHk1Gcy7uBf
railway-app
Bot
temporarily deployed
to
docs.page / docs.page-pr-540
August 27, 2026 11:26
Destroyed
The CTA area was a row: Get started on the left, the snippet's labels and chip in a column to its right. Alex asked for the labels and chip centred on top with Get started underneath, so the whole group is now one centred column at every width. The button's own markup and size are untouched — only its place in the layout moves. The labels stay in the chip's column, centred with it rather than left-aligned from `sm` up, because they belong to the chip and not to the button. Mobile already stacked, so the `sm:flex-row` / `sm:items-end` pair that used to build the row is gone rather than left to contradict the new layout, and one gap-6 — on the hero's own spacing scale, a step tighter than the space-y-8 between its sections — replaces the old gap-3/gap-4 pair. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DVqUkMoWYpxWHk1Gcy7uBf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Alex Duke · Slack thread
Summary
Before — the hero's copy chip offered one thing:
npx @docs.page/cli init. A visitor who would rather have their coding agent do the setup had nothing to copy, and we had no idea how many of them there were.After — two plain-text toggles sit directly above the chip: For humans, the default, and For agents.
For humansis the CLI command,$prefix and all, unchanged.For agentsshowsRead https://use.docs.page/quickstart.md and set up docs.page in this repository., with no$prefix because it is a prompt, not a command. Both copies record one server-side event, and the event says which snippet it was. The rootllms.txtalso gained a pointer to the Markdown setup instructions, so an agent that reads it can find how to create a site, not just how to consume one.The tracking shape
Three homepage actions, two events, and every action distinguishable:
Get startedclickedhomepage:cta_clickcta=get-startedFor humanscopiedhomepage:prompt_copysnippet=terminalFor agentscopiedhomepage:prompt_copysnippet=agenthomepage:cta_clickandapp/src/app/get-started/route.tsare untouched by this PR.Copies are deliberately not folded into
homepage:cta_click. That would be the smaller diff, and it would be wrong: insights count that event unfiltered — it is the numerator of an acquisition-rate metric and the denominator of an activation-rate metric — so introducing a newctavalue would silently change both numbers without anything looking broken. A copy is also not a click: nobody lands anywhere. The copy keeps its own event, and the discriminator goes there.utm now rides the beacon. At copy time the hero reads
window.location.search, picks out the five keys we attribute on and appends any that are present to the beacon URL. Without this a copy is unattributable to any campaign at all: the route reads utm off the request URL it is handed, and until now that URL was a bare path. The server side needed no change for it.How
The two labels read as text rather than buttons: the active one is
text-foreground, the inactive onetext-muted-foregroundwith ahover:text-foreground, and a 1pxbg-borderrule sits between them. No new component, no new colour token, no new dependency — the UI is all stillhero.tsx.The CTA area is one centred column. Alex asked for the labels and the chip centred, with
Get startedunderneath them, so the hero's call to action now stacks at every width: the label row, then the chip, then the button, all three on the hero's vertical axis. The row that used to put the button to the left of the snippet is gone, and with it thesm:flex-rowandsm:items-endthat built it — mobile was already stacked, so leaving those in would have contradicted the new layout. The labels stay centred with the chip rather than left-aligned fromsmup, because they belong to the chip and not to the button.Spacing follows the hero's own scale:
gap-2between the labels and the chip, which keeps them reading as one unit, andgap-6between the chip and the button — a step tighter than thespace-y-8the hero puts between its sections, so the button still reads as part of this group. The chip keeps thepy-2.5that gives it a 50px height, which is no longer load-bearing now that nothing is aligned side by side, but does mean the chip and the button are two boxes of the same height stacked on one axis. Belowsmthe group isw-full, so the chip still spans the hero column and shrinks its snippet instead of pushing past the gutter. TheGet startedbutton itself is untouched — same markup, same size, same 201×50 box at every width. Only its place in the layout moved.hero.tsxkeeps auseStatetab selection and feeds the active snippet to the existinguseCopyhook, which takes its text as an argument — so switching tabs is all the plumbing the copy button needs. The snippet and its copy button are keyed by tab, so the copied tick can never carry over to a snippet the visitor did not copy. The prompt is far longer than the command, so the chip caps the width of the existingoverflow-x-autoscroll area and keeps the text on one scrolling line rather than widening the hero.The beacon. On copy of either snippet the client beacons
POST /api/track/prompt-copy, carrying the snippet id and any utm params as query params —navigator.sendBeaconcalled with one argument sends no body, so the URL is the only channel there is, and thefetch(..., { keepalive: true })fallback matches it. Every failure is still swallowed. The route captureshomepage:prompt_copyand returns204.Validating the id. The
snippetparam is the one user-controlled value the route reads, so it is checked against the closed set of ids we actually ship and dropped entirely if it is not in it — an arbitrary string must never become an event property, or a breakdown grows a bucket per attacker. The param name and the two ids live inapp/src/lib/prompt-copy.tsbecause both ends of the beacon need them and neither can own them: importing the hero from the route handler would pull React,next/linkand the icon set into the server module.UTM_KEYSmoves out ofutmPropertiesfor the same reason — the hero now needs the same five keys the server attributes on, and one list is better than two.Notes for reviewers
homepage:prompt_copychanges meaning, and it has no history. Until this PR the event fired only for the agent prompt, so any saved query that counts it unfiltered will step up once both tabs report, and events captured before this deploys carry nosnippetproperty at all — a breakdown will show one null bucket for everything prior. Nothing in the repo queries this event; worth knowing before anyone builds on it.posthog-jsadded. A clipboard copy never leaves the browser, so it is unmeasurable unless something tells the server. Rather than add a client-side PostHog (which would mean cookies and a cross-site request per visitor), the copy button pings a tiny endpoint and the server does the capture. The event mirrorshomepage:cta_click: daily-rotating cookieless visitor hash,$process_person_profile: false, queued rather than flushed — nothing callsshutdown()orflush().HEADexport on/get-started. The route's old comment justified its safety by saying nothing user-controlled was read; that is no longer true, so the comment now says what is actually true — one user-controlled value, validated against a closed set./api, or simply not tracking the copy at all — say so and I will change it.buttonelements, not the sharedButton. AButtonvariant would have brought back the chrome the design is trying to lose. Keyboard focus is still visible: the base layer'soutline-ring/50colours the UA focus ring.py-2.5coupled to theicon-smcopy button inside it; the comment inhero.tsxsays so, in case either changes later.Get startedlink in the markup, so keyboard order follows what is on screen rather than crossing it.app/src/components/homepage/source-files.jsonwas already stale onmain, sobun run generate:source-filesadded seven paths that have nothing to do with this PR (get-started/route.tsplus its test,lib/utm.ts,CHANGELOG.md,docs/reference/caching.mdx, two files underdocs/releases/) alongside this PR's three new files.Scope
app/(hosted site, MCP, Ask AI)packages/cli/packages/mdx-bundler/docs/(product documentation)Type of change
Test plan
bun run checkpasses locally —Checked 256 files in 487ms. No fixes applied.bun test—132 pass, 0 fail, 299 expect() callsacross 17 files.app/src/app/api/track/prompt-copy/route.test.tsis now 14 tests:204and empty body, event name,$process_person_profile: false, one event per request, noGET/HEADexport, plus the new discriminator —snippetcaptured for each of the two ids, kept apart across consecutive copies, dropped for an unknown id, a case variant, an empty value and a JSON-shaped payload, absent when the beacon sends no id,204whatever the param says, and all five utm keys landing while an unknownutm_key does not.Sanity-checked that the new tests bite: replacing the validated spread with a bare
snippetin the capture fails exactly the five drop/absent assertions, and nothing else.next buildfromapp/—Compiled successfully in 34.2s, TypeScript clean,/api/track/prompt-copystill registered as a dynamic route. This is the check that would catch the client/server bundle-graph mistake the shared module exists to avoid, so it matters more than usual here.bun run generate:source-filesfromapp/— 369 paths, one line added for the newlib/prompt-copy.ts. The stacking commit adds and removes no files, so no further regen was needed.Tested locally with
next dev(Next 16 plus Turbopack) and driven in Chromium. Measured in both tab states at 1280px, 375px and 320px:documentElement.scrollWidthequalsclientWidthat every width, so no horizontal page overflow. The three stacked boxes share one centre line at each width — centrexof 640 at 1280px, 187.5 at 375px and 160 at 320px for the label row, the chip and the button alike — and they run top to bottom in that order, with 8px between the labels and the chip and 24px between the chip and the button in every case. At 1280px: labelsy: 461 → 481, chipy: 489 → 539, buttony: 563 → 613. TheGet startedbutton measures 201×50 with an 18px font and 24px padding at all three widths, the same computed box as on the previous head, which was measured the same way for comparison. Belowsmthe chip still spans the hero column (359px at 375px, 304px at 320px).Copy behaviour, in-browser, after the layout change. Each copy sends exactly one request to
POST /api/track/prompt-copy, each answered204. The query strings observed, param by param in the order they appeared on the URL:For humanssnippet=terminal,utm_source=test-src,utm_campaign=test-campFor agentssnippet=agent,utm_source=test-src,utm_campaign=test-camp(The URLs are written out param by param rather than as literal query strings because this PR body sanitiser escapes the joining ampersand and leaves it escaped inside a code block.) Both rows are one page load carrying
utm_source=test-srcandutm_campaign=test-camp, copied from each tab in turn. Nothing beacons on page view or on tab switch, only on copy. Each tab still copies its own text to the clipboard, read back asnpx @docs.page/cli initfor the humans tab and the full prompt for the agents tab, and the copied tick resets when switching tabs.Screenshots of the stacked layout in both tab states at 1280px, 375px and 320px are posted in the Slack thread linked at the top of this description.