Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 12 additions & 48 deletions apps/docs/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -308,17 +308,17 @@
.agent-setup-heading h2 { margin-top: .6rem; font-size: clamp(2rem, 4.5vw, 3.65rem); font-weight: 720; letter-spacing: -.055em; line-height: 1.02; text-wrap: balance; }
.agent-setup-heading > span { display: block; max-width: 700px; margin-top: 1rem; color: var(--color-fd-muted-foreground); font-size: .98rem; line-height: 1.65; }

.agent-setup-tabs {
.agent-setup-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .7rem;
margin-top: 2rem;
}

.agent-setup-tabs > button {
.agent-setup-actions > button {
display: grid;
min-width: 0;
grid-template-columns: auto 1fr;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: .75rem;
border: 1px solid var(--color-fd-border);
Expand All @@ -329,52 +329,18 @@
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
}

.agent-setup-tabs > button:hover { transform: translateY(-2px); border-color: color-mix(in oklab, var(--color-fd-primary) 50%, transparent); }
.agent-setup-tabs > button[aria-selected="true"] { border-color: var(--color-fd-primary); background: color-mix(in oklab, var(--color-fd-primary) 10%, var(--color-fd-background)); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--color-fd-primary) 20%, transparent); }
.agent-setup-tab__mark { display: grid; width: 2.7rem; height: 2.7rem; place-items: center; border-radius: .65rem; background: #f6f5f1; }
.agent-setup-tab__mark img { width: 1.8rem; height: 1.8rem; object-fit: contain; }
.agent-setup-tabs strong, .agent-setup-tabs small { display: block; }
.agent-setup-tabs strong { font-size: .9rem; }
.agent-setup-tabs small { overflow: hidden; margin-top: .1rem; color: var(--color-fd-muted-foreground); font-size: .68rem; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
.agent-setup-actions > button:hover { transform: translateY(-2px); border-color: color-mix(in oklab, var(--color-fd-primary) 50%, transparent); }
.agent-setup-actions > button[data-copy-state="copied"] { border-color: var(--color-fd-primary); background: color-mix(in oklab, var(--color-fd-primary) 10%, var(--color-fd-background)); }
.agent-setup-actions > button > svg { width: 1rem; height: 1rem; color: var(--color-fd-primary); }
.agent-setup-action__mark { display: grid; width: 2.7rem; height: 2.7rem; place-items: center; border-radius: .65rem; background: #f6f5f1; }
.agent-setup-action__mark img { width: 1.8rem; height: 1.8rem; object-fit: contain; }
.agent-setup-action__label strong, .agent-setup-action__label small { display: block; }
.agent-setup-action__label strong { margin-top: .1rem; font-size: .78rem; }
.agent-setup-action__label small { color: var(--color-fd-muted-foreground); font-size: .68rem; }

.agent-setup-panel {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(17rem, .85fr);
gap: 1rem;
margin-top: 1rem;
}

.agent-setup-panel[hidden] { display: none; }

.agent-setup-prompt,
.agent-setup-contract {
min-width: 0;
border: 1px solid var(--color-fd-border);
border-radius: 1rem;
padding: 1rem;
background: color-mix(in oklab, var(--color-fd-background) 86%, transparent);
}

.agent-setup-prompt__heading { display: flex; align-items: center; gap: .8rem; margin-bottom: .85rem; }
.agent-setup-prompt__logo { display: grid; width: 3.25rem; height: 3.25rem; flex: none; place-items: center; border-radius: .72rem; background: #f6f5f1; }
.agent-setup-prompt__logo img { width: 2rem; height: 2rem; object-fit: contain; }
.agent-setup-prompt__heading p { color: var(--color-fd-primary); font-size: .67rem; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
.agent-setup-prompt__heading h3 { margin-top: .15rem; font-size: 1.15rem; font-weight: 720; letter-spacing: -.02em; }
.agent-setup-prompt .command-block pre { max-height: 21rem; overflow: auto; font-size: .73rem; line-height: 1.65; }
.agent-setup-links { display: flex; flex-wrap: wrap; align-items: center; gap: .65rem 1rem; margin-top: .85rem; font-size: .78rem; font-weight: 650; }
.agent-setup-links a { display: inline-flex; align-items: center; gap: .35rem; color: var(--color-fd-primary); text-decoration: underline; text-underline-offset: .25rem; }
.agent-setup-links svg { width: .85rem; height: .85rem; }

.agent-setup-contract { display: grid; align-content: start; gap: 1rem; }
.agent-setup-contract > div { border-bottom: 1px solid var(--color-fd-border); padding-bottom: 1rem; }
.agent-setup-contract__icon { display: grid; width: 2rem; height: 2rem; place-items: center; border-radius: .55rem; background: color-mix(in oklab, var(--color-fd-primary) 13%, transparent); color: var(--color-fd-primary); }
.agent-setup-contract__icon svg { width: 1.05rem; height: 1.05rem; }
.agent-setup-contract h3 { margin-top: .65rem; font-size: .9rem; font-weight: 720; }
.agent-setup-contract ul { display: grid; gap: .38rem; margin-top: .55rem; color: var(--color-fd-muted-foreground); font-size: .72rem; line-height: 1.45; }
.agent-setup-contract li { position: relative; padding-left: .8rem; }
.agent-setup-contract li::before { position: absolute; left: 0; color: var(--color-fd-primary); content: "•"; }
.agent-setup-reload { color: var(--color-fd-muted-foreground); font-size: .75rem; line-height: 1.5; }
.agent-setup-reload strong { color: var(--color-fd-foreground); }
.agent-setup-noscript { margin-top: 1rem; border: 1px solid var(--color-fd-border); border-radius: .75rem; padding: .8rem; color: var(--color-fd-muted-foreground); font-size: .8rem; }
.agent-setup-noscript a, .agent-setup-manual a { color: var(--color-fd-primary); font-weight: 650; text-decoration: underline; text-underline-offset: .25rem; }
.agent-setup-manual { margin-top: 1rem; color: var(--color-fd-muted-foreground); font-size: .82rem; text-align: center; }
Expand Down Expand Up @@ -475,7 +441,6 @@
@media (max-width: 900px) {
.hero-shell { min-height: auto; grid-template-columns: 1fr; padding-top: 6.5rem; }
.hero-terminal { width: min(620px, 100%); transform: none; }
.agent-setup-panel { grid-template-columns: 1fr; }
.agent-shell { grid-template-columns: 1fr; }
.path-grid { grid-template-columns: 1fr; }
.path-card { min-height: 14rem; }
Expand All @@ -490,8 +455,7 @@
.button-tertiary { width: 100%; }
.lifecycle-preview li { grid-template-columns: 1.5rem 3.5rem 1fr; padding-inline: .3rem; }
.agent-grid { grid-template-columns: 1fr; }
.agent-setup-tabs { grid-template-columns: 1fr; }
.agent-setup-tabs > button { grid-template-columns: auto 1fr; }
.agent-setup-actions { grid-template-columns: 1fr; }
.agent-card { grid-template-columns: auto 1fr auto; align-items: center; }
.agent-card__mark { grid-column: auto; width: 4rem; aspect-ratio: 1; }
.agent-card__mark img { width: 2.35rem; height: 2.35rem; }
Expand Down
143 changes: 58 additions & 85 deletions apps/docs/components/agent-setup-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,46 @@
'use client';

import { ArrowRight, FileCheck2, ShieldCheck } from 'lucide-react';
import { ArrowRight, Check, Copy } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useState, type KeyboardEvent } from 'react';
import { CommandBlock } from '@/components/command-block';
import { useState } from 'react';
import {
agentClientIds,
agentRecipes,
type AgentClientId,
} from '@/lib/agent-recipes';

export function AgentSetupPanel() {
const [selectedClient, setSelectedClient] = useState<AgentClientId>('codex');
const [copyState, setCopyState] = useState<{
client: AgentClientId | null;
status: 'idle' | 'copied' | 'error';
}>({ client: null, status: 'idle' });

function selectFromKeyboard(event: KeyboardEvent<HTMLButtonElement>, client: AgentClientId) {
const currentIndex = agentClientIds.indexOf(client);
let nextIndex: number | undefined;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % agentClientIds.length;
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') nextIndex = (currentIndex - 1 + agentClientIds.length) % agentClientIds.length;
if (event.key === 'Home') nextIndex = 0;
if (event.key === 'End') nextIndex = agentClientIds.length - 1;
if (nextIndex === undefined) return;
async function copyInstallPrompt(client: AgentClientId) {
const prompt = agentRecipes[client].setupPrompt;
try {
await navigator.clipboard.writeText(prompt);
} catch {
try {
const fallback = document.createElement('textarea');
fallback.value = prompt;
fallback.setAttribute('readonly', '');
fallback.style.position = 'fixed';
fallback.style.opacity = '0';
document.body.append(fallback);
fallback.select();
const copied = document.execCommand('copy');
fallback.remove();
if (!copied) throw new Error('Fallback copy was rejected');
} catch {
setCopyState({ client, status: 'error' });
window.setTimeout(() => setCopyState({ client: null, status: 'idle' }), 2400);
return;
}
}

event.preventDefault();
const nextClient = agentClientIds[nextIndex];
setSelectedClient(nextClient);
document.getElementById(`agent-tab-${nextClient}`)?.focus();
setCopyState({ client, status: 'copied' });
window.setTimeout(() => setCopyState({ client: null, status: 'idle' }), 1600);
}

return (
Expand All @@ -35,93 +49,52 @@ export function AgentSetupPanel() {
<p>Set up with your agent</p>
<h2 id="agent-setup-title">One prompt. Project-scoped by default.</h2>
<span>
Pick your client and copy its complete setup contract. Your agent previews repository
changes, preserves existing configuration, verifies diagnostics, and stops before product work.
Choose your coding agent, copy the install prompt, and hand off setup in one click.
</span>
</div>

<div className="agent-setup-tabs" role="tablist" aria-label="Choose your coding agent">
<div className="agent-setup-actions" aria-label="Copy an install prompt for your coding agent">
{agentClientIds.map((client) => {
const option = agentRecipes[client];
const selected = selectedClient === client;
const currentStatus = copyState.client === client ? copyState.status : 'idle';
return (
<button
key={client}
id={`agent-tab-${client}`}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`agent-panel-${client}`}
tabIndex={selected ? 0 : -1}
data-agent-tab={client}
onClick={() => setSelectedClient(client)}
onKeyDown={(event) => selectFromKeyboard(event, client)}
data-agent-copy={client}
data-copy-state={currentStatus}
aria-label={`${currentStatus === 'copied' ? 'Copied' : currentStatus === 'error' ? 'Copy failed for' : 'Copy install prompt for'} ${option.displayName}`}
onClick={() => copyInstallPrompt(client)}
>
<span className="agent-setup-tab__mark">
<span className="agent-setup-action__mark">
<Image src={option.logoPath} width={40} height={40} alt={option.logoAlt} />
</span>
<span><strong>{option.displayName}</strong><small>{option.cardSummary}</small></span>
<span className="agent-setup-action__label">
<small>{option.displayName}</small>
<strong>{currentStatus === 'copied' ? 'Copied' : currentStatus === 'error' ? 'Try again' : 'Copy install prompt'}</strong>
</span>
{currentStatus === 'copied' ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
</button>
);
})}
</div>

{agentClientIds.map((client) => {
const recipe = agentRecipes[client];
const selected = selectedClient === client;
return (
<div
key={client}
id={`agent-panel-${client}`}
role="tabpanel"
aria-labelledby={`agent-tab-${client}`}
className="agent-setup-panel"
data-agent-setup-panel={client}
hidden={!selected}
>
<div className="agent-setup-prompt">
<div className="agent-setup-prompt__heading">
<span className="agent-setup-prompt__logo" aria-hidden="true">
<Image src={recipe.logoPath} width={54} height={54} alt="" />
</span>
<div>
<p>Canonical {recipe.displayName} contract</p>
<h3>Paste this into {recipe.displayName}</h3>
</div>
</div>
<CommandBlock command={recipe.setupPrompt} label={`${recipe.displayName} setup prompt`} />
<div className="agent-setup-links">
<Link prefetch={false} href="/docs/agents/quickstart">
Full Agent Quickstart <ArrowRight aria-hidden="true" />
</Link>
<Link prefetch={false} href="/docs/agents/quickstart.md">
Open Markdown contract
</Link>
</div>
</div>
<span className="sr-only" role="status" aria-live="polite">
{copyState.client && copyState.status === 'copied'
? `${agentRecipes[copyState.client].displayName} install prompt copied to clipboard.`
: copyState.client && copyState.status === 'error'
? `Could not copy the ${agentRecipes[copyState.client].displayName} install prompt.`
: ''}
</span>

<div className="agent-setup-contract">
<div>
<span className="agent-setup-contract__icon"><ShieldCheck aria-hidden="true" /></span>
<h3>Safety boundary</h3>
<ul>
{recipe.safetyRequirements.slice(0, 4).map((requirement) => (
<li key={requirement}>{requirement}</li>
))}
</ul>
</div>
<div>
<span className="agent-setup-contract__icon"><FileCheck2 aria-hidden="true" /></span>
<h3>Required success receipt</h3>
<ul>
{recipe.successReceiptFields.map((field) => <li key={field}>{field}</li>)}
</ul>
</div>
<p className="agent-setup-reload"><strong>After setup:</strong> {recipe.reloadGuidance}</p>
</div>
</div>
);
})}
<div className="agent-setup-links">
<Link prefetch={false} href="/docs/agents/quickstart">
Full Agent Quickstart <ArrowRight aria-hidden="true" />
</Link>
<Link prefetch={false} href="/docs/agents/quickstart.md">
Open Markdown contract
</Link>
</div>

<noscript>
<div className="agent-setup-noscript">
Expand Down
61 changes: 31 additions & 30 deletions apps/docs/scripts/verify-agent-landing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { agentClientIds, agentRecipes } from '../lib/agent-recipes.ts';
const docsRoot = path.resolve(import.meta.dirname, '..');
const landing = await readFile(path.join(docsRoot, 'app', 'page.tsx'), 'utf8');
const panel = await readFile(path.join(docsRoot, 'components', 'agent-setup-panel.tsx'), 'utf8');
const commandBlock = await readFile(path.join(docsRoot, 'components', 'command-block.tsx'), 'utf8');

for (const marker of [
"href=\"#agent-setup\"",
Expand All @@ -22,46 +21,48 @@ for (const marker of [
}

for (const marker of [
'role="tablist"',
'role="tab"',
'aria-selected={selected}',
'role="tabpanel"',
'id={`agent-panel-${client}`}',
'aria-labelledby={`agent-tab-${client}`}',
'data-agent-setup-panel={client}',
'hidden={!selected}',
"event.key === 'ArrowRight'",
"event.key === 'ArrowLeft'",
"event.key === 'Home'",
"event.key === 'End'",
'command={recipe.setupPrompt}',
'recipe.safetyRequirements',
'recipe.successReceiptFields',
'agentClientIds.map((client) =>',
'data-agent-copy={client}',
'const prompt = agentRecipes[client].setupPrompt',
"status: 'idle' | 'copied' | 'error'",
"document.execCommand('copy')",
"if (!copied) throw new Error",
'role="status"',
'aria-live="polite"',
'/docs/agents/quickstart.md',
'<noscript>',
'/docs/getting-started/installation',
]) {
assert(panel.includes(marker), `agent setup panel is missing ${marker}`);
}

assert(!panel.includes('Set up Planr for Codex in this repository.'), 'panel must not copy the canonical prompt into JSX');
for (const forbidden of [
'role="tablist"',
'role="tab"',
'role="tabpanel"',
'<CommandBlock',
'Safety boundary',
'Required success receipt',
'recipe.safetyRequirements',
'recipe.successReceiptFields',
'Set up Planr for Codex in this repository.',
]) {
assert(!panel.includes(forbidden), `compact agent setup panel must not render ${forbidden}`);
}
assert.equal(
panel.match(/setupPrompt/g)?.length,
1,
'the canonical setupPrompt must be consumed only once by the clipboard path',
);
assert(
panel.includes('const prompt = agentRecipes[client].setupPrompt;'),
'the clipboard path must read setupPrompt directly from the typed recipe',
);
assert.deepEqual(agentClientIds, ['codex', 'claude', 'cursor']);
for (const client of agentClientIds) {
assert(agentRecipes[client].setupPrompt.length > 1_000, `${client} setup prompt is not self-contained`);
assert(agentRecipes[client].successReceiptFields.length >= 8, `${client} receipt is incomplete`);
assert(agentRecipes[client].safetyRequirements.length >= 7, `${client} safety contract is incomplete`);
}

for (const marker of [
"'idle' | 'copied' | 'error'",
"setCopyState('error')",
"document.execCommand('copy')",
"if (!copied) throw new Error",
'role="status"',
'aria-live="polite"',
"'Copy failed'",
]) {
assert(commandBlock.includes(marker), `copy control is missing ${marker}`);
}

console.log(`agent_landing_contract=passed clients=${agentClientIds.length} canonical_prompts=${agentClientIds.length} receipt_fields=${agentRecipes.codex.successReceiptFields.length}`);
console.log(`agent_landing_contract=passed compact_actions=${agentClientIds.length} canonical_prompts=${agentClientIds.length} visible_prompt_bodies=0 visible_contract_blocks=0`);
Loading
Loading