Skip to content

feat: offer the setup prompt on the homepage and track copies server-side - #540

Draft
claude[bot] wants to merge 6 commits into
mainfrom
feat/agent-prompt-homepage-tracking
Draft

feat: offer the setup prompt on the homepage and track copies server-side#540
claude[bot] wants to merge 6 commits into
mainfrom
feat/agent-prompt-homepage-tracking

Conversation

@claude

@claude claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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 humans is the CLI command, $ prefix and all, unchanged. For agents shows Read 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 root llms.txt also 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:

Action Event Discriminator
Get started clicked homepage:cta_click cta = get-started
For humans copied homepage:prompt_copy snippet = terminal
For agents copied homepage:prompt_copy snippet = agent

homepage:cta_click and app/src/app/get-started/route.ts are 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 new cta value 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 one text-muted-foreground with a hover:text-foreground, and a 1px bg-border rule sits between them. No new component, no new colour token, no new dependency — the UI is all still hero.tsx.

The CTA area is one centred column. Alex asked for the labels and the chip centred, with Get started underneath 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 the sm:flex-row and sm:items-end that 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 from sm up, because they belong to the chip and not to the button.

Spacing follows the hero's own scale: gap-2 between the labels and the chip, which keeps them reading as one unit, and gap-6 between the chip and the button — a step tighter than the space-y-8 the hero puts between its sections, so the button still reads as part of this group. The chip keeps the py-2.5 that 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. Below sm the group is w-full, so the chip still spans the hero column and shrinks its snippet instead of pushing past the gutter. The Get started button itself is untouched — same markup, same size, same 201×50 box at every width. Only its place in the layout moved.

hero.tsx keeps a useState tab selection and feeds the active snippet to the existing useCopy hook, 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 existing overflow-x-auto scroll 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.sendBeacon called with one argument sends no body, so the URL is the only channel there is, and the fetch(..., { keepalive: true }) fallback matches it. Every failure is still swallowed. The route captures homepage:prompt_copy and returns 204.

Validating the id. The snippet param 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 in app/src/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, and one list is better than two.

Notes for reviewers

  • homepage:prompt_copy changes 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 no snippet property 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.
  • Tracking stays cookieless and server-side; no posthog-js added. 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 mirrors homepage:cta_click: daily-rotating cookieless visitor hash, $process_person_profile: false, queued rather than flushed — nothing calls shutdown() or flush().
  • The endpoint is still POST-only and still reads no body. POST-only means link checkers, unfurl bots and crawlers — which only send GET/HEAD — cannot record copies no human performed, the same reasoning as the explicit HEAD export 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.
  • There is no rate limit on the endpoint, so a script can inflate copy counts. That was true before this PR and is unchanged by it; happy to add one if you would rather not carry it.
  • This adds an API route called from the interface, which cuts against the convention of not exposing API routes through the interface. Being straight about it: there is no other way to capture a client-side interaction without giving up cookieless, server-side-only tracking. If you would rather do this differently — a different path, a route not under /api, or simply not tracking the copy at all — say so and I will change it.
  • The labels are plain button elements, not the shared Button. A Button variant would have brought back the chrome the design is trying to lose. Keyboard focus is still visible: the base layer's outline-ring/50 colours the UA focus ring.
  • The chip's height is its own padding, not a row's. Stacked, that no longer has to match anything, but it does keep py-2.5 coupled to the icon-sm copy button inside it; the comment in hero.tsx says so, in case either changes later.
  • The stacked layout changes DOM order as well as visual order. The tabs and the chip now come before the Get started link in the markup, so keyboard order follows what is on screen rather than crossing it.
  • The source-files manifest regen includes pre-existing drift. app/src/components/homepage/source-files.json was already stale on main, so bun run generate:source-files added seven paths that have nothing to do with this PR (get-started/route.ts plus its test, lib/utm.ts, CHANGELOG.md, docs/reference/caching.mdx, two files under docs/releases/) alongside this PR's three new files.
  • The copy icon button still has no accessible name — pre-existing, left alone here, happy to fix separately.

Scope

  • app/ (hosted site, MCP, Ask AI)
  • packages/cli/
  • packages/mdx-bundler/
  • docs/ (product documentation)
  • Repo / CI / other

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Refactor / chore

Test plan

  • bun run check passes locally — Checked 256 files in 487ms. No fixes applied.

  • bun test132 pass, 0 fail, 299 expect() calls across 17 files. app/src/app/api/track/prompt-copy/route.test.ts is now 14 tests: 204 and empty body, event name, $process_person_profile: false, one event per request, no GET/HEAD export, plus the new discriminator — snippet captured 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, 204 whatever the param says, and all five utm keys landing while an unknown utm_ key does not.

  • Sanity-checked that the new tests bite: replacing the validated spread with a bare snippet in the capture fails exactly the five drop/absent assertions, and nothing else.

  • next build from app/Compiled successfully in 34.2s, TypeScript clean, /api/track/prompt-copy still 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-files from app/ — 369 paths, one line added for the new lib/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.scrollWidth equals clientWidth at every width, so no horizontal page overflow. The three stacked boxes share one centre line at each width — centre x of 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: labels y: 461 → 481, chip y: 489 → 539, button y: 563 → 613. The Get started button 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. Below sm the 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 answered 204. The query strings observed, param by param in the order they appeared on the URL:

    Copy Beacon query params
    For humans snippet=terminal, utm_source=test-src, utm_campaign=test-camp
    For agents snippet=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-src and utm_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 as npx @docs.page/cli init for 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.

claude added 3 commits August 27, 2026 09:45
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.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@railway-app

railway-app Bot commented Aug 27, 2026

Copy link
Copy Markdown

🚅 Deployed to the docs.page-pr-540 environment in docs.page

Service Status Web Updated (UTC)
docs.page ✅ Success (View Logs) Web Aug 27, 2026 at 11:43 am
@railway-app
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
railway-app Bot temporarily deployed to docs.page / docs.page-pr-540 August 27, 2026 11:10 Destroyed
@claude
claude Bot marked this pull request as draft August 27, 2026 11:17
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
railway-app Bot temporarily deployed to docs.page / docs.page-pr-540 August 27, 2026 11:26 Destroyed
@claude
claude Bot marked this pull request as ready for review August 27, 2026 11:31
@claude
claude Bot marked this pull request as draft August 27, 2026 11:32
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants