Landing gets an AI page (/ai) and counts clicks and section views

New public page /ai, linked from the navbar (AI), the Then just ask section
and the footer. It shows what an AI client can answer from the structured
enrollment data: a question gallery whose answer panel switches between chat
app, VS Code, terminal and self-hosted, the data path (where the model runs,
what reaches us) and how to connect. A test keeps every MCP tool and argument
on the page in line with the server's registrations for tenant users.

The landing, /get-started and /ai send anonymous click and section-view
counts (marketing_cta_clicked, marketing_section_viewed); every link and
button carries a data-track id. Layouts with their own OpenGraph metadata
keep the shared image and the large Twitter card again. The navbar tightens
between lg and xl so the new entry fits at 1024 px. The FAQ names the
documented MCP clients, and the terminal demo uses the real search_sessions
argument (deviceName).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-01 18:15:15 +02:00
parent e54b4585b8
commit ff9e79dcc7
58 changed files with 2468 additions and 192 deletions
@@ -82,7 +82,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
{
question: "Can I ask an AI assistant about my enrollments?",
answer:
"Yes. Autopilot Monitor exposes a Model Context Protocol (MCP) server. Connect Claude Desktop, VS Code with Claude, or any MCP client that supports Streamable HTTP with OAuth, and ask questions like \"show me all failed enrollments from the last 24 hours\" or \"why did this session fail?\". Access follows your portal role and is scoped to your tenant, with usage limits tied to the tenant's plan.",
"Yes. Autopilot Monitor exposes a Model Context Protocol (MCP) server. Connect Claude, ChatGPT, VS Code with GitHub Copilot, or a command-line client such as Claude Code, Codex or Gemini CLI, and ask questions like \"show me all failed enrollments from the last 24 hours\" or \"why did this session fail?\". An AI client your organization hosts itself is registered once by a Tenant Admin. Access follows your portal role, with usage limits tied to the tenant's plan.",
},
{
question: "Who builds and operates Autopilot Monitor?",
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
import { OPEN_GRAPH_BASE, TWITTER_BASE } from "@/lib/siteMetadata";
export const metadata: Metadata = {
title: "About – Real-Time Windows Autopilot Monitoring",
@@ -26,12 +27,14 @@ export const metadata: Metadata = {
"Autopilot Monitor overview",
],
openGraph: {
...OPEN_GRAPH_BASE,
title: "About Autopilot Monitor – Real-Time Windows Autopilot Monitoring",
description:
"Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster — built by Oliver Kieselbach.",
url: `${SITE_URL}/about`,
},
twitter: {
...TWITTER_BASE,
title: "About Autopilot Monitor – Real-Time Windows Autopilot Monitoring",
description:
"Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster.",
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
import { OPEN_GRAPH_BASE, TWITTER_BASE } from "@/lib/siteMetadata";
const SHARE_TITLE = "AI Analysis for Windows Autopilot – Bring Your Own AI";
const SHARE_DESCRIPTION =
"Ask your Autopilot enrollments anything in Claude, ChatGPT, VS Code, a terminal agent or a self-hosted client. Read-only, Microsoft sign-in, included in every plan.";
export const metadata: Metadata = {
title: "AI Analysis – Ask Your Autopilot Enrollments",
description:
"Connect Claude, ChatGPT, VS Code with GitHub Copilot, a terminal agent or a self-hosted client to the Autopilot Monitor MCP server and ask about your Windows Autopilot enrollments in plain language. Read-only, Microsoft sign-in, included in every plan.",
keywords: [
"Windows Autopilot AI analysis",
"Autopilot MCP server",
"Model Context Protocol Intune",
"Intune AI assistant",
"Claude Autopilot troubleshooting",
"ChatGPT Intune enrollment",
"GitHub Copilot MCP Intune",
"Autopilot enrollment root cause AI",
"bring your own AI",
],
openGraph: {
...OPEN_GRAPH_BASE,
title: SHARE_TITLE,
description: SHARE_DESCRIPTION,
url: `${SITE_URL}/ai`,
},
twitter: {
...TWITTER_BASE,
title: SHARE_TITLE,
description: SHARE_DESCRIPTION,
},
alternates: {
canonical: `${SITE_URL}/ai`,
},
};
export default function AiLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
@@ -0,0 +1,32 @@
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { MarketingTracker } from "../../components/MarketingTracker";
import { AiHero } from "../../components/ai/AiHero";
import { StructuredFacts } from "../../components/ai/StructuredFacts";
import { QuestionsSection } from "../../components/ai/QuestionsSection";
import { ArchitectureSection } from "../../components/ai/ArchitectureSection";
import { ConnectSection } from "../../components/ai/ConnectSection";
import { ClosingCta } from "../../components/ai/ClosingCta";
/**
* AI analysis with MCP: what you can ask, where the model runs, how to connect. Every claim on the
* page must hold against the MCP access model and the customer docs (D-305).
*/
export default function AiPage() {
return (
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
<MarketingTracker page="ai">
<LandingNavbar />
<main>
<AiHero />
<StructuredFacts />
<QuestionsSection />
<ArchitectureSection />
<ConnectSection />
<ClosingCta />
</main>
<SiteFooter />
</MarketingTracker>
</div>
);
}
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
import { OPEN_GRAPH_BASE, TWITTER_BASE } from "@/lib/siteMetadata";
export const metadata: Metadata = {
title: "Get Pro – Purchase Options",
@@ -13,12 +14,14 @@ export const metadata: Metadata = {
"Windows Autopilot monitoring Pro",
],
openGraph: {
...OPEN_GRAPH_BASE,
title: "Get Pro – Autopilot Monitor",
description:
"How to purchase Autopilot Monitor Pro: through Microsoft Marketplace or online via Cleverbridge.",
url: `${SITE_URL}/buy`,
},
twitter: {
...TWITTER_BASE,
title: "Get Pro – Autopilot Monitor",
description:
"How to purchase Autopilot Monitor Pro: through Microsoft Marketplace or online via Cleverbridge.",
@@ -67,7 +67,9 @@ describe("event follow-up pages", () => {
it("mark every call to action for the click event", async () => {
for (const event of FOLLOWUP_EVENTS) {
const html = await renderEventPage(event.slug);
const ids = [...html.matchAll(/data-track="([^"]+)"/g)].map((m) => m[1]);
// SiteFooter carries the marketing ids of the shared footer (footer_*); it sits outside the
// event tracker, so those clicks never become event_followup_clicked.
const ids = [...html.matchAll(/data-track="([^"]+)"/g)].map((m) => m[1]).filter((id) => !id.startsWith("footer_"));
expect(ids.sort()).toEqual(
[
"logo",
@@ -1,11 +1,4 @@
/** The one custom event the event follow-up pages send: properties `event` (slug) and `button`. */
export const EVENT_FOLLOWUP_CLICKED = "event_followup_clicked";
interface TrackableTarget {
closest(selector: string): { getAttribute(name: string): string | null } | null;
}
/** The data-track id of the clicked element or its nearest marked ancestor, else null. */
export function trackIdOf(target: TrackableTarget | null): string | null {
return target?.closest("[data-track]")?.getAttribute("data-track") || null;
}
export { trackIdOf } from "@/lib/clickTracking";
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SignupConsentCta } from "../../components/landing/SignupConsentCta";
import { SiteFooter } from "../../components/SiteFooter";
import { MarketingTracker } from "../../components/MarketingTracker";
import { DOCS_URL, SITE_URL } from "@/utils/config";
export const metadata: Metadata = {
@@ -49,69 +50,73 @@ const STEPS = [
export default function GetStartedPage() {
return (
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)] flex flex-col">
<LandingNavbar />
<MarketingTracker page="get_started">
<LandingNavbar />
<main className="flex-1 px-6 pt-16 sm:pt-20 pb-20">
<div className="max-w-3xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">
Get started
</p>
<h1 className="mt-4 text-4xl sm:text-5xl font-bold tracking-tight text-[var(--lp-ink)] leading-[1.08] text-balance">
From sign-in to your first live enrollment.
</h1>
<p className="mt-4 text-lg text-[var(--lp-ink-soft)] leading-relaxed max-w-xl">
Five steps, no infrastructure on your side. Most teams see their first live session
the same day.
</p>
<main className="flex-1 px-6 pt-16 sm:pt-20 pb-20">
<div className="max-w-3xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">
Get started
</p>
<h1 className="mt-4 text-4xl sm:text-5xl font-bold tracking-tight text-[var(--lp-ink)] leading-[1.08] text-balance">
From sign-in to your first live enrollment.
</h1>
<p className="mt-4 text-lg text-[var(--lp-ink-soft)] leading-relaxed max-w-xl">
Five steps, no infrastructure on your side. Most teams see their first live session
the same day.
</p>
{/* Step sequence */}
<div className="relative mt-12">
<div className="absolute left-[15px] top-3 bottom-3 w-px bg-[var(--lp-line)]" aria-hidden="true" />
<ol className="space-y-8">
{STEPS.map((step, i) => (
<li key={step.title} className="relative pl-14">
<span className="absolute left-0 top-0 w-8 h-8 rounded-full bg-[var(--lp-accent)] text-white text-sm font-bold flex items-center justify-center">
{i + 1}
</span>
<div className="flex items-baseline gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-[var(--lp-ink)]">{step.title}</h2>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-[var(--lp-accent-soft)] text-[var(--lp-accent-ink)]">
{step.note}
{/* Step sequence */}
<div data-track-section="steps" className="relative mt-12">
<div className="absolute left-[15px] top-3 bottom-3 w-px bg-[var(--lp-line)]" aria-hidden="true" />
<ol className="space-y-8">
{STEPS.map((step, i) => (
<li key={step.title} className="relative pl-14">
<span className="absolute left-0 top-0 w-8 h-8 rounded-full bg-[var(--lp-accent)] text-white text-sm font-bold flex items-center justify-center">
{i + 1}
</span>
</div>
<p className="mt-1.5 text-[15px] text-[var(--lp-ink-soft)] leading-relaxed">{step.description}</p>
</li>
))}
</ol>
<div className="flex items-baseline gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-[var(--lp-ink)]">{step.title}</h2>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-[var(--lp-accent-soft)] text-[var(--lp-accent-ink)]">
{step.note}
</span>
</div>
<p className="mt-1.5 text-[15px] text-[var(--lp-ink-soft)] leading-relaxed">{step.description}</p>
</li>
))}
</ol>
</div>
{/* CTA — sign-in waits for the Terms + DPA tick */}
<SignupConsentCta>
<a
href={DOCS_URL}
data-track="get_started_docs"
target="_blank"
rel="noopener noreferrer"
className="px-7 py-3 rounded-lg border border-[var(--lp-line)] bg-[var(--lp-surface)] text-[var(--lp-ink)] font-semibold hover:border-[var(--lp-ink-faint)] transition-colors"
>
Read the docs
</a>
</SignupConsentCta>
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
When you need more later: the{" "}
<a
href="/plans"
data-track="get_started_pro_plan"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
Pro plan
</a>{" "}
adds SLAs, support commitments, MSP delegation, higher operating limits and more —
same service, nothing to migrate.
</p>
</div>
</main>
{/* CTA — sign-in waits for the Terms + DPA tick */}
<SignupConsentCta>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="px-7 py-3 rounded-lg border border-[var(--lp-line)] bg-[var(--lp-surface)] text-[var(--lp-ink)] font-semibold hover:border-[var(--lp-ink-faint)] transition-colors"
>
Read the docs
</a>
</SignupConsentCta>
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
When you need more later: the{" "}
<a
href="/plans"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
Pro plan
</a>{" "}
adds SLAs, support commitments, MSP delegation, higher operating limits and more —
same service, nothing to migrate.
</p>
</div>
</main>
<SiteFooter />
<SiteFooter />
</MarketingTracker>
</div>
);
}
@@ -48,14 +48,20 @@ html {
--lp-accent-line: rgba(51, 177, 97, 0.35);
--lp-warn: #d97706;
--lp-warn-soft: rgba(217, 119, 6, 0.1);
/* Warning text on light surfaces: --lp-warn is too light for body-size text (contrast) */
--lp-warn-ink: #b45309;
--lp-danger: #dc2626;
/* GitHub star glyph only (GitHubStarLink) — never text or a fill */
--lp-star: #eab308;
/* Terminal is dark in both themes */
--lp-term-bg: #0d1424;
--lp-term-deep: #0a101d;
--lp-term-line: #22304a;
--lp-term-ink: #d6e1f0;
--lp-term-faint: #7b8ba3;
--lp-term-ok: #55c57f;
--lp-term-warn: #f5a623;
--lp-term-high: #ff9d66;
}
.dark {
@@ -74,6 +80,7 @@ html {
--lp-accent-line: rgba(59, 192, 108, 0.4);
--lp-warn: #f5a623;
--lp-warn-soft: rgba(245, 166, 35, 0.12);
--lp-warn-ink: #f5a623;
--lp-danger: #f87171;
--lp-star: #facc15;
}
@@ -764,6 +771,7 @@ html.auth-pending .landing-v2::after {
--lp-accent-line: rgba(51, 177, 97, 0.35);
--lp-warn: #d97706;
--lp-warn-soft: rgba(217, 119, 6, 0.1);
--lp-warn-ink: #b45309;
--lp-danger: #dc2626;
--lp-star: #eab308;
}
@@ -892,6 +900,72 @@ html.auth-pending .landing-v2::after {
}
}
/* ===== AI page data-path diagram (components/ai/DataPath.tsx) =====
The nodes stack on phones and sit in one row from lg, so every link turns with them. The
conversation link draws its dashes with a gradient: a thin dashed border with a radius renders as
a solid line, and the link must keep the data links' box or the dots have no track to run on. */
.ai-flow-line {
position: relative;
flex-shrink: 0;
width: 2px;
height: 56px;
border-radius: 2px;
background: var(--lp-accent);
}
.ai-flow-line--talk {
border-radius: 0;
background: repeating-linear-gradient(to bottom, var(--lp-ink-faint) 0 4px, transparent 4px 8px);
}
.ai-flow-dot {
position: absolute;
left: -3px;
top: 0;
width: 8px;
height: 8px;
border-radius: 9999px;
background: var(--lp-accent);
box-shadow: 0 0 0 3px rgba(51, 177, 97, 0.18);
animation: aiFlowDown 2.4s linear infinite;
}
.ai-flow-dot--back {
animation-name: aiFlowUp;
animation-delay: -1.2s;
background: var(--lp-accent-ink);
}
.ai-flow-line--talk .ai-flow-dot {
background: var(--lp-ink-faint);
box-shadow: 0 0 0 3px rgba(156, 163, 175, 0.2);
}
@keyframes aiFlowDown { from { top: -4px; } to { top: calc(100% - 4px); } }
@keyframes aiFlowUp { from { top: calc(100% - 4px); } to { top: -4px; } }
@keyframes aiFlowRight { from { left: -4px; } to { left: calc(100% - 4px); } }
@keyframes aiFlowLeft { from { left: calc(100% - 4px); } to { left: -4px; } }
@media (min-width: 1024px) {
.ai-flow-line {
width: 100%;
height: 2px;
}
.ai-flow-line--talk {
background: repeating-linear-gradient(to right, var(--lp-ink-faint) 0 6px, transparent 6px 12px);
}
.ai-flow-dot {
top: -3px;
left: 0;
animation-name: aiFlowRight;
}
.ai-flow-dot--back {
animation-name: aiFlowLeft;
}
}
@media (prefers-reduced-motion: reduce) {
.ai-flow-dot {
animation: none;
}
.ai-flow-dot--back {
display: none;
}
}
/* ===== FEEDBACK BUBBLE ANIMATION ===== */
@keyframes fadeInUp {
from {
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
import { OPEN_GRAPH_BASE, TWITTER_BASE } from "@/lib/siteMetadata";
export const metadata: Metadata = {
title: "Help & Support – How to Get Help",
@@ -14,12 +15,14 @@ export const metadata: Metadata = {
"Oliver Kieselbach",
],
openGraph: {
...OPEN_GRAPH_BASE,
title: "Help & Support – Autopilot Monitor",
description:
"How to get help with Autopilot Monitor: open a GitHub issue or reach out directly on LinkedIn.",
url: `${SITE_URL}/help`,
},
twitter: {
...TWITTER_BASE,
title: "Help & Support – Autopilot Monitor",
description:
"How to get help with Autopilot Monitor: open a GitHub issue or reach out directly on LinkedIn.",
+14 -11
View File
@@ -10,6 +10,7 @@ import { HowItWorks } from "../components/landing/HowItWorks";
import { Testimonials } from "../components/landing/Testimonials";
import { FinalCta } from "../components/landing/FinalCta";
import { SiteFooter } from "../components/SiteFooter";
import { MarketingTracker } from "../components/MarketingTracker";
/**
* Landing page v2 — a scroll story of one enrollment.
@@ -22,17 +23,19 @@ export default function LandingPage() {
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
{/* Client component: handles auth redirect + loading overlay */}
<AuthGate />
<LandingNavbar />
<Hero />
<StatsBand />
<JustAsk />
<Story />
<CapabilitiesStrip />
<Comparison />
<HowItWorks />
<Testimonials />
<FinalCta />
<SiteFooter />
<MarketingTracker page="landing">
<LandingNavbar />
<Hero />
<StatsBand />
<JustAsk />
<Story />
<CapabilitiesStrip />
<Comparison />
<HowItWorks />
<Testimonials />
<FinalCta />
<SiteFooter />
</MarketingTracker>
</div>
);
}
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
import { OPEN_GRAPH_BASE, TWITTER_BASE } from "@/lib/siteMetadata";
export const metadata: Metadata = {
title: "Plans – Community & Pro",
@@ -14,12 +15,14 @@ export const metadata: Metadata = {
"Autopilot monitoring MSP",
],
openGraph: {
...OPEN_GRAPH_BASE,
title: "Plans – Autopilot Monitor",
description:
"Community is the full product and free — and stays free. Pro adds extended retention, higher limits, MSP delegation, and priority support.",
url: `${SITE_URL}/plans`,
},
twitter: {
...TWITTER_BASE,
title: "Plans – Autopilot Monitor",
description:
"Community is the full product and free — and stays free. Pro adds extended retention, higher limits, MSP delegation, and priority support.",
@@ -1,11 +1,13 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
import { OPEN_GRAPH_BASE } from "@/lib/siteMetadata";
export const metadata: Metadata = {
title: "Privacy Policy",
description:
"Privacy Policy for Autopilot Monitor. Learn how we collect, store, and protect your data when using the Windows Autopilot monitoring platform.",
openGraph: {
...OPEN_GRAPH_BASE,
title: "Privacy Policy | Autopilot Monitor",
description:
"Privacy Policy for Autopilot Monitor. Learn how we collect, store, and protect your data.",
+1 -1
View File
@@ -5,7 +5,7 @@ import type { MetadataRoute } from "next";
import { SITE_URL } from "@/utils/config";
// Public marketing / legal pages.
const PUBLIC_PATHS = ["/", "/about", "/buy", "/get-started", "/help", "/plans", "/privacy", "/terms"];
const PUBLIC_PATHS = ["/", "/about", "/ai", "/buy", "/get-started", "/help", "/plans", "/privacy", "/terms"];
// Authenticated portal routes: nothing to index, and the static shell would
// only ever render a sign-in redirect.
@@ -8,6 +8,7 @@ import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { api } from "@/lib/api";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { useCopy } from "@/hooks/useCopy";
import { MCP_SERVER_URL } from "@/utils/config";
import type {
CreateMcpClientRegistrationRequest,
@@ -21,20 +22,6 @@ function formatDay(iso: string | undefined | null): string {
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
}
function useCopy() {
const [copied, setCopied] = useState<string | null>(null);
const copy = useCallback(async (value: string, key: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
} catch {
setCopied(null);
}
}, []);
return { copied, copy };
}
/**
* Settings → Tenant → AI Integration. Part 1 explains the normal connection (Claude, ChatGPT, VS Code and
* other supported AI clients: add the server URL, sign in — nothing to register here). Part 2, for Tenant
@@ -33,6 +33,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "monthly",
priority: 0.7,
},
{
url: sitePageUrl("/ai"),
lastModified: lastmod("/ai"),
changeFrequency: "monthly",
priority: 0.7,
},
{
url: sitePageUrl("/get-started"),
lastModified: lastmod("/get-started"),
@@ -1,11 +1,13 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
import { OPEN_GRAPH_BASE } from "@/lib/siteMetadata";
export const metadata: Metadata = {
title: "Terms of Use",
description:
"Terms of Use for Autopilot Monitor. Read the usage terms, conditions, and acceptable use policies for the Windows Autopilot monitoring and troubleshooting platform.",
openGraph: {
...OPEN_GRAPH_BASE,
title: "Terms of Use | Autopilot Monitor",
description: "Terms of Use for Autopilot Monitor. Read the usage terms, conditions, and acceptable use policies for the monitoring platform.",
url: `${SITE_URL}/terms`,
@@ -6,10 +6,21 @@ import { GitHubIcon } from "./GitHubIcon";
* The counter segment only renders when the deploy baked a star count in (utils/githubStars.ts).
* No hooks, so server components (Testimonials) and client components (LandingNavbar) share it.
*/
export function GitHubStarLink({ label }: { label: string }) {
export function GitHubStarLink({
label,
track,
labelClassName,
}: {
label: string;
/** data-track id for the anonymous marketing click count (components/MarketingTracker.tsx). */
track?: string;
/** Lets a tight bar hide the word at some widths; the aria-label still names the link. */
labelClassName?: string;
}) {
return (
<a
href={GITHUB_REPO_URL}
data-track={track}
target="_blank"
rel="noopener noreferrer"
aria-label={
@@ -21,7 +32,7 @@ export function GitHubStarLink({ label }: { label: string }) {
>
<span className="inline-flex items-center gap-1.5 px-2.5">
<GitHubIcon className="w-[15px] h-[15px]" />
{label}
<span className={labelClassName}>{label}</span>
</span>
{GITHUB_STARS !== null && (
<span className="inline-flex items-center gap-1 px-2.5 border-l border-[var(--lp-line)] bg-[var(--lp-surface-2)] font-semibold text-[var(--lp-ink)]">
@@ -0,0 +1,54 @@
"use client";
import { useEffect, useRef, type MouseEvent, type ReactNode } from "react";
import { trackEvent } from "@/lib/appInsights";
import {
MARKETING_CTA_CLICKED,
MARKETING_SECTION_VIEWED,
isSectionSeen,
trackIdOf,
} from "@/lib/clickTracking";
const THRESHOLDS = [0, 0.1, 0.2, 1 / 3];
/**
* Anonymous usage counts for one public marketing page: one event per click on an element marked
* data-track="<id>", and one per section marked data-track-section="<id>" the first time it is
* seen in this page load. `display: contents` keeps the page layout as it is, and the sections
* stay server components.
*/
export function MarketingTracker({ page, children }: { page: string; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = ref.current;
if (!root || typeof IntersectionObserver === "undefined") return;
const seen = new Set<string>();
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
const section = (entry.target as HTMLElement).dataset.trackSection;
if (!section || seen.has(section) || !entry.isIntersecting) continue;
if (!isSectionSeen(entry.intersectionRatio, entry.intersectionRect.height, window.innerHeight)) continue;
seen.add(section);
observer.unobserve(entry.target);
trackEvent(MARKETING_SECTION_VIEWED, { page, section });
}
},
{ threshold: THRESHOLDS }
);
root.querySelectorAll<HTMLElement>("[data-track-section]").forEach(el => observer.observe(el));
return () => observer.disconnect();
}, [page]);
const onClick = (e: MouseEvent<HTMLDivElement>) => {
const button = trackIdOf(e.target as Element);
if (button) trackEvent(MARKETING_CTA_CLICKED, { page, button });
};
return (
<div ref={ref} className="contents" onClick={onClick}>
{children}
</div>
);
}
@@ -7,34 +7,35 @@ const LINK_COLUMNS = [
{
title: "Product",
links: [
{ label: "The Story", href: "/#story" },
{ label: "Capabilities", href: "/#features" },
{ label: "Compare", href: "/#comparison" },
{ label: "Plans", href: "/plans" },
{ label: "The Story", href: "/#story", track: "story" },
{ label: "Capabilities", href: "/#features", track: "capabilities" },
{ label: "Compare", href: "/#comparison", track: "compare" },
{ label: "AI analysis", href: "/ai/", track: "ai" },
{ label: "Plans", href: "/plans", track: "plans" },
],
},
{
title: "Resources",
links: [
{ label: "Documentation", href: DOCS_URL, external: true },
{ label: "Help & Support", href: "/help" },
{ label: "Feedback", href: "https://github.com/okieselbach/AutopilotMonitor/issues", external: true },
{ label: "GitHub", href: GITHUB_REPO_URL, external: true },
{ label: "Documentation", href: DOCS_URL, external: true, track: "docs" },
{ label: "Help & Support", href: "/help", track: "help" },
{ label: "Feedback", href: "https://github.com/okieselbach/AutopilotMonitor/issues", external: true, track: "feedback" },
{ label: "GitHub", href: GITHUB_REPO_URL, external: true, track: "github" },
],
},
{
title: "Company",
links: [
{ label: "About", href: "/about" },
{ label: "glueckkanja AG", href: "https://www.glueckkanja.com", external: true },
{ label: "About", href: "/about", track: "about" },
{ label: "glueckkanja AG", href: "https://www.glueckkanja.com", external: true, track: "glueckkanja" },
],
},
{
title: "Legal",
links: [
{ label: "Privacy Policy", href: "/privacy" },
{ label: "Terms of Use", href: "/terms" },
{ label: "Imprint", href: "https://www.glueckkanja.com/en/imprint", external: true },
{ label: "Privacy Policy", href: "/privacy", track: "privacy" },
{ label: "Terms of Use", href: "/terms", track: "terms" },
{ label: "Imprint", href: "https://www.glueckkanja.com/en/imprint", external: true, track: "imprint" },
],
},
];
@@ -63,6 +64,7 @@ export function SiteFooter() {
<div className="flex items-center gap-3">
<a
href="https://www.linkedin.com/in/oliver-kieselbach/"
data-track="footer_linkedin"
target="_blank"
rel="noopener noreferrer"
className="text-[var(--lp-ink-faint)] hover:text-[var(--lp-accent-ink)] transition-colors"
@@ -74,6 +76,7 @@ export function SiteFooter() {
</a>
<a
href={GITHUB_REPO_URL}
data-track="footer_github_icon"
target="_blank"
rel="noopener noreferrer"
className="text-[var(--lp-ink-faint)] hover:text-[var(--lp-ink)] transition-colors"
@@ -97,6 +100,7 @@ export function SiteFooter() {
<a
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
data-track={`footer_${link.track}`}
className="text-xs text-[var(--lp-ink-faint)] hover:text-[var(--lp-accent-ink)] transition-colors"
>
{link.label}
@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createElement, type ComponentType } from "react";
import { renderToStaticMarkup } from "react-dom/server";
// LandingNavbar and LoginButton read the auth context; the public pages render signed out.
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ login: async () => undefined, isAuthenticated: false, user: null, getAccessToken: async () => null }),
}));
// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links.
vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null }));
import LandingPage from "@/app/page";
import GetStartedPage from "@/app/get-started/page";
import AiPage from "@/app/ai/page";
import robots from "@/app/robots";
import sitemap from "@/app/sitemap";
import { QUESTIONS } from "@/components/ai/questions";
import { TRACK_ID_PATTERN, isSectionSeen, trackIdOf } from "@/lib/clickTracking";
import { sitePageUrl } from "@/utils/config";
/**
* The UX report counts clicks and section views on the marketing pages (D-305). A link or button
* without a data-track id is invisible there, so every one carries an id; the What's new bell is
* the exception, it sends whats_new_opened itself.
*/
const render = (Page: ComponentType) => renderToStaticMarkup(createElement(Page));
const trackIds = (html: string) => [...html.matchAll(/data-track="([^"]+)"/g)].map(m => m[1]);
const sectionIds = (html: string) => [...html.matchAll(/data-track-section="([^"]+)"/g)].map(m => m[1]);
const untracked = (html: string) =>
[...html.matchAll(/<(?:a|button)\b[^>]*>/g)]
.map(m => m[0])
.filter(tag => !/\sdata-track="/.test(tag) && !/aria-label="What&#x27;s new"/.test(tag));
const PAGES: { name: string; Page: ComponentType; sections: string[] }[] = [
{
name: "landing",
Page: LandingPage,
sections: ["hero", "stats", "just_ask", "story", "capabilities", "comparison", "how_it_works", "testimonials", "final_cta"],
},
{ name: "get-started", Page: GetStartedPage, sections: ["steps", "signup"] },
{ name: "ai", Page: AiPage, sections: ["hero", "structured", "questions", "architecture", "connect", "closing"] },
];
describe("marketing page tracking", () => {
for (const { name, Page, sections } of PAGES) {
it(`${name}: every link and button carries a valid, unique data-track id`, () => {
const html = render(Page);
expect(untracked(html)).toEqual([]);
const ids = trackIds(html);
for (const id of ids) expect(id).toMatch(TRACK_ID_PATTERN);
expect(ids.filter((id, i) => ids.indexOf(id) !== i)).toEqual([]);
});
it(`${name}: marks its sections`, () => {
expect(sectionIds(render(Page))).toEqual(sections);
});
}
it("the landing links to the AI page from the navbar, Then just ask and the footer", () => {
const ids = trackIds(render(LandingPage));
expect(ids).toEqual(expect.arrayContaining(["nav_ai", "just_ask_ai", "footer_ai"]));
});
it("the AI page carries every question text in its static HTML", () => {
const html = render(AiPage);
const escape = (text: string) => text.replace(/&/g, "&amp;").replace(/'/g, "&#x27;");
for (const q of QUESTIONS) expect(html, q.id).toContain(escape(q.text));
});
it("the AI page is in the sitemap, robots.txt and llms.txt", () => {
expect(sitemap().map(e => e.url)).toContain(sitePageUrl("/ai"));
const allow = robots().rules;
expect(Array.isArray(allow) ? allow[0].allow : allow.allow).toContain("/ai");
const llms = readFileSync(join(__dirname, "..", "..", "public", "llms.txt"), "utf-8");
expect(llms).toContain(sitePageUrl("/ai"));
});
});
describe("clickTracking helpers", () => {
it("reads the id of the nearest marked element", () => {
const target = { closest: (selector: string) => (selector === "[data-track]" ? { getAttribute: () => "nav_ai" } : null) };
expect(trackIdOf(target)).toBe("nav_ai");
expect(trackIdOf({ closest: () => null })).toBeNull();
expect(trackIdOf(null)).toBeNull();
});
it("counts a section once a third is visible, or half the viewport for tall sections", () => {
expect(isSectionSeen(0.34, 100, 800)).toBe(true);
expect(isSectionSeen(0.2, 420, 800)).toBe(true);
expect(isSectionSeen(0.2, 200, 800)).toBe(false);
expect(isSectionSeen(0, 0, 0)).toBe(false);
});
});
@@ -0,0 +1,52 @@
import { AskBar } from "./AskBar";
/** Clients documented in the customer docs (integrations/ai-integration-mcp.md, "Supported AI clients"). */
const CLIENTS = ["Claude", "ChatGPT", "VS Code & GitHub Copilot", "Claude Code", "Codex", "Gemini CLI", "Self-hosted clients"];
export function AiHero() {
return (
<section data-track-section="hero" className="px-6 pb-16 pt-16 sm:pb-20 sm:pt-24">
<div className="mx-auto max-w-4xl text-center">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">AI analysis with MCP</p>
<h1 className="mt-5 text-balance text-[2.6rem] font-bold leading-[1.06] tracking-tight text-[var(--lp-ink)] sm:text-6xl sm:leading-[1.04] lg:text-7xl">
Ask your enrollments anything.
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-[var(--lp-ink-soft)] sm:text-xl">
Autopilot Monitor records every enrollment as structured data. Connect the AI assistant you already use and ask
in plain language, about one device or the whole fleet.
</p>
</div>
<div className="mx-auto mt-10 max-w-3xl">
<AskBar />
</div>
<div className="mx-auto mt-5 flex max-w-4xl flex-wrap items-center justify-center gap-2">
<span className="mr-1 text-[13px] text-[var(--lp-ink-faint)]">Works in</span>
{CLIENTS.map(client => (
<span
key={client}
className="inline-flex items-center rounded-full border border-[var(--lp-line)] bg-[var(--lp-surface)] px-2.5 py-1 text-[13px] font-medium text-[var(--lp-ink-soft)]"
>
{client}
</span>
))}
</div>
<div className="mt-10 flex flex-wrap justify-center gap-3">
<a
href="#connect"
data-track="hero_connect"
className="rounded-lg bg-[var(--lp-accent-ink)] px-6 py-3 font-semibold text-white shadow-md transition-all hover:shadow-lg hover:brightness-110"
>
Connect your assistant
</a>
<a
href="#questions"
data-track="hero_examples"
className="rounded-lg border border-[var(--lp-line)] bg-[var(--lp-surface)] px-6 py-3 font-semibold text-[var(--lp-ink)] transition-colors hover:border-[var(--lp-ink-faint)]"
>
See example questions
</a>
</div>
<p className="mt-3 text-center text-sm text-[var(--lp-ink-faint)]">Included in every plan · read-only · Microsoft sign-in</p>
</section>
);
}
@@ -0,0 +1,47 @@
import { DataPath } from "./DataPath";
import { CheckChip } from "./icons";
/** Each line is backed by the MCP access model: READ_ONLY tool annotations, Entra OAuth, portal-role authorization per request (D-239, D-283). */
const GUARDRAILS = [
{ title: "Read-only tools", text: "Your assistant reads. Sessions, rules and settings stay untouched." },
{ title: "Microsoft sign-in", text: "Work accounts through Microsoft Entra ID. There are no API keys to hand out." },
{ title: "Your portal role", text: "The assistant sees exactly what the person's role allows in the portal." },
{ title: "Access follows the role", text: "Remove a role or block an account, and access ends within minutes." },
];
export function ArchitectureSection() {
return (
<section
id="architecture"
data-track-section="architecture"
className="scroll-mt-20 border-t border-[var(--lp-line-soft)] px-6 py-20 sm:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">Bring your own AI</p>
<h2 className="mt-3 text-balance text-3xl font-bold tracking-tight text-[var(--lp-ink)] sm:text-4xl">
We provide the data. You pick the AI.
</h2>
<p className="mt-4 text-lg leading-relaxed text-[var(--lp-ink-soft)]">
MCP is an open standard for connecting AI assistants to data. Use the assistant your organization already
approved, wherever its model runs.
</p>
</div>
<DataPath />
<ul className="mt-10 grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2 lg:grid-cols-4">
{GUARDRAILS.map(item => (
<li key={item.title} className="flex items-start gap-3">
<CheckChip />
<div>
<h3 className="text-[15px] font-semibold text-[var(--lp-ink)]">{item.title}</h3>
<p className="mt-1 text-sm leading-relaxed text-[var(--lp-ink-soft)]">{item.text}</p>
</div>
</li>
))}
</ul>
</div>
</section>
);
}
@@ -0,0 +1,82 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useInViewActive } from "@/hooks/useInViewActive";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { ArrowRightIcon } from "./icons";
const QUESTIONS = [
"Why did CONTOSO-3812 fail this morning?",
"Which app keeps device setup waiting the longest?",
"How many devices enrolled right the first time last month?",
"Which CVEs on new devices should we fix first?",
"Write this week's enrollment report for the change board.",
];
const HOLD_MS = 2800;
const ERASE_MS = 16;
const TYPE_MS = 34;
const NEXT_MS = 260;
type Phase = "hold" | "erase" | "type";
/**
* The hero's "ask" bar: cycles through example questions with a typing effect and links to the
* gallery. The first question is prerendered and stays put under reduced motion; timers run only
* while the bar is on screen in a visible tab.
*/
export function AskBar() {
const ref = useRef<HTMLAnchorElement>(null);
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
const active = useInViewActive(ref, { startThreshold: 0.5, disabled: reduced });
const [index, setIndex] = useState(0);
const [chars, setChars] = useState(QUESTIONS[0].length);
const [phase, setPhase] = useState<Phase>("hold");
useEffect(() => {
if (!active) return;
let timer: number;
if (phase === "hold") {
timer = window.setTimeout(() => setPhase("erase"), HOLD_MS);
} else if (phase === "erase") {
timer =
chars > 0
? window.setTimeout(() => setChars(c => Math.max(0, c - 3)), ERASE_MS)
: window.setTimeout(() => {
setIndex(i => (i + 1) % QUESTIONS.length);
setPhase("type");
}, NEXT_MS);
} else {
timer =
chars < QUESTIONS[index].length
? window.setTimeout(() => setChars(c => c + 1), TYPE_MS)
: window.setTimeout(() => setPhase("hold"), 0);
}
return () => window.clearTimeout(timer);
}, [active, phase, chars, index]);
return (
<a
ref={ref}
href="#questions"
data-track="hero_ask_bar"
aria-label="See example questions and answers"
className="flex items-center gap-3.5 rounded-[18px] border border-[var(--lp-line)] bg-[var(--lp-surface)] py-3.5 pl-5 pr-3.5 text-[var(--lp-ink)] shadow-[0_22px_44px_-22px_rgba(24,24,27,0.22),0_1px_2px_rgba(24,24,27,0.04)] transition-[border-color,box-shadow] hover:border-[var(--lp-accent-line)] hover:shadow-[0_24px_48px_-22px_rgba(30,138,76,0.32)]"
>
<span aria-hidden="true" className="shrink-0 font-mono text-lg text-[var(--lp-accent)]">
❯
</span>
<span aria-hidden="true" className="min-w-0 flex-1 truncate text-left text-[15px] font-medium sm:text-[17px]">
{QUESTIONS[index].slice(0, chars)}
<span className="lp-cursor" />
</span>
<span
aria-hidden="true"
className="inline-flex shrink-0 items-center gap-1.5 rounded-[10px] bg-[var(--lp-accent-soft)] px-3 py-2 text-[13px] font-semibold text-[var(--lp-accent-ink)]"
>
<span className="hidden sm:inline">See answers</span>
<ArrowRightIcon />
</span>
</a>
);
}
@@ -0,0 +1,402 @@
import {
FILE_ACCESS_SKINS,
type Block,
type Question,
type Skin,
type Span,
type Tone,
type ToolCall,
} from "./questions";
/**
* One answer from the /ai gallery, drawn as a chat app, VS Code, a terminal agent or a self-hosted
* front end. Same content in every skin; only chrome and type change. Colors are lp-* tokens or
* arbitrary values: the app-wide `.dark` overrides would otherwise flip palette classes.
*/
const TONE_LIGHT: Record<Tone, string> = {
ok: "text-[var(--lp-accent-ink)]",
warn: "text-[var(--lp-warn-ink)]",
high: "text-[var(--lp-danger)]",
dim: "text-[var(--lp-ink-soft)]",
};
const TONE_DARK: Record<Tone, string> = {
ok: "text-[var(--lp-term-ok)]",
warn: "text-[var(--lp-term-warn)]",
high: "text-[var(--lp-term-high)]",
dim: "text-[var(--lp-term-faint)]",
};
interface SkinStyle {
dark: boolean;
frame: string;
head: string;
body: string;
user: string;
tools: string;
tool: string;
who: string | null;
input: string;
code: string;
list: string;
th: string;
td: string;
ticket: string;
ticketText: string;
note: string;
}
const STYLES: Record<Skin, SkinStyle> = {
chat: {
dark: false,
frame: "grid-cols-1 border-[var(--lp-line)] bg-[var(--lp-surface)]",
head: "border-b border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)]",
body: "gap-3.5 text-[14.5px] leading-[1.6] text-[var(--lp-ink)]",
user: "self-end max-w-[82%] rounded-[18px] rounded-br-md bg-[var(--lp-surface-2)] px-3.5 py-2.5",
tools: "flex flex-wrap gap-1.5",
tool: "inline-flex items-center gap-1.5 rounded-full bg-[var(--lp-accent-soft)] px-2.5 py-1 font-mono text-[11.5px] text-[var(--lp-accent-ink)]",
who: "Assistant",
input: "rounded-[14px] border border-[var(--lp-line)] bg-[var(--lp-surface)] px-3.5 py-2.5 text-sm text-[var(--lp-ink-faint)]",
code: "rounded-[5px] bg-[var(--lp-surface-2)] px-[5px] py-px font-mono text-[0.88em]",
list: "list-disc pl-5",
th: "border-b border-[var(--lp-line-soft)] py-1.5 pr-4 text-[11.5px] font-semibold text-[var(--lp-ink-faint)]",
td: "border-b border-[var(--lp-line-soft)] py-1.5 pr-4",
ticket: "rounded-xl border border-[var(--lp-line)] bg-[var(--lp-bg)] px-3.5 py-3 text-[13.5px]",
ticketText: "text-[var(--lp-ink-soft)]",
note: "inline-flex items-center rounded-full border border-[var(--lp-line)] px-2.5 py-1 font-sans text-xs text-[var(--lp-ink-soft)]",
},
vscode: {
dark: true,
frame: "grid-cols-[44px_minmax(0,1fr)] border-[var(--lp-term-line)] bg-[var(--lp-term-bg)]",
head: "border-b border-[var(--lp-term-line)] text-[var(--lp-term-faint)]",
body: "gap-3.5 text-[13.5px] leading-[1.6] text-[var(--lp-term-ink)]",
user: "rounded-lg border border-[var(--lp-term-line)] bg-[rgba(255,255,255,0.04)] px-3.5 py-2.5",
tools: "flex flex-col gap-[3px]",
tool: "text-xs text-[var(--lp-term-faint)]",
who: "Copilot",
input: "rounded-lg border border-[var(--lp-term-line)] bg-[rgba(255,255,255,0.04)] px-3.5 py-2.5 text-sm text-[var(--lp-term-faint)]",
code: "rounded-[5px] bg-[rgba(255,255,255,0.07)] px-[5px] py-px font-mono text-[0.88em]",
list: "list-disc pl-5",
th: "border-b border-[var(--lp-term-line)] py-1.5 pr-4 text-[11.5px] font-semibold text-[var(--lp-term-faint)]",
td: "border-b border-[var(--lp-term-line)] py-1.5 pr-4",
ticket: "rounded-xl border border-[var(--lp-term-line)] bg-[rgba(255,255,255,0.03)] px-3.5 py-3 text-[13.5px]",
ticketText: "text-[var(--lp-term-faint)]",
note: "inline-flex items-center rounded-full border border-[var(--lp-term-line)] px-2.5 py-1 font-sans text-xs text-[var(--lp-term-faint)]",
},
terminal: {
dark: true,
frame: "grid-cols-1 border-[var(--lp-term-line)] bg-[var(--lp-term-bg)]",
head: "border-b border-[var(--lp-term-line)]",
body: "gap-2.5 font-mono text-[12.5px] leading-[1.7] text-[var(--lp-term-ink)]",
user: "",
tools: "flex flex-col pl-4",
tool: "break-all text-[var(--lp-term-faint)]",
who: null,
input: "font-mono text-[12.5px] text-[var(--lp-term-faint)]",
code: "",
list: "list-none",
th: "pr-[18px] font-normal text-[var(--lp-term-faint)]",
td: "pr-[18px]",
ticket: "rounded-xl border border-[var(--lp-term-line)] bg-[rgba(255,255,255,0.03)] px-3.5 py-3",
ticketText: "text-[var(--lp-term-faint)]",
note: "inline-flex items-center rounded-full border border-[var(--lp-term-line)] px-2.5 py-1 font-sans text-xs text-[var(--lp-term-faint)]",
},
selfhosted: {
dark: false,
frame: "grid-cols-1 border-[var(--lp-line)] bg-[var(--lp-surface)]",
head: "border-b border-[var(--lp-line-soft)] bg-[var(--lp-surface)]",
body: "gap-3.5 text-[14.5px] leading-[1.6] text-[var(--lp-ink)]",
user: "self-end max-w-[82%] rounded-[18px] rounded-br-md bg-[var(--lp-accent-soft)] px-3.5 py-2.5 shadow-[inset_0_0_0_1px_var(--lp-accent-line)]",
tools: "flex flex-wrap gap-1.5",
tool: "inline-flex items-center gap-1.5 rounded-full bg-[var(--lp-surface-2)] px-2.5 py-1 font-mono text-[11.5px] text-[var(--lp-ink-soft)]",
who: "Assistant · your model",
input: "rounded-[14px] border border-[var(--lp-line)] bg-[var(--lp-surface)] px-3.5 py-2.5 text-sm text-[var(--lp-ink-faint)]",
code: "rounded-[5px] bg-[var(--lp-surface-2)] px-[5px] py-px font-mono text-[0.88em]",
list: "list-disc pl-5",
th: "border-b border-[var(--lp-line-soft)] py-1.5 pr-4 text-[11.5px] font-semibold text-[var(--lp-ink-faint)]",
td: "border-b border-[var(--lp-line-soft)] py-1.5 pr-4",
ticket: "rounded-xl border border-[var(--lp-line)] bg-[var(--lp-bg)] px-3.5 py-3 text-[13.5px]",
ticketText: "text-[var(--lp-ink-soft)]",
note: "inline-flex items-center rounded-full border border-[var(--lp-line)] px-2.5 py-1 font-sans text-xs text-[var(--lp-ink-soft)]",
},
};
const BAR_LIGHT = { base: "bg-[var(--lp-accent)]", warn: "bg-[var(--lp-warn)]", dim: "bg-[var(--lp-line)]" };
const BAR_DARK = { base: "bg-[var(--lp-accent)]", warn: "bg-[var(--lp-term-warn)]", dim: "bg-[var(--lp-term-line)]" };
function toneClass(tone: Tone, skin: Skin): string {
return (STYLES[skin].dark ? TONE_DARK : TONE_LIGHT)[tone];
}
function SpanView({ span, skin }: { span: Span; skin: Skin }) {
if (typeof span === "string") return <>{span}</>;
const content = span.code ? <code className={STYLES[skin].code}>{span.text}</code> : span.text;
const classes = [span.tone ? toneClass(span.tone, skin) : "", span.bold ? "font-semibold" : ""].filter(Boolean).join(" ");
return classes ? <span className={classes}>{content}</span> : <>{content}</>;
}
function Spans({ spans, skin }: { spans: Span[]; skin: Skin }) {
return (
<>
{spans.map((span, i) => (
<SpanView key={i} span={span} skin={skin} />
))}
</>
);
}
function TableView({ block, skin }: { block: Extract<Block, { kind: "table" }>; skin: Skin }) {
const style = STYLES[skin];
const numeric = new Set(block.numeric ?? []);
const hasBar = block.rows.some(row => row.bar);
const columns = (block.head?.length ?? Math.max(...block.rows.map(row => row.cells.length))) + (hasBar ? 1 : 0);
const bars = style.dark ? BAR_DARK : BAR_LIGHT;
const align = (i: number) => (numeric.has(i) ? "text-right tabular-nums" : "text-left");
return (
<div className="overflow-x-auto">
<table className="border-collapse [&_tbody_tr:last-child>td]:border-b-0">
{block.head && (
<thead>
<tr>
{block.head.map((heading, i) => (
<th key={heading} className={`${style.th} ${align(i)}`}>
{heading}
</th>
))}
{hasBar && <th className={style.th} />}
</tr>
</thead>
)}
<tbody>
{block.rows.map((row, ri) => (
<tr key={ri}>
{row.fullWidth ? (
<td colSpan={columns} className={style.td}>
<Spans spans={row.cells} skin={skin} />
</td>
) : (
<>
{row.cells.map((cell, ci) => (
<td key={ci} className={`${style.td} whitespace-nowrap ${align(ci)}`}>
<SpanView span={cell} skin={skin} />
</td>
))}
{hasBar && (
<td className={style.td}>
{row.bar && (
<span
className={`inline-block h-2 rounded align-middle ${bars[row.bar.tone ?? "base"]}`}
style={{ width: row.bar.width }}
/>
)}
</td>
)}
</>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
function BlockView({ block, skin }: { block: Block; skin: Skin }) {
const style = STYLES[skin];
switch (block.kind) {
case "p":
return (
<p className={block.tone ? toneClass(block.tone, skin) : undefined}>
<Spans spans={block.spans} skin={skin} />
</p>
);
case "list":
return (
<ul className={style.list}>
{block.items.map((item, i) => (
<li key={i}>
{skin === "terminal" ? "- " : null}
<Spans spans={item} skin={skin} />
</li>
))}
</ul>
);
case "table":
return <TableView block={block} skin={skin} />;
case "ticket":
return (
<div className={style.ticket}>
<p className="font-bold">{block.title}</p>
{block.fields.map(field => (
<p key={field.label} className={`mt-1.5 ${style.ticketText}`}>
<b className="font-semibold">{`${field.label}:`}</b>
{` ${field.text}`}
</p>
))}
</div>
);
case "note":
return (
<p>
<span className={style.note}>{block.text}</span>
</p>
);
case "byFileAccess":
return <BlockList blocks={FILE_ACCESS_SKINS.has(skin) ? block.withFiles : block.withoutFiles} skin={skin} />;
}
}
function BlockList({ blocks, skin }: { blocks: Block[]; skin: Skin }) {
return (
<div className="flex flex-col gap-2">
{blocks.map((block, i) => (
<BlockView key={i} block={block} skin={skin} />
))}
</div>
);
}
function ToolLine({ call, skin }: { call: ToolCall; skin: Skin }) {
const style = STYLES[skin];
if (skin === "terminal") {
return (
<span className={style.tool}>
<span className="text-[var(--lp-accent)]">{"✓ "}</span>
{`${call.name}(${call.args ?? ""})`}
</span>
);
}
if (skin === "vscode") {
return (
<span className={style.tool}>
<span className="text-[var(--lp-term-ok)]">{"✓ "}</span>
{call.local ? "Ran terminal command: " : "Ran "}
<code className="font-mono">{call.local ? call.args : call.name}</code>
{call.local ? null : " · Autopilot Monitor (MCP Server)"}
</span>
);
}
return (
<span className={style.tool}>
<span aria-hidden="true">✓</span>
{call.name}
</span>
);
}
function PanelHead({ skin }: { skin: Skin }) {
const row = "flex min-h-[46px] flex-wrap items-center gap-x-2.5 gap-y-1.5 px-3.5 py-2.5 text-[13px]";
switch (skin) {
case "chat":
return (
<div className={row}>
<span className="font-semibold text-[var(--lp-ink)]">Enrollment questions</span>
<span className="ml-auto inline-flex items-center gap-1.5 rounded-full bg-[var(--lp-accent-soft)] px-2 py-0.5 text-xs font-semibold text-[var(--lp-accent-ink)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--lp-accent)]" />
Autopilot Monitor
</span>
</div>
);
case "vscode":
return (
<div className={row}>
<span className="text-[11px] font-semibold uppercase tracking-[0.14em]">Chat</span>
<span className="ml-auto text-xs">Agent · Autopilot Monitor (MCP)</span>
</div>
);
case "terminal":
return (
<div className={row}>
<span className="inline-flex gap-1.5" aria-hidden="true">
<span className="h-2.5 w-2.5 rounded-full bg-[#f16057]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#f5bd4f]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#57c454]" />
</span>
<span className="ml-1 min-w-0 truncate font-mono text-[11px] text-[var(--lp-term-faint)]">
terminal — AI agent · autopilot-monitor MCP connected
</span>
</div>
);
case "selfhosted":
return (
<div className={row}>
<span className="min-w-0 truncate rounded-md bg-[var(--lp-surface-2)] px-2.5 py-1 font-mono text-xs text-[var(--lp-ink-soft)]">
chat.contoso.internal
</span>
<span className="rounded-full bg-[var(--lp-surface-2)] px-2 py-0.5 text-xs font-semibold text-[var(--lp-ink-soft)]">
Model: your choice
</span>
<span className="ml-auto rounded-full bg-[var(--lp-accent-soft)] px-2 py-0.5 text-xs font-semibold text-[var(--lp-accent-ink)]">
Runs on your server
</span>
</div>
);
}
}
function InputLine({ skin }: { skin: Skin }) {
const style = STYLES[skin];
if (skin === "terminal") {
return (
<div className={style.input}>
<span className="text-[var(--lp-accent)]">{"❯ "}</span>
<span className="lp-cursor" />
</div>
);
}
return <div className={style.input}>{skin === "selfhosted" ? "Message your assistant…" : "Ask a follow-up…"}</div>;
}
export function AssistantPanel({ skin, question }: { skin: Skin; question: Question }) {
const style = STYLES[skin];
const calls = question.tools.filter(call => !call.local || FILE_ACCESS_SKINS.has(skin));
return (
<div
className={`grid overflow-hidden rounded-[18px] border shadow-[0_30px_60px_-30px_rgba(24,24,27,0.28)] transition-colors ${style.frame}`}
>
{skin === "vscode" && (
<div aria-hidden="true" className="border-r border-[var(--lp-term-line)] bg-[var(--lp-term-deep)] py-3">
{[0, 1, 2, 3].map(i => (
<span
key={i}
className={`mx-auto mb-4 block h-5 w-5 rounded-[5px] border-2 ${
i === 2 ? "border-[var(--lp-term-ink)]" : "border-[var(--lp-term-faint)] opacity-55"
}`}
/>
))}
</div>
)}
<div className="flex min-w-0 flex-col">
<div className={style.head}>
<PanelHead skin={skin} />
</div>
<div key={question.id} className={`lp-event-in flex flex-1 flex-col px-[18px] pb-1.5 pt-[18px] lg:min-h-[440px] ${style.body}`}>
{skin === "terminal" ? (
<div>
<span className="text-[var(--lp-accent)]">{"❯ "}</span>
{question.text}
</div>
) : (
<div className={style.user}>{question.text}</div>
)}
<div className={style.tools}>
{calls.map(call => (
<ToolLine key={`${call.name}-${call.args ?? ""}`} call={call} skin={skin} />
))}
</div>
<div className={skin === "chat" || skin === "selfhosted" ? "max-w-[94%]" : undefined}>
{style.who && (
<div className={`mb-1 text-xs font-semibold ${style.dark ? "text-[var(--lp-term-faint)]" : "text-[var(--lp-ink-faint)]"}`}>
{style.who}
</div>
)}
<BlockList blocks={question.answer} skin={skin} />
</div>
</div>
<div className="px-3.5 pb-3.5 pt-2.5">
<InputLine skin={skin} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import Link from "next/link";
export function ClosingCta() {
return (
<section data-track-section="closing" className="px-6 py-20 sm:py-24">
<div className="mx-auto flex max-w-7xl flex-col justify-between gap-8 lg:flex-row lg:items-center">
<h2 className="max-w-2xl text-balance text-3xl font-bold tracking-tight text-[var(--lp-ink)] sm:text-4xl">
Your enrollment data has the answers. Start asking.
</h2>
<div className="lg:text-right">
<Link
href="/get-started"
prefetch={false}
data-track="closing_get_started"
className="inline-block rounded-lg bg-[var(--lp-accent-ink)] px-7 py-3 font-semibold text-white shadow-md transition-all hover:shadow-lg hover:brightness-110"
>
Get started
</Link>
<p className="mt-3 text-sm leading-relaxed text-[var(--lp-ink-faint)]">
Free, open source, deployed in minutes.
<br />
{"Need SLAs, support, or MSP delegation? "}
<a href="/plans/" data-track="closing_pro_plan" className="text-[var(--lp-accent-ink)] underline hover:opacity-80">
There&apos;s a Pro plan
</a>
.
</p>
</div>
</div>
</section>
);
}
@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { DOCS_URL } from "@/utils/config";
import { CopyServerUrl } from "./CopyServerUrl";
import { ArrowRightIcon } from "./icons";
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
return (
<li className="rounded-2xl border border-[var(--lp-line)] bg-[var(--lp-bg)] p-5">
<div className="flex items-start gap-4">
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--lp-accent)] text-sm font-bold text-white">
{n}
</span>
<div className="min-w-0 flex-1">
<h3 className="text-[15px] font-semibold text-[var(--lp-ink)]">{title}</h3>
<div className="mt-1 text-sm leading-relaxed text-[var(--lp-ink-soft)]">{children}</div>
</div>
</div>
</li>
);
}
export function ConnectSection() {
return (
<section
id="connect"
data-track-section="connect"
className="scroll-mt-20 border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-6 py-20 sm:py-24"
>
<div className="mx-auto grid max-w-7xl grid-cols-1 items-start gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] lg:gap-16">
<div className="max-w-xl">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">Connect</p>
<h2 className="mt-3 text-balance text-3xl font-bold tracking-tight text-[var(--lp-ink)] sm:text-4xl">Connected in a minute.</h2>
<p className="mt-4 text-lg leading-relaxed text-[var(--lp-ink-soft)]">
There are no API keys and no local server to install. You need a role in your organization&apos;s Autopilot
Monitor tenant.
</p>
<a
href={`${DOCS_URL}${DOCS_PATHS.mcpUsers}`}
target="_blank"
rel="noopener noreferrer"
data-track="setup_guide"
className="mt-6 inline-flex items-center gap-2 text-[15px] font-semibold text-[var(--lp-accent-ink)] hover:opacity-80"
>
Setup guide for every client
<ArrowRightIcon />
</a>
</div>
<ol className="grid grid-cols-1 gap-4">
<Step n={1} title="Add the server">
Paste this URL as a custom connector or MCP server in your client.
<CopyServerUrl />
</Step>
<Step n={2} title="Sign in with Microsoft">
Your browser opens the Microsoft sign-in. Use the work account you use for Autopilot Monitor.
</Step>
<Step n={3} title="Ask your first question">
{"For example: "}
<span className="font-medium text-[var(--lp-ink)]">{"“Which enrollments failed this week, and why?”"}</span>
</Step>
</ol>
</div>
</section>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useCopy } from "@/hooks/useCopy";
import { MCP_SERVER_URL } from "@/utils/config";
import { CopyIcon } from "./icons";
export function CopyServerUrl() {
const { copied, copy } = useCopy();
return (
<div className="mt-3 flex items-center gap-2.5 rounded-xl border border-[var(--lp-line)] bg-[var(--lp-surface)] py-1.5 pl-3.5 pr-1.5">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[13px] text-[var(--lp-ink)]">{MCP_SERVER_URL}</code>
<button
type="button"
data-track="copy_mcp_url"
onClick={() => void copy(MCP_SERVER_URL, "mcp-url")}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-[var(--lp-line)] bg-[var(--lp-surface)] px-2.5 py-2 text-xs font-semibold text-[var(--lp-ink-soft)] transition-colors hover:border-[var(--lp-ink-faint)] hover:text-[var(--lp-ink)]"
>
<CopyIcon className="h-3.5 w-3.5" />
{copied === "mcp-url" ? "Copied" : "Copy"}
</button>
</div>
);
}
@@ -0,0 +1,242 @@
"use client";
import { useState, type ReactNode } from "react";
import { BrandMark } from "../BrandMark";
import {
BuildingIcon,
ChatIcon,
ChipIcon,
CloudIcon,
CodeIcon,
LaptopIcon,
RepeatIcon,
ServerIcon,
TerminalIcon,
} from "./icons";
import { SEGMENT_BUTTON, SEGMENT_GROUP, SEGMENT_OFF, SEGMENT_ON } from "./segmented";
type Placement = "vendor" | "devtools" | "own";
const PLACEMENTS: {
id: Placement;
label: string;
clients: string;
processedBy: string;
setup: string;
}[] = [
{
id: "vendor",
label: "Your AI vendor",
clients: "Claude on web, desktop and mobile. ChatGPT.",
processedBy: "Your AI vendor, under the agreement your organization already has with them.",
setup: "Add the server URL as a connector and sign in with Microsoft. Nothing to register.",
},
{
id: "devtools",
label: "Your developer tools",
clients: "VS Code with GitHub Copilot. Claude Code, Codex and Gemini CLI.",
processedBy: "The model behind your editor or terminal, under the policy that already covers your developer tools.",
setup:
"Add the server to the tool's MCP configuration and sign in. Terminal agents can also download the diagnostics package and search the logs on your machine.",
},
{
id: "own",
label: "Your own infrastructure",
clients: "A chat front end your organization hosts, connected to a model you run. Or a scheduled report that runs without a person.",
processedBy: "Your own systems. The model can run in your cloud subscription or on your own hardware.",
setup:
"A Tenant Admin registers the client's callback URL once under Settings → Tenant → AI Integration. Only accounts of your tenant can sign in. Automation uses an app registration instead.",
},
];
interface Row {
icon: ReactNode;
label: string;
sub: string;
placements: Placement[];
}
const CLIENT_ROWS: Row[] = [
{ icon: <ChatIcon />, label: "Chat app", sub: "Claude, ChatGPT", placements: ["vendor"] },
{ icon: <CodeIcon />, label: "Code editor", sub: "VS Code with GitHub Copilot", placements: ["devtools"] },
{ icon: <TerminalIcon />, label: "Terminal agent", sub: "Claude Code, Codex, Gemini CLI", placements: ["devtools"] },
{ icon: <ServerIcon />, label: "Self-hosted front end", sub: "registered once by a Tenant Admin", placements: ["own"] },
{ icon: <RepeatIcon />, label: "Automation", sub: "its own app identity", placements: ["own"] },
];
const MODEL_ROWS: Row[] = [
{ icon: <CloudIcon />, label: "Your AI vendor's cloud", sub: "under your agreement with them", placements: ["vendor", "devtools"] },
{ icon: <BuildingIcon />, label: "Your cloud subscription", sub: "a model you deploy yourself", placements: ["own"] },
{ icon: <ServerIcon />, label: "Your own hardware", sub: "a local model on your servers", placements: ["own"] },
];
function Node({ tag, accent = false, children }: { tag: string; accent?: boolean; children: ReactNode }) {
return (
<div
className={`relative rounded-2xl border px-4 pb-3.5 pt-5 ${
accent ? "border-[var(--lp-accent-line)] bg-[var(--lp-accent-soft)]" : "border-dashed border-[var(--lp-line)] bg-[var(--lp-surface)]"
}`}
>
<span
className={`absolute -top-[9px] left-3.5 bg-[var(--lp-surface)] px-1.5 text-[10px] font-bold uppercase tracking-[0.16em] ${
accent ? "text-[var(--lp-accent-ink)]" : "text-[var(--lp-ink-faint)]"
}`}
>
{tag}
</span>
{children}
</div>
);
}
function NodeTitle({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<div className="flex items-center gap-2 text-[15px] font-bold text-[var(--lp-ink)]">
{icon}
{children}
</div>
);
}
function Rows({ rows, placement }: { rows: Row[]; placement: Placement }) {
return (
<div className="mt-2.5 grid gap-0.5">
{rows.map(row => {
const on = row.placements.includes(placement);
return (
<div
key={row.label}
className={`flex items-start gap-2.5 rounded-[10px] px-2 py-[7px] text-[13px] leading-[1.35] transition-colors ${
on
? "bg-[var(--lp-surface-2)] font-semibold text-[var(--lp-ink)] shadow-[inset_0_0_0_1px_var(--lp-accent-line)]"
: "text-[var(--lp-ink-soft)]"
}`}
>
<span className={`mt-px shrink-0 ${on ? "text-[var(--lp-accent-ink)]" : "text-[var(--lp-ink-faint)]"}`}>{row.icon}</span>
<span>
{row.label}
<small className="block text-[11.5px] font-normal text-[var(--lp-ink-faint)]">{row.sub}</small>
</span>
</div>
);
})}
</div>
);
}
/** One link of the diagram; talk = the conversation, which never passes Autopilot Monitor. */
function FlowLink({ label, talk = false, both = false }: { label: string; talk?: boolean; both?: boolean }) {
return (
<div aria-hidden="true" className="flex items-center justify-center gap-3 py-2.5 lg:flex-col lg:gap-2 lg:px-2 lg:py-0">
<span
className={`text-[11.5px] font-semibold leading-[1.3] lg:max-w-[100px] lg:text-center ${
talk ? "text-[var(--lp-ink-faint)]" : "text-[var(--lp-accent-ink)]"
}`}
>
{label}
</span>
<span className={`ai-flow-line${talk ? " ai-flow-line--talk" : ""}`}>
<i className="ai-flow-dot" />
{both && <i className="ai-flow-dot ai-flow-dot--back" />}
</span>
</div>
);
}
/**
* Where the model runs: devices → Autopilot Monitor (MCP) → the AI client → the model. Picking a
* placement highlights the matching clients and models and explains who processes the
* conversation and what to set up.
*/
export function DataPath() {
const [placement, setPlacement] = useState<Placement>("vendor");
const active = PLACEMENTS.find(p => p.id === placement) ?? PLACEMENTS[0];
return (
<div className="mt-10 rounded-3xl border border-[var(--lp-line)] bg-[var(--lp-surface)] p-5 shadow-[0_20px_25px_-5px_rgba(0,0,0,0.04),0_8px_10px_-6px_rgba(0,0,0,0.04)] sm:p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h3 className="text-xl font-bold tracking-tight text-[var(--lp-ink)] sm:text-2xl">Where should the model run?</h3>
<p className="mt-1 text-sm text-[var(--lp-ink-soft)]">Pick one. The data and the tools stay the same.</p>
</div>
<div role="tablist" aria-label="Where the model runs" className={SEGMENT_GROUP}>
{PLACEMENTS.map(p => (
<button
key={p.id}
type="button"
role="tab"
aria-selected={p.id === placement}
data-track={`model_runs:${p.id}`}
onClick={() => setPlacement(p.id)}
className={`${SEGMENT_BUTTON} ${p.id === placement ? SEGMENT_ON : SEGMENT_OFF}`}
>
{p.label}
</button>
))}
</div>
</div>
<div className="mt-10 grid grid-cols-1 lg:grid-cols-[minmax(0,0.82fr)_84px_minmax(0,1.12fr)_104px_minmax(0,1.08fr)_104px_minmax(0,1fr)] lg:items-center">
<Node tag="Yours">
<NodeTitle icon={<LaptopIcon className="h-[18px] w-[18px] text-[var(--lp-ink-soft)]" />}>Your devices</NodeTitle>
<p className="mt-1 text-[12.5px] leading-[1.45] text-[var(--lp-ink-soft)]">The agent records each enrollment while it runs.</p>
</Node>
<FlowLink label="telemetry" />
<Node tag="Autopilot Monitor" accent>
<NodeTitle icon={<BrandMark className="h-[18px] w-[18px]" />}>Autopilot Monitor</NodeTitle>
<div className="mt-3 flex flex-wrap gap-1.5">
{["Enrollment data", "Analysis rules", "Error-code catalog", "Product docs"].map(chip => (
<span
key={chip}
className="rounded-full border border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-2 py-[3px] text-[11.5px] font-medium text-[var(--lp-ink-soft)]"
>
{chip}
</span>
))}
</div>
<div className="mt-2.5 flex items-center justify-between gap-2 rounded-[10px] bg-[var(--lp-accent-ink)] px-3 py-2 text-[13px] font-bold text-white">
<span>MCP server</span>
<span className="font-mono text-[11px] font-medium opacity-85">read-only</span>
</div>
<p className="mt-2.5 text-[12.5px] leading-[1.45] text-[var(--lp-ink-soft)]">Answers tool calls. Calls no AI provider itself.</p>
</Node>
<FlowLink label="tool calls and results" both />
<Node tag="Yours">
<NodeTitle icon={<ChatIcon className="h-[18px] w-[18px] text-[var(--lp-ink-soft)]" />}>Your AI client</NodeTitle>
<Rows rows={CLIENT_ROWS} placement={placement} />
</Node>
<FlowLink label="your conversation" talk both />
<Node tag="Your choice">
<NodeTitle icon={<ChipIcon className="h-[18px] w-[18px] text-[var(--lp-ink-soft)]" />}>The model</NodeTitle>
<Rows rows={MODEL_ROWS} placement={placement} />
</Node>
</div>
<div className="mt-8 flex flex-col gap-3 text-[13px] text-[var(--lp-ink-soft)] md:flex-row md:gap-8">
<span className="inline-flex items-center gap-2.5">
<span className="h-0.5 w-7 shrink-0 rounded bg-[var(--lp-accent)]" />
Reaches Autopilot Monitor: telemetry from your devices, tool calls from your assistant.
</span>
<span className="inline-flex items-center gap-2.5">
<span className="w-7 shrink-0 border-t-2 border-dashed border-[var(--lp-ink-faint)]" />
Never reaches Autopilot Monitor: your prompts and the model&apos;s answers.
</span>
</div>
<div className="mt-8 grid grid-cols-1 gap-6 border-t border-[var(--lp-line-soft)] pt-8 md:grid-cols-3 md:gap-10">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">Typical clients</p>
<p className="mt-2 text-[15px] leading-relaxed text-[var(--lp-ink-soft)]">{active.clients}</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">Who processes the conversation</p>
<p className="mt-2 text-[15px] leading-relaxed text-[var(--lp-ink-soft)]">{active.processedBy}</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">What you set up</p>
<p className="mt-2 text-[15px] leading-relaxed text-[var(--lp-ink-soft)]">{active.setup}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { AssistantPanel } from "./AssistantPanel";
import { QUESTIONS, SKINS, TOPICS, type Skin, type TopicId } from "./questions";
import { SEGMENT_BUTTON, SEGMENT_GROUP, SEGMENT_OFF, SEGMENT_ON } from "./segmented";
/**
* The /ai question gallery: pick a topic and a question, see the tool calls and the answer, and
* switch the client the answer is shown in. Every question text is in the static HTML; the
* inactive topic lists are only hidden.
*/
export function QuestionGallery() {
const [questionId, setQuestionId] = useState(QUESTIONS[0].id);
const [skin, setSkin] = useState<Skin>("chat");
const question = QUESTIONS.find(q => q.id === questionId) ?? QUESTIONS[0];
const skinNote = SKINS.find(s => s.id === skin)?.note ?? "";
const selectTopic = (topic: TopicId) => {
const first = QUESTIONS.find(q => q.topic === topic);
if (first) setQuestionId(first.id);
};
return (
<div className="mt-10 grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.35fr)] lg:gap-8">
<div className="min-w-0">
<div role="tablist" aria-label="Question topics" className="flex flex-wrap gap-2">
{TOPICS.map(topic => {
const selected = topic.id === question.topic;
return (
<button
key={topic.id}
type="button"
role="tab"
aria-selected={selected}
data-track={`topic:${topic.id}`}
onClick={() => selectTopic(topic.id)}
className={`whitespace-nowrap rounded-full border px-3.5 py-2.5 text-[13px] font-semibold leading-none transition-colors ${
selected
? "border-[var(--lp-ink)] bg-[var(--lp-ink)] text-[var(--lp-surface)]"
: "border-[var(--lp-line)] bg-[var(--lp-surface)] text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)]"
}`}
>
{topic.label}
</button>
);
})}
</div>
{TOPICS.map(topic => (
<div
key={topic.id}
role="tabpanel"
aria-label={topic.label}
className={topic.id === question.topic ? "mt-5 grid grid-cols-1 gap-2" : "hidden"}
>
{QUESTIONS.filter(q => q.topic === topic.id).map(q => {
const pressed = q.id === question.id;
return (
<button
key={q.id}
type="button"
aria-pressed={pressed}
data-track={`question:${q.id}`}
onClick={() => setQuestionId(q.id)}
className={`flex w-full flex-col gap-1.5 rounded-[14px] border px-4 py-3.5 text-left transition-colors ${
pressed
? "border-[var(--lp-accent-line)] bg-[var(--lp-accent-soft)]"
: "border-[var(--lp-line-soft)] bg-[var(--lp-surface)] hover:border-[var(--lp-accent-line)]"
}`}
>
<span className="text-[15px] font-semibold leading-snug text-[var(--lp-ink)]">{q.text}</span>
<span
className={`font-mono text-[11.5px] ${pressed ? "text-[var(--lp-accent-ink)]" : "text-[var(--lp-ink-faint)]"}`}
>
{q.tools.map(t => (t.local ? "local search" : t.name)).join(" · ")}
</span>
</button>
);
})}
</div>
))}
<p className="mt-6 text-sm leading-relaxed text-[var(--lp-ink-faint)]">
{"Built-in prompts such as "}
<span className="font-mono text-[12.5px] text-[var(--lp-ink-soft)]">investigate-failed-session</span>
{" and "}
<span className="font-mono text-[12.5px] text-[var(--lp-ink-soft)]">cve-exposure-audit</span>
{" give your assistant a head start."}
</p>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
<p className="text-sm text-[var(--lp-ink-soft)]">{skinNote}</p>
<div role="group" aria-label="Show the answer in" className={SEGMENT_GROUP}>
{SKINS.map(s => (
<button
key={s.id}
type="button"
aria-pressed={s.id === skin}
data-track={`skin:${s.id}`}
onClick={() => setSkin(s.id)}
className={`${SEGMENT_BUTTON} ${s.id === skin ? SEGMENT_ON : SEGMENT_OFF}`}
>
{s.label}
</button>
))}
</div>
</div>
<div className="mt-4">
<AssistantPanel skin={skin} question={question} />
</div>
<p className="mt-3 text-[13px] text-[var(--lp-ink-faint)]">Sample data. The tools and the shape of each answer are real.</p>
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { ArrowDownIcon } from "./icons";
import { QuestionGallery } from "./QuestionGallery";
export function QuestionsSection() {
return (
<section id="questions" data-track-section="questions" className="scroll-mt-20 px-6 py-20 sm:py-24">
<div className="mx-auto max-w-7xl">
<div className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">Try it</p>
<h2 className="mt-3 text-balance text-3xl font-bold tracking-tight text-[var(--lp-ink)] sm:text-4xl">
Questions teams ask every day.
</h2>
<p className="mt-4 text-lg leading-relaxed text-[var(--lp-ink-soft)]">
Pick a question to see which tools the assistant calls and what comes back. All answers use sample data.
</p>
<a
href="#architecture"
data-track="connection_link"
className="mt-4 inline-flex items-center gap-1.5 text-[15px] font-semibold text-[var(--lp-accent-ink)] hover:opacity-80"
>
How the connection works
<ArrowDownIcon />
</a>
</div>
<QuestionGallery />
</div>
</section>
);
}
@@ -0,0 +1,104 @@
import { ArrowRightIcon, CheckChip } from "./icons";
const IME_LINE =
'<![LOG[[Win32App] Admin did NOT set mapping for lpExitCode: 60001]LOG]!><time="11:14:58.5124" date="10-1-2026" component="AppWorkload" context="" type="1" thread="18" file="">';
const EVENT_FIELDS: [string, string][] = [
["event", "app_install_failed"],
["app", "Contoso CRM 4.2"],
["exit code", "60001 · no mapping"],
["phase", "Device setup"],
["attempt", "1"],
["install time", "1 min 32 s"],
];
const DOMAINS = [
{ title: "Sessions and phases", text: "Status, duration, phase timeline and reboots of every enrollment." },
{ title: "App installs", text: "Attempts, exit codes and install times per app, plus Delivery Optimization." },
{ title: "Errors, decoded", text: "Error codes with their meaning, IME log matches and rule findings." },
{ title: "Devices and network", text: "Model, TPM, OS build, connection type and, if enabled, location." },
{ title: "Software and CVEs", text: "Optional inventory matched against known CVEs, ranked by KEV and EPSS." },
{ title: "Knowledge built in", text: "Analysis rules, the error-code catalog and the product docs." },
];
function Caption({ label, meta, accent = false }: { label: string; meta: string; accent?: boolean }) {
const color = accent ? "text-[var(--lp-accent-ink)]" : "text-[var(--lp-ink-faint)]";
return (
<figcaption className="flex items-baseline justify-between gap-3">
<span className={`text-[11px] font-semibold uppercase tracking-[0.2em] ${color}`}>{label}</span>
<span className={`font-mono text-[11px] ${color}`}>{meta}</span>
</figcaption>
);
}
function PipeArrow() {
return (
<div className="flex items-center justify-center text-[var(--lp-ink-faint)]" aria-hidden="true">
<ArrowRightIcon className="h-5 w-5 rotate-90 lg:rotate-0" />
</div>
);
}
export function StructuredFacts() {
return (
<section
id="structured"
data-track-section="structured"
className="scroll-mt-20 border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-6 py-20 sm:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">Structured from the start</p>
<h2 className="mt-3 text-balance text-3xl font-bold tracking-tight text-[var(--lp-ink)] sm:text-4xl">
Your assistant reads facts, not log files.
</h2>
<p className="mt-4 text-lg leading-relaxed text-[var(--lp-ink-soft)]">
The agent turns each enrollment into typed events while it runs. Your assistant queries them through dedicated
tools, so its answers rest on data instead of guesswork.
</p>
</div>
<div className="mt-12 grid grid-cols-1 items-stretch gap-3 lg:grid-cols-[minmax(0,1fr)_32px_minmax(0,1fr)_32px_minmax(0,1fr)] lg:gap-4">
<figure className="flex flex-col gap-3 rounded-2xl border border-[var(--lp-line)] bg-[var(--lp-bg)] p-5">
<Caption label="On the device" meta="AppWorkload.log" />
<pre className="m-0 whitespace-pre-wrap break-all rounded-[10px] border border-[var(--lp-term-line)] bg-[var(--lp-term-bg)] p-3 font-mono text-[11.5px] leading-[1.6] text-[var(--lp-term-ink)]">
{IME_LINE}
</pre>
<p className="text-[13px] leading-relaxed text-[var(--lp-ink-soft)]">One line among thousands, spread over several log files.</p>
</figure>
<PipeArrow />
<figure className="flex flex-col gap-3 rounded-2xl border border-[var(--lp-accent-line)] bg-[var(--lp-accent-soft)] p-5">
<Caption label="In Autopilot Monitor" meta="typed event" accent />
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-4 gap-y-1.5 font-mono text-[13px]">
{EVENT_FIELDS.map(([key, value]) => (
<div key={key} className="contents">
<dt className="text-[var(--lp-ink-faint)]">{key}</dt>
<dd className="m-0 font-medium text-[var(--lp-ink)]">{value}</dd>
</div>
))}
</dl>
</figure>
<PipeArrow />
<figure className="flex flex-col gap-3 rounded-2xl border border-[var(--lp-line)] bg-[var(--lp-bg)] p-5">
<Caption label="In your assistant" meta="plain language" />
<blockquote className="text-[17px] font-medium leading-relaxed text-[var(--lp-ink)]">
{"“Contoso CRM exited with 60001, a code the app has no mapping for, so the ESP stopped. The retry an hour later installed cleanly.”"}
</blockquote>
</figure>
</div>
<ul className="mt-14 grid grid-cols-1 gap-x-10 gap-y-7 sm:grid-cols-2 lg:grid-cols-3">
{DOMAINS.map(domain => (
<li key={domain.title} className="flex items-start gap-3">
<CheckChip />
<div>
<h3 className="text-[15px] font-semibold text-[var(--lp-ink)]">{domain.title}</h3>
<p className="mt-1 text-sm leading-relaxed text-[var(--lp-ink-soft)]">{domain.text}</p>
</div>
</li>
))}
</ul>
</div>
</section>
);
}
@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { QUESTIONS, SKINS, TOPICS, type Block } from "../questions";
import { TRACK_ID_PATTERN } from "@/lib/clickTracking";
/**
* The /ai gallery says "the tools and the shape of each answer are real". These checks keep that
* true when the MCP server changes: every tool named exists, every tenant user has it, and every
* argument shown is one the tool declares.
*/
const MCP_TOOLS_DIR = path.resolve(__dirname, "../../../../../McpServer/autopilot-monitor-mcp/src/tools");
interface Registration {
gate: string | null;
block: string;
}
/** Every tool the MCP server registers, with the `if (…)` it is registered under and its source block. */
function mcpRegistrations(): Map<string, Registration> {
const registrations = new Map<string, Registration>();
const pattern = /(?:if\s*\(([^)]*)\)\s*)?server\.registerTool\(\s*['"]([a-z_]+)['"]/g;
for (const file of readdirSync(MCP_TOOLS_DIR).filter(f => f.endsWith(".ts"))) {
const source = readFileSync(path.join(MCP_TOOLS_DIR, file), "utf-8");
const matches = [...source.matchAll(pattern)];
matches.forEach((match, i) => {
const end = i + 1 < matches.length ? matches[i + 1].index : source.length;
registrations.set(match[2], { gate: match[1]?.trim() || null, block: source.slice(match.index, end) });
});
}
return registrations;
}
/** Registered for every tenant member: no role gate, or only the exclusion of delegated admins. */
const openToTenantUsers = (gate: string | null) => gate === null || gate === "!delegated";
function tablesOf(blocks: Block[]): Extract<Block, { kind: "table" }>[] {
return blocks.flatMap(block =>
block.kind === "table"
? [block]
: block.kind === "byFileAccess"
? [...tablesOf(block.withFiles), ...tablesOf(block.withoutFiles)]
: []
);
}
describe("AI page question gallery", () => {
it("has unique question ids and at least one question per topic", () => {
const ids = QUESTIONS.map(q => q.id);
expect(new Set(ids).size).toBe(ids.length);
for (const topic of TOPICS) expect(QUESTIONS.some(q => q.topic === topic.id), topic.id).toBe(true);
});
it("gives every question an MCP tool call and an answer", () => {
for (const q of QUESTIONS) {
expect(q.tools.some(t => !t.local), q.id).toBe(true);
expect(q.answer.length, q.id).toBeGreaterThan(0);
}
});
it("keeps table rows as wide as their heading", () => {
for (const q of QUESTIONS) {
for (const table of tablesOf(q.answer)) {
const width = table.head?.length ?? table.rows[0].cells.length;
for (const row of table.rows.filter(r => !r.fullWidth)) expect(row.cells.length, q.id).toBe(width);
}
}
});
it("builds valid tracking ids from topics, questions and skins", () => {
const ids = [
...TOPICS.map(t => `topic:${t.id}`),
...QUESTIONS.map(q => `question:${q.id}`),
...SKINS.map(s => `skin:${s.id}`),
];
for (const id of ids) expect(id).toMatch(TRACK_ID_PATTERN);
});
});
describe("MCP tools named on the AI page", () => {
const registrations = mcpRegistrations();
it("reads the server's registrations, gated and open", () => {
expect(registrations.size).toBeGreaterThanOrEqual(50);
expect(openToTenantUsers(registrations.get("get_session_summary")!.gate)).toBe(true);
expect(openToTenantUsers(registrations.get("query_table")!.gate)).toBe(false);
});
it("names only tools every tenant user has", () => {
for (const q of QUESTIONS) {
for (const call of q.tools.filter(t => !t.local)) {
const registration = registrations.get(call.name);
expect(registration, `${q.id}: ${call.name} is not registered by the MCP server`).toBeDefined();
expect(openToTenantUsers(registration!.gate), `${q.id}: ${call.name} is registered under if (${registration!.gate})`).toBe(true);
}
}
});
it("shows only arguments the tool declares", () => {
for (const q of QUESTIONS) {
for (const call of q.tools.filter(t => !t.local && t.args)) {
const block = registrations.get(call.name)!.block;
const keys = [...call.args!.matchAll(/(?:^|[{,])\s*([A-Za-z_]\w*)\s*:/g)].map(m => m[1]);
for (const key of keys) expect(new RegExp(`\\b${key}\\s*:`).test(block), `${q.id}: ${call.name} has no argument ${key}`).toBe(true);
}
}
});
});
@@ -0,0 +1,104 @@
import type { ReactNode } from "react";
/** Stroke icons for the /ai page (24-unit grid, currentColor). */
type IconProps = { className?: string };
function Icon({ className = "w-4 h-4", children }: IconProps & { children: ReactNode }) {
return (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{children}
</svg>
);
}
export const ChatIcon = (p: IconProps) => (
<Icon {...p}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</Icon>
);
export const CodeIcon = (p: IconProps) => (
<Icon {...p}>
<path d="M16 18l6-6-6-6M8 6l-6 6 6 6" />
</Icon>
);
export const TerminalIcon = (p: IconProps) => (
<Icon {...p}>
<path d="M4 17l6-6-6-6M12 19h8" />
</Icon>
);
export const ServerIcon = (p: IconProps) => (
<Icon {...p}>
<rect x="2" y="3" width="20" height="7" rx="2" />
<rect x="2" y="14" width="20" height="7" rx="2" />
<path d="M6 6.5h.01M6 17.5h.01" />
</Icon>
);
export const RepeatIcon = (p: IconProps) => (
<Icon {...p}>
<path d="M17 1l4 4-4 4" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<path d="M7 23l-4-4 4-4" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</Icon>
);
export const LaptopIcon = (p: IconProps) => (
<Icon {...p}>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M2 20h20" />
</Icon>
);
export const ChipIcon = (p: IconProps) => (
<Icon {...p}>
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="9" y="9" width="6" height="6" />
<path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3" />
</Icon>
);
export const CloudIcon = (p: IconProps) => (
<Icon {...p}>
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" />
</Icon>
);
export const BuildingIcon = (p: IconProps) => (
<Icon {...p}>
<rect x="4" y="2" width="16" height="20" rx="2" />
<path d="M9 22v-4h6v4M8 6h.01M12 6h.01M16 6h.01M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14h.01" />
</Icon>
);
export const ArrowRightIcon = (p: IconProps) => (
<Icon {...p}>
<path d="M5 12h14M13 5l7 7-7 7" />
</Icon>
);
export const ArrowDownIcon = (p: IconProps) => (
<Icon {...p}>
<path d="M12 5v14M5 12l7 7 7-7" />
</Icon>
);
export const CopyIcon = (p: IconProps) => (
<Icon {...p}>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</Icon>
);
/** The check chip used in landing lists (CapabilitiesStrip). */
export function CheckChip() {
return (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-[var(--lp-accent-soft)]">
<svg className="h-3 w-3 text-[var(--lp-accent-ink)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</span>
);
}
@@ -0,0 +1,395 @@
/**
* Content of the /ai question gallery: topics, example questions, the MCP tools each one calls and
* the answer, as typed blocks. Data instead of JSX, so every string keeps its own spaces (Turbopack
* trims JSX text at inline-element boundaries) and the tool names can be checked against the MCP
* server (components/ai/__tests__/questions.test.ts). All numbers and names are sample data.
*/
export type Skin = "chat" | "vscode" | "terminal" | "selfhosted";
export type Tone = "ok" | "warn" | "high" | "dim";
export type TopicId = "troubleshoot" | "fleet" | "apps" | "security" | "reporting";
/** Plain text, or a text run with emphasis. Strings carry their own leading/trailing spaces. */
export type Span = string | { text: string; tone?: Tone; bold?: boolean; code?: boolean };
export interface TableRow {
cells: Span[];
/** Bar drawn in an extra last column, width in px. */
bar?: { width: number; tone?: "warn" | "dim" };
/** One cell spanning the whole table (a "… and N more" line). */
fullWidth?: boolean;
}
export type Block =
| { kind: "p"; spans: Span[]; tone?: Tone }
| { kind: "list"; items: Span[][] }
| { kind: "table"; head?: string[]; numeric?: number[]; rows: TableRow[] }
| { kind: "ticket"; title: string; fields: { label: string; text: string }[] }
| { kind: "note"; text: string }
/** The answer differs between clients that can read local files and those that cannot. */
| { kind: "byFileAccess"; withFiles: Block[]; withoutFiles: Block[] };
/** An MCP tool call, or (`local`) a command a file-capable client runs on its own machine. */
export interface ToolCall {
name: string;
args?: string;
local?: true;
}
export interface Question {
id: string;
topic: TopicId;
text: string;
tools: ToolCall[];
answer: Block[];
}
export const TOPICS: { id: TopicId; label: string }[] = [
{ id: "troubleshoot", label: "Troubleshoot" },
{ id: "fleet", label: "Fleet" },
{ id: "apps", label: "Apps" },
{ id: "security", label: "Security" },
{ id: "reporting", label: "Reporting" },
];
export const SKINS: { id: Skin; label: string; note: string }[] = [
{ id: "chat", label: "Chat app", note: "Claude on the web, desktop or mobile, or ChatGPT." },
{ id: "vscode", label: "VS Code", note: "VS Code with GitHub Copilot in agent mode, next to your scripts." },
{ id: "terminal", label: "Terminal", note: "Claude Code, Codex or Gemini CLI. They can also download the diagnostics package." },
{ id: "selfhosted", label: "Self-hosted", note: "A chat front end your organization hosts, with the model you choose." },
];
/** Clients with file access can download the diagnostics package and search it locally. */
export const FILE_ACCESS_SKINS: ReadonlySet<Skin> = new Set<Skin>(["vscode", "terminal"]);
const dim = (text: string): Span => ({ text, tone: "dim" });
const code = (text: string): Span => ({ text, code: true });
const label = (text: string, tone: Tone): Span => ({ text, tone, bold: true });
export const QUESTIONS: Question[] = [
{
id: "t1",
topic: "troubleshoot",
text: "CONTOSO-3812 failed enrollment this morning. What happened?",
tools: [
{ name: "search_sessions", args: '{ deviceName: "CONTOSO-3812" }' },
{ name: "get_session_summary", args: '{ sessionId: "e6a1…f43b" }' },
],
answer: [
{ kind: "p", spans: ["Two attempts today. The first failed, the retry succeeded."] },
{
kind: "list",
items: [
[label("✗ Failed", "high"), " 11:05–11:17 · ESP error ", code("0x8007EA61"), " · 7 of 8 apps"],
[label("✓ Succeeded", "ok"), " 12:05–12:20 · 8 of 8 apps"],
],
},
{
kind: "p",
spans: [
label("Cause:", "warn"),
" Contoso CRM exited with ",
code("60001"),
". The Win32 app has no mapping for that code, so the ESP stopped. ",
code("0x8007EA61"),
" is 60001 as an HRESULT.",
],
},
{ kind: "p", tone: "dim", spans: ["60001 is PSADT's generic code for an unhandled error."] },
{ kind: "p", spans: [label("Fix:", "ok"), " map 60001 to retry, or handle the error inside the package."] },
],
},
{
id: "t2",
topic: "troubleshoot",
text: "What does 0x87D1041C mean, and where do we see it?",
tools: [
{ name: "lookup_error_code", args: '{ code: "0x87D1041C" }' },
{ name: "search_events", args: '{ query: "app not detected after install" }' },
],
answer: [
{ kind: "p", spans: [code("0x87D1041C"), " means the app was not detected after the installer reported success."] },
{
kind: "p",
tone: "dim",
spans: ["The usual cause is a detection rule that checks a file, version or registry value the installer does not write."],
},
{ kind: "p", spans: [label("Seen in 14 sessions", "warn"), " in the last 30 days, all with Contoso VPN 7.1."] },
{ kind: "p", spans: [label("Next step:", "ok"), " compare the detection rule with what version 7.1 installs."] },
],
},
{
id: "t3",
topic: "troubleshoot",
text: "Get the diagnostics package of that session and check the IME log.",
tools: [
{ name: "get_session_diagnostics", args: '{ sessionId: "e6a1…f43b" }' },
{ name: "Bash", args: 'unzip -q diag.zip && grep -n "Contoso CRM" AppWorkload.log', local: true },
],
answer: [
{
kind: "byFileAccess",
withoutFiles: [
{ kind: "p", spans: ["Here is a download link for the diagnostics package. It stays valid for about 10 minutes."] },
{
kind: "p",
tone: "dim",
spans: [
"I can't open ZIP files in this chat. A client with file access, such as Claude Code or VS Code, unpacks the package and searches the IME logs for you.",
],
},
],
withFiles: [
{ kind: "p", spans: ["Downloaded the package and searched AppWorkload.log:"] },
{
kind: "table",
rows: [
{ cells: [dim("11:13:26"), "Contoso CRM: install started, attempt 1"] },
{ cells: [dim("11:14:58"), { text: "Installer exited with 60001 after 92 s", tone: "high" }] },
{ cells: [dim("11:14:58"), "No mapping for 60001, install reported as failed"] },
{ cells: [dim("12:09:41"), { text: "Attempt 2: installed in 75 s", tone: "ok" }] },
],
},
{ kind: "p", spans: [label("Result:", "ok"), " the package works on a second run. The failure is transient."] },
],
},
],
},
{
id: "f1",
topic: "fleet",
text: "Where does our enrollment time go, and what would save the most?",
tools: [{ name: "get_time_attribution" }],
answer: [
{ kind: "p", spans: ["Median user-driven enrollment over the last 30 days: ", { text: "38 min", bold: true }, "."] },
{
kind: "table",
head: ["Phase", "Median"],
numeric: [1],
rows: [
{ cells: ["Device preparation", "4 min"], bar: { width: 25 } },
{ cells: [{ text: "Device setup apps", tone: "warn" }, { text: "19 min", tone: "warn" }], bar: { width: 120, tone: "warn" } },
{ cells: ["Windows Hello", "2 min"], bar: { width: 13 } },
{ cells: ["Account setup", "9 min"], bar: { width: 57 } },
{ cells: ["Desktop handoff", "1 min"], bar: { width: 6 } },
{ cells: [dim("Not attributed"), dim("3 min")], bar: { width: 19, tone: "dim" } },
],
},
{
kind: "p",
spans: [
label("Biggest lever:", "warn"),
" Contoso CAD Suite blocks device setup for 7 min. Installing it after setup would save up to 7 min per device.",
],
},
],
},
{
id: "f2",
topic: "fleet",
text: "How many devices enrolled right the first time last month?",
tools: [
{ name: "get_device_history", args: "{ days: 30 }" },
{ name: "search_sessions", args: '{ fields: "serialNumber,model,status" }' },
],
answer: [
{ kind: "p", spans: [label("First-time-right: 412 of 448 devices (92%).", "ok"), " 36 devices needed two or more attempts."] },
{ kind: "p", spans: [label("21 of the 36 are the same model:", "warn"), " Contoso Book 14 G3."] },
{ kind: "p", spans: ["Check the driver and firmware apps for that model first."] },
],
},
{
id: "f3",
topic: "fleet",
text: "Are enrollments at the Munich office slower than elsewhere?",
tools: [{ name: "get_geographic_metrics", args: '{ groupBy: "city" }' }],
answer: [
{
kind: "table",
head: ["Location", "Sessions", "Median", "Success"],
numeric: [1, 2, 3],
rows: [
{ cells: [{ text: "Munich", tone: "warn" }, "64", { text: "52 min", tone: "warn" }, "97%"] },
{ cells: ["All sites", "487", "38 min", "95%"] },
],
},
{
kind: "p",
spans: [
"The devices in Munich don't fail, they are slow. API latency matches the other sites, so the distance to the backend is not the cause.",
],
},
{ kind: "p", spans: [label("Next step:", "ok"), " check the proxy and Delivery Optimization at that site."] },
],
},
{
id: "p1",
topic: "apps",
text: "Which app breaks our enrollments most often?",
tools: [{ name: "get_app_install_metrics", args: "{ days: 30 }" }],
answer: [
{ kind: "p", spans: ["Top failing apps in the last 30 days:"] },
{
kind: "table",
head: ["App", "Failures", "Rate", "Most common code"],
numeric: [1, 2],
rows: [
{ cells: [{ text: "Contoso VPN 7.1", tone: "high" }, { text: "14", tone: "high" }, "6.2%", code("0x87D1041C")] },
{ cells: ["Contoso CRM 4.2", "5", "2.4%", code("exit 60001")] },
{ cells: ["Printer Pack 2.0", "3", "0.7%", code("0x80070643")] },
],
},
{ kind: "p", spans: [label("Takeaway:", "ok"), " Contoso VPN alone causes more than half of all app failures."] },
],
},
{
id: "p2",
topic: "apps",
text: "Which apps keep device setup waiting the longest?",
tools: [{ name: "get_time_attribution" }],
answer: [
{ kind: "p", spans: ["Apps that block device setup, by median active install time:"] },
{
kind: "table",
head: ["App", "Install time"],
numeric: [1],
rows: [
{ cells: [{ text: "Contoso CAD Suite", tone: "warn" }, { text: "7 min 10 s", tone: "warn" }], bar: { width: 120, tone: "warn" } },
{ cells: ["Microsoft 365 Apps", "4 min 40 s"], bar: { width: 78 } },
{ cells: ["Contoso VPN 7.1", "1 min 50 s"], bar: { width: 31 } },
],
},
{ kind: "p", spans: [label("Biggest saving:", "ok"), " taking CAD Suite off the blocking list saves up to 7 min per device."] },
],
},
{
id: "p3",
topic: "apps",
text: "How much install traffic came from peers or Connected Cache?",
tools: [{ name: "get_app_install_metrics", args: "{ days: 30 }" }],
answer: [
{ kind: "p", spans: ["1.8 TB were downloaded for app installs in the last 30 days."] },
{
kind: "table",
head: ["Source", "Share"],
numeric: [1],
rows: [
{ cells: [{ text: "Peers and Microsoft Connected Cache", tone: "ok" }, { text: "62%", tone: "ok" }], bar: { width: 120 } },
{ cells: ["Internet", "38%"], bar: { width: 74, tone: "dim" } },
],
},
{ kind: "p", spans: ["That kept about 1.1 TB off your internet line."] },
],
},
{
id: "s1",
topic: "security",
text: "How exposed are the devices we enrolled this month?",
tools: [{ name: "get_vulnerability_summary", args: "{ days: 30 }" }],
answer: [
{ kind: "p", spans: ["17 distinct CVEs across 236 devices."] },
{ kind: "p", spans: [label("Act now:", "high"), " 2 CVEs are on CISA's Known Exploited list."] },
{
kind: "table",
head: ["Software", "Priority", "CVSS", "Devices"],
numeric: [2, 3],
rows: [
{ cells: ["Contoso PDF Reader 11.2", { text: "KEV", tone: "high" }, "7.8", "188"] },
{ cells: ["Contoso Archiver 3.1", { text: "KEV", tone: "high" }, "8.8", "9"] },
],
},
{ kind: "p", spans: [label("Attend:", "warn"), " 4 CVEs with a high exploit probability (EPSS). ", dim("Track: 11.")] },
{ kind: "p", spans: [label("Start with PDF Reader:", "ok"), " one update covers 188 devices."] },
{ kind: "note", text: "Uses the optional software inventory with CVE matching." },
],
},
{
id: "s2",
topic: "security",
text: "Which devices still get the vulnerable PDF Reader?",
tools: [{ name: "search_sessions_by_cve", args: '{ cveId: "CVE-2026-…" }' }],
answer: [
{ kind: "p", spans: ["188 sessions in the last 30 days. ", label("All of them report version 11.2, none has 11.3 yet.", "warn")] },
{
kind: "table",
head: ["Device", "Model", "Enrolled"],
rows: [
{ cells: ["CONTOSO-4471", "Contoso Book 14 G3", "today 09:12"] },
{ cells: ["CONTOSO-4466", "Contoso Book 14 G3", "today 08:47"] },
{ cells: ["CONTOSO-4459", "Contoso Desk 7", "yesterday 16:05"] },
{ cells: [dim("… and 185 more")], fullWidth: true },
],
},
{ kind: "p", tone: "dim", spans: ["Want the full list as a CSV for the patch team?"] },
],
},
{
id: "s3",
topic: "security",
text: "Did any device enroll with an old TPM version?",
tools: [{ name: "search_sessions", args: '{ deviceProperties: { "tpm_status.specVersion": "1.2" } }' }],
answer: [{ kind: "p", spans: [label("No.", "ok"), " All 448 devices from the last 30 days report TPM 2.0."] }],
},
{
id: "r1",
topic: "reporting",
text: "Write this week's enrollment report for the change advisory board.",
tools: [
{ name: "get_metrics", args: "{ days: 7 }" },
{ name: "get_device_history", args: "{ days: 7 }" },
{ name: "get_app_install_metrics", args: "{ days: 7 }" },
],
answer: [
{ kind: "p", spans: [{ text: "Enrollment report, week 40", bold: true }] },
{
kind: "list",
items: [
["118 enrollments, 95% succeeded (last week 91%)"],
["First-time-right: 93%"],
["Median duration 37 min, 4 min faster than last week"],
[{ text: "Open issue: Contoso VPN 7.1 detection rule, 4 failures", tone: "warn" }],
],
},
{ kind: "p", tone: "dim", spans: ["Want it as a table or as an email draft?"] },
],
},
{
id: "r2",
topic: "reporting",
text: "Draft a ticket for the packaging team about the VPN failures.",
tools: [
{ name: "search_events", args: '{ query: "Contoso VPN not detected" }' },
{ name: "get_session_summary", args: "… × 3" },
],
answer: [
{ kind: "p", spans: ["Here is a draft:"] },
{
kind: "ticket",
title: "Contoso VPN 7.1 not detected after install (0x87D1041C)",
fields: [
{ label: "Impact", text: "14 failed installs in 30 days, all during device setup." },
{ label: "Evidence", text: "3 example sessions with timeline, linked below." },
{ label: "Likely cause", text: "the detection rule does not match version 7.1." },
{ label: "Ask", text: "align the detection rule with 7.1 and test it on one device." },
],
},
],
},
{
id: "r3",
topic: "reporting",
text: "Summarize last month for my manager in five sentences.",
tools: [
{ name: "get_metrics", args: "{ days: 30 }" },
{ name: "get_time_attribution" },
],
answer: [
{
kind: "p",
spans: [
"In September we enrolled 448 devices, and 95% succeeded. 92% of them worked on the first attempt. A typical enrollment took 38 minutes. Most failures came from one VPN package, which packaging is fixing. Moving one large app out of device setup could save up to 7 minutes per device.",
],
},
],
},
];
@@ -0,0 +1,8 @@
/** Segmented control used on /ai (answer client, where the model runs). */
export const SEGMENT_GROUP =
"inline-flex flex-wrap gap-1 rounded-xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)] p-1";
export const SEGMENT_BUTTON =
"rounded-[9px] px-3.5 py-2.5 text-[13.5px] font-semibold leading-tight transition-colors";
export const SEGMENT_ON =
"bg-[var(--lp-surface)] text-[var(--lp-ink)] shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_var(--lp-line)]";
export const SEGMENT_OFF = "text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)]";
@@ -41,7 +41,7 @@ const CAPABILITIES = [
export function CapabilitiesStrip() {
return (
<section id="features" className="py-20 sm:py-24 px-6 scroll-mt-20 border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface)]">
<section id="features" data-track-section="capabilities" className="py-20 sm:py-24 px-6 scroll-mt-20 border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface)]">
<div className="max-w-7xl mx-auto">
<Reveal className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">Capabilities</p>
@@ -50,7 +50,7 @@ const ROWS = [
export function Comparison() {
return (
<section id="comparison" className="py-20 sm:py-24 px-6 scroll-mt-20">
<section id="comparison" data-track-section="comparison" className="py-20 sm:py-24 px-6 scroll-mt-20">
<div className="max-w-7xl mx-auto">
<Reveal className="max-w-2xl mx-auto text-center">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">Compare</p>
@@ -1,6 +1,6 @@
export function FinalCta() {
return (
<section className="py-20 sm:py-24 px-6 border-t border-[var(--lp-line-soft)]">
<section data-track-section="final_cta" className="py-20 sm:py-24 px-6 border-t border-[var(--lp-line-soft)]">
<div className="max-w-7xl mx-auto flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)] max-w-2xl text-balance">
Your next enrollment doesn&apos;t have to be a black box.
@@ -8,6 +8,7 @@ export function FinalCta() {
<div className="shrink-0 lg:text-right">
<a
href="/get-started"
data-track="final_get_started"
className="inline-block px-7 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all"
>
Start monitoring now
@@ -18,6 +19,7 @@ export function FinalCta() {
Need SLAs, support, or MSP delegation?{" "}
<a
href="/plans"
data-track="final_pro_plan"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
There&apos;s a Pro plan
@@ -15,12 +15,13 @@ const DEVICE_ASSOCIATION_DOCS_URL = `${DOCS_URL}/getting-started/autopilot-devic
export function Hero() {
return (
<header className="pt-16 sm:pt-20 pb-0 px-6">
<header data-track-section="hero" className="pt-16 sm:pt-20 pb-0 px-6">
<div className="max-w-7xl mx-auto">
{/* Release-day banner: Microsoft shipped Autopilot device association on 2026-08-25;
the validator is live in the same week — the pill exists to make that visible. */}
<a
href={DEVICE_ASSOCIATION_DOCS_URL}
data-track="hero_announcement"
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-2.5 mb-6 pl-1.5 pr-3.5 py-1.5 rounded-full border border-[var(--lp-accent-line)] bg-[var(--lp-accent-soft)] text-[13px] text-[var(--lp-ink)] hover:border-[var(--lp-accent)] transition-colors"
@@ -55,12 +56,14 @@ export function Hero() {
<div className="flex items-center gap-3">
<a
href="/get-started"
data-track="hero_get_started"
className="px-6 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all"
>
Get started
</a>
<a
href={DOCS_URL}
data-track="hero_docs"
target="_blank"
rel="noopener noreferrer"
className="px-6 py-3 rounded-lg border border-[var(--lp-line)] bg-[var(--lp-surface)] text-[var(--lp-ink)] font-semibold hover:border-[var(--lp-ink-faint)] transition-colors"
@@ -17,7 +17,7 @@ const STEPS = [
export function HowItWorks() {
return (
<section id="how-it-works" className="py-20 sm:py-24 px-6 scroll-mt-20 border-t border-[var(--lp-line-soft)] bg-[var(--lp-surface)]">
<section id="how-it-works" data-track-section="how_it_works" className="py-20 sm:py-24 px-6 scroll-mt-20 border-t border-[var(--lp-line-soft)] bg-[var(--lp-surface)]">
<div className="max-w-7xl mx-auto">
<Reveal className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">How it works</p>
@@ -1,8 +1,9 @@
import Link from "next/link";
import { McpTerminalDemo } from "./McpTerminalDemo";
export function JustAsk() {
return (
<section className="py-16 sm:py-20 px-6">
<section data-track-section="just_ask" className="py-16 sm:py-20 px-6">
<div className="max-w-7xl mx-auto grid lg:grid-cols-[1fr_1.7fr] gap-6 lg:gap-10 items-center">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-accent-ink)]">
@@ -15,6 +16,17 @@ export function JustAsk() {
Your AI assistant reads the whole session for you — and finds the root cause a human
would dig for all afternoon. This analysis is real.
</p>
<Link
href="/ai"
prefetch={false}
data-track="just_ask_ai"
className="mt-5 inline-flex items-center gap-1.5 text-[15px] font-semibold text-[var(--lp-accent-ink)] hover:opacity-80 transition-opacity"
>
See what else you can ask
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 12h14M13 5l7 7-7 7" />
</svg>
</Link>
</div>
<div className="min-w-0">
<McpTerminalDemo />
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { getPortalLoginUrl, shouldCrossOriginToPortal } from "../../lib/hostRouting";
@@ -12,15 +13,22 @@ import { GITHUB_REPO_URL } from "@/utils/githubStars";
import { useWhatsNew } from "@/hooks/useWhatsNew";
// Root-anchored (/#…) so the links also work from subpages
// like /get-started, /about, /terms, /privacy.
// like /get-started, /about, /terms, /privacy. `track` names the link in the
// marketing click events: nav_<track> in the bar, menu_<track> in the burger menu.
const NAV_LINKS = [
{ href: "/#story", label: "Product" },
{ href: "/#features", label: "Capabilities" },
{ href: "/#comparison", label: "Compare" },
{ href: "/plans", label: "Plans" },
{ href: DOCS_URL, label: "Docs", external: true },
{ href: "/#story", label: "Product", track: "product" },
{ href: "/#features", label: "Capabilities", track: "capabilities" },
{ href: "/#comparison", label: "Compare", track: "compare" },
{ href: "/ai/", label: "AI", track: "ai" },
{ href: "/plans", label: "Plans", track: "plans" },
{ href: DOCS_URL, label: "Docs", track: "docs", external: true },
];
/** With trailingSlash: true a page shows as /foo/ after client navigation and /foo on a hard load. */
function withoutTrailingSlash(path: string): string {
return path.length > 1 ? path.replace(/\/+$/, "") : path;
}
function BellIcon({ className }: { className?: string }) {
// Ringing bell: the public site announces "there is news" without claiming a count —
// anonymous visitors have no seen mark, so a counter would read 9+ for everyone.
@@ -43,6 +51,10 @@ export function LandingNavbar() {
const { login, isAuthenticated } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const whatsNew = useWhatsNew();
const pathname = withoutTrailingSlash(usePathname() ?? "");
// A route link (not an anchor, not external) whose page is the current one.
const isCurrent = (link: (typeof NAV_LINKS)[number]) =>
!link.external && !link.href.includes("#") && withoutTrailingSlash(link.href) === pathname;
useEffect(() => {
if (!menuOpen) return;
@@ -73,10 +85,12 @@ export function LandingNavbar() {
// see components/NavLink.tsx.)
return (
<nav className="sticky top-0 z-40 bg-[var(--lp-nav)] backdrop-blur-xl border-b border-[var(--lp-line-soft)]">
{/* Measured widths: the full desktop bar (links, star link, bell, sign-in, CTA) needs
~1000px, so it starts at lg; below that the burger menu carries links and sign-in. */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center gap-4 lg:gap-8">
<Link href="/" prefetch={false} className="flex items-center gap-2.5 shrink-0">
{/* Measured widths (2026-10-01, six links, four-digit star count): the full desktop bar
needs ~1070px at its xl spacing. It starts at lg, and between lg and xl it tightens the
gaps, the link padding and the star label so it still fits at 1024px; below lg the burger
menu carries links and sign-in. */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center gap-4 lg:gap-6 xl:gap-8">
<Link href="/" prefetch={false} data-track="nav_logo" className="flex items-center gap-2.5 shrink-0">
<BrandMark className="w-6 h-6" />
{/* Wordmark needs ~390px alongside GitHub mark + CTA + burger; mark alone below that */}
<span className="hidden min-[390px]:block text-[15px] font-bold tracking-tight text-[var(--lp-ink)] whitespace-nowrap">
@@ -85,27 +99,25 @@ export function LandingNavbar() {
</Link>
<div className="hidden lg:flex items-center gap-1">
{NAV_LINKS.map(link =>
link.external ? (
{NAV_LINKS.map(link => {
const current = isCurrent(link);
return (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 text-sm font-medium rounded-lg text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
aria-current={current ? "page" : undefined}
data-track={`nav_${link.track}`}
className={`px-2.5 xl:px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
current
? "text-[var(--lp-ink)] bg-[var(--lp-surface-2)]"
: "text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)]"
}`}
>
{link.label}
</a>
) : (
<a
key={link.label}
href={link.href}
className="px-3 py-2 text-sm font-medium rounded-lg text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
>
{link.label}
</a>
)
)}
);
})}
</div>
<div className="ml-auto flex items-center gap-1 sm:gap-2 shrink-0">
@@ -114,6 +126,7 @@ export function LandingNavbar() {
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
data-track="nav_github"
className="sm:hidden p-1.5 rounded-lg text-[var(--lp-ink-faint)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
title="GitHub"
aria-label="GitHub"
@@ -121,7 +134,7 @@ export function LandingNavbar() {
<GitHubIcon className="w-4 h-4" />
</a>
<div className="hidden sm:flex">
<GitHubStarLink label="Star" />
<GitHubStarLink label="Star" track="nav_star" labelClassName="lg:max-xl:hidden" />
</div>
<button
type="button"
@@ -134,6 +147,7 @@ export function LandingNavbar() {
</button>
<button
onClick={handleSignIn}
data-track="nav_sign_in"
className="hidden lg:block px-3 py-2 text-sm font-semibold text-[var(--lp-ink)] hover:text-[var(--lp-accent-ink)] transition-colors"
>
Sign in
@@ -141,12 +155,14 @@ export function LandingNavbar() {
<Link
href="/get-started"
prefetch={false}
data-track="nav_get_started"
className="px-4 py-2 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-md text-white text-sm font-semibold shadow-sm transition-all whitespace-nowrap"
>
Get started
</Link>
<button
onClick={() => setMenuOpen(open => !open)}
data-track={menuOpen ? "nav_menu_close" : "nav_menu_open"}
className="lg:hidden p-2 -mr-2 rounded-lg text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
aria-expanded={menuOpen}
aria-controls="landing-mobile-menu"
@@ -179,22 +195,32 @@ export function LandingNavbar() {
className="lg:hidden absolute top-full left-0 right-0 bg-[var(--lp-surface)] border-b border-[var(--lp-line-soft)] shadow-lg"
>
<div className="px-6 py-4 flex flex-col gap-1">
{NAV_LINKS.map(link => (
<a
key={link.label}
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
onClick={() => setMenuOpen(false)}
className="px-3 py-2.5 text-[15px] font-medium rounded-lg text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
>
{link.label}
</a>
))}
{NAV_LINKS.map(link => {
const current = isCurrent(link);
return (
<a
key={link.label}
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
onClick={() => setMenuOpen(false)}
aria-current={current ? "page" : undefined}
data-track={`menu_${link.track}`}
className={`px-3 py-2.5 text-[15px] font-medium rounded-lg transition-colors ${
current
? "text-[var(--lp-ink)] bg-[var(--lp-surface-2)]"
: "text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)]"
}`}
>
{link.label}
</a>
);
})}
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMenuOpen(false)}
data-track="menu_github"
className="flex items-center gap-2.5 px-3 py-2.5 text-[15px] font-medium rounded-lg text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
>
<GitHubIcon className="w-4 h-4" />
@@ -211,6 +237,7 @@ export function LandingNavbar() {
<div className="h-px bg-[var(--lp-line-soft)] my-2" />
<button
onClick={handleSignIn}
data-track="menu_sign_in"
className="w-full px-4 py-2.5 rounded-lg border border-[var(--lp-line)] text-sm font-semibold text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
>
Sign in
@@ -9,6 +9,7 @@ export function LoginButton({
children,
signup = false,
disabled = false,
track,
}: {
className?: string;
children: React.ReactNode;
@@ -21,6 +22,8 @@ export function LoginButton({
* (dual app-reg window; localStorage is per-origin, hence the ?authapp handover to portal).
*/
signup?: boolean;
/** data-track id for the anonymous marketing click count (components/MarketingTracker.tsx). */
track?: string;
}) {
const { login } = useAuth();
@@ -46,7 +49,7 @@ export function LoginButton({
};
return (
<button type="button" onClick={handleClick} disabled={disabled} className={className}>
<button type="button" onClick={handleClick} disabled={disabled} data-track={track} className={className}>
{children}
</button>
);
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useInViewActive } from "@/hooks/useInViewActive";
import { useMediaQuery } from "@/hooks/useMediaQuery";
type Step =
@@ -17,7 +18,7 @@ type Step =
*/
const SCRIPT: Step[] = [
{ kind: "user", text: "CONTOSO-3812 failed enrollment this morning — what happened?" },
{ kind: "tool", text: 'search_sessions({ device: "CONTOSO-3812" })' },
{ kind: "tool", text: 'search_sessions({ deviceName: "CONTOSO-3812" })' },
{ kind: "tool", text: 'get_session_summary({ sessionId: "e6a1…f43b" })' },
{ kind: "tool", text: 'get_session_summary({ sessionId: "98db…16af" })' },
{ kind: "gap" },
@@ -46,40 +47,16 @@ const START_THRESHOLD = 0.3;
export function McpTerminalDemo() {
const ref = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(false);
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
const [stepIndex, setStepIndex] = useState(0);
const [charCount, setCharCount] = useState(0);
// Reduced motion shows the finished transcript immediately — derived, not seeded.
const effectiveStepIndex = reduced ? SCRIPT.length : stepIndex;
// The typing chain below is a setTimeout loop (every 32 ms while typing) that
// would otherwise run forever once started. It starts when 30% of the terminal
// is in view and then runs only while the terminal is on screen in a visible
// tab: scrolled away or tab hidden = no timers; back = resume at the current step.
useEffect(() => {
if (reduced) return;
const el = ref.current;
if (!el) return;
let started = false;
let inView = false;
const update = () => setActive(started && inView && !document.hidden);
const observer = new IntersectionObserver(
entries => {
const latest = entries[entries.length - 1];
inView = latest.isIntersecting;
if (latest.intersectionRatio >= START_THRESHOLD) started = true;
update();
},
{ threshold: [0, START_THRESHOLD] }
);
observer.observe(el);
document.addEventListener("visibilitychange", update);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", update);
};
}, [reduced]);
const active = useInViewActive(ref, { startThreshold: START_THRESHOLD, disabled: reduced });
const [stepIndex, setStepIndex] = useState(0);
const [charCount, setCharCount] = useState(0);
// Reduced motion shows the finished transcript immediately — derived, not seeded.
const effectiveStepIndex = reduced ? SCRIPT.length : stepIndex;
useEffect(() => {
if (!active || reduced) return;
@@ -135,11 +112,11 @@ export function McpTerminalDemo() {
case "out": {
const color =
step.accent === "success"
? "text-[#55c57f]"
? "text-[var(--lp-term-ok)]"
: step.accent === "warn"
? "text-[#f5a623]"
? "text-[var(--lp-term-warn)]"
: step.accent === "high"
? "text-[#ff9d66]"
? "text-[var(--lp-term-high)]"
: step.accent === "dim"
? "text-[var(--lp-term-faint)]"
: "text-[var(--lp-term-ink)]";
@@ -15,7 +15,7 @@ export function SignupConsentCta({ children }: { children?: React.ReactNode }) {
const [accepted, setAccepted] = useState(false);
return (
<div className="mt-14">
<div data-track-section="signup" className="mt-14">
<label className="flex items-start gap-3 max-w-xl text-sm text-[var(--lp-ink-soft)] leading-relaxed cursor-pointer select-none">
<input
type="checkbox"
@@ -25,12 +25,13 @@ export function SignupConsentCta({ children }: { children?: React.ReactNode }) {
/>
<span>
I agree to the{" "}
<a href="/terms" target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
<a href="/terms" target="_blank" rel="noopener noreferrer" data-track="signup_terms" className={LINK_CLASS}>
Terms of Use
</a>{" "}
and the{" "}
<a
href={`${DOCS_URL}/legal/data-privacy-agreement-dpa`}
data-track="signup_dpa"
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
@@ -44,6 +45,7 @@ export function SignupConsentCta({ children }: { children?: React.ReactNode }) {
<div className="mt-5 flex flex-wrap items-center gap-3">
<LoginButton
signup
track="signup_sign_in"
disabled={!accepted}
className="px-7 py-3 rounded-lg bg-[var(--lp-accent-ink)] text-white font-semibold shadow-md transition-all enabled:hover:brightness-110 enabled:hover:shadow-lg disabled:opacity-40 disabled:shadow-none disabled:cursor-not-allowed"
>
@@ -145,7 +145,7 @@ export function StatsBand() {
}
return (
<section className="border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)]">
<section data-track-section="stats" className="border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)]">
<div className="max-w-7xl mx-auto px-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-y-6 py-8 lg:divide-x lg:divide-[var(--lp-line)]">
{(stats ?? Array.from({ length: 5 }, () => null)).map((item, i) =>
item ? (
@@ -144,7 +144,7 @@ export function Story() {
];
return (
<section id="story" className="py-20 sm:py-28 px-6 scroll-mt-20">
<section id="story" data-track-section="story" className="py-20 sm:py-28 px-6 scroll-mt-20">
<div className="max-w-7xl mx-auto">
<Reveal className="max-w-2xl mx-auto text-center">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">The story</p>
@@ -8,7 +8,7 @@ import { Reveal } from "./Reveal";
*/
export function Testimonials() {
return (
<section className="py-20 sm:py-24 px-6 border-t border-[var(--lp-line-soft)] bg-[var(--lp-surface)]">
<section data-track-section="testimonials" className="py-20 sm:py-24 px-6 border-t border-[var(--lp-line-soft)] bg-[var(--lp-surface)]">
<div className="max-w-7xl mx-auto grid lg:grid-cols-12 gap-10 lg:gap-6">
<Reveal className="lg:col-span-4">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">From the field</p>
@@ -48,7 +48,7 @@ export function Testimonials() {
<div className="mt-8 flex flex-wrap items-center gap-x-4 gap-y-3">
<p className="text-[15px] text-[var(--lp-ink-soft)]">Seeing the same in your tenant? Leave a star.</p>
<GitHubStarLink label="Star on GitHub" />
<GitHubStarLink label="Star on GitHub" track="testimonials_star" />
</div>
</Reveal>
</div>
@@ -0,0 +1,19 @@
import { useCallback, useState } from "react";
/**
* Clipboard copy with a short-lived "copied" marker. `copied` holds the key of the last value
* copied for two seconds, so one hook serves several copy buttons on the same screen.
*/
export function useCopy() {
const [copied, setCopied] = useState<string | null>(null);
const copy = useCallback(async (value: string, key: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
} catch {
setCopied(null);
}
}, []);
return { copied, copy };
}
@@ -0,0 +1,40 @@
import { useEffect, useState, type RefObject } from "react";
/**
* True while the element is on screen in a visible tab, once it has been at least
* `startThreshold` visible. Looping animations key their timers on it, so they start when the
* reader reaches them and stop while scrolled away or in a background tab, then resume.
* `disabled` (reduced motion) keeps it false.
*/
export function useInViewActive(
ref: RefObject<Element | null>,
{ startThreshold = 0.3, disabled = false }: { startThreshold?: number; disabled?: boolean } = {}
): boolean {
const [active, setActive] = useState(false);
useEffect(() => {
if (disabled) return;
const el = ref.current;
if (!el) return;
let started = false;
let inView = false;
const update = () => setActive(started && inView && !document.hidden);
const observer = new IntersectionObserver(
entries => {
const latest = entries[entries.length - 1];
inView = latest.isIntersecting;
if (latest.intersectionRatio >= startThreshold) started = true;
update();
},
{ threshold: [0, startThreshold] }
);
observer.observe(el);
document.addEventListener("visibilitychange", update);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", update);
};
}, [ref, startThreshold, disabled]);
return active;
}
@@ -136,6 +136,8 @@ describe("hostRouting public/portal guard", () => {
}
// Prefix matching: subpaths and generated asset variants match…
expect(isPublicPath("/about")).toBe(true);
expect(isPublicPath("/ai")).toBe(true);
expect(isPublicPath("/ai/")).toBe(true);
expect(isPublicPath("/help")).toBe(true);
expect(isPublicPath("/plans")).toBe(true);
expect(isPublicPath("/buy")).toBe(true);
@@ -0,0 +1,27 @@
/**
* Anonymous usage counts on the public marketing pages: clicks on elements marked
* data-track="<id>" and sections marked data-track-section="<id>" (components/MarketingTracker.tsx).
* Counts per button and section, never per person (D-304, D-305).
*/
export const MARKETING_CTA_CLICKED = "marketing_cta_clicked";
export const MARKETING_SECTION_VIEWED = "marketing_section_viewed";
/** Shape of data-track ids: lower-case words, `_` inside a word group, `:` before a value. */
export const TRACK_ID_PATTERN = /^[a-z0-9]+(?:[_-][a-z0-9]+)*(?::[a-z0-9]+(?:[_-][a-z0-9]+)*)?$/;
interface TrackableTarget {
closest(selector: string): { getAttribute(name: string): string | null } | null;
}
/** The data-track id of the clicked element or its nearest marked ancestor, else null. */
export function trackIdOf(target: TrackableTarget | null): string | null {
return target?.closest("[data-track]")?.getAttribute("data-track") || null;
}
/**
* A section counts as seen once a third of it is on screen, or, for a section taller than the
* viewport, once it fills half of the viewport.
*/
export function isSectionSeen(ratio: number, visibleHeight: number, viewportHeight: number): boolean {
return ratio >= 1 / 3 || (viewportHeight > 0 && visibleHeight >= viewportHeight / 2);
}
@@ -22,6 +22,7 @@ import { PORTAL_URL, SITE_URL } from "@/utils/config";
*/
export const PUBLIC_PATH_PREFIXES = [
"/about",
"/ai",
"/buy",
"/changelog",
"/docs",
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
/**
* Shared link-preview fields for public pages. A page that sets its own `openGraph` or `twitter`
* replaces the root layout's object instead of merging with it, so every such page spreads these
* in. Without them its previews lose the image, type and site name, and the Twitter card falls
* back to "summary".
*/
const OG_IMAGE = { url: "/opengraph-image.png", width: 1200, height: 630, alt: "Autopilot Monitor" };
export const OPEN_GRAPH_BASE = {
type: "website",
locale: "en_US",
siteName: "Autopilot Monitor",
images: [OG_IMAGE],
} satisfies NonNullable<Metadata["openGraph"]>;
export const TWITTER_BASE = {
card: "summary_large_image",
images: [OG_IMAGE.url],
} satisfies NonNullable<Metadata["twitter"]>;
@@ -8,6 +8,7 @@ Autopilot Monitor is for IT administrators, helpdesk engineers, and managed serv
- [Home](https://www.autopilotmonitor.com/): product overview
- [About](https://www.autopilotmonitor.com/about/): what it is, how it works, common questions
- [AI analysis](https://www.autopilotmonitor.com/ai/): ask about your enrollments in your own AI assistant through the MCP server
- [Get started](https://www.autopilotmonitor.com/get-started/): first steps for a new tenant
- [Plans](https://www.autopilotmonitor.com/plans/): Community vs. Pro
- [Get Pro](https://www.autopilotmonitor.com/buy/): purchase options and the free 30-day trial
@@ -20,6 +20,7 @@ const OUTPUT_FILE = path.join(WEB_ROOT, "utils/page-lastmod.generated.ts");
const PAGE_MAP = {
"/": ["app/page.tsx"],
"/about": ["app/about/page.tsx", "app/about/faq.ts"],
"/ai": ["app/ai/page.tsx", "components/ai/questions.ts"],
"/get-started": ["app/get-started/page.tsx"],
"/plans": ["app/plans/page.tsx", "components/plans/planData.ts"],
"/buy": ["app/buy/page.tsx"],
@@ -5,6 +5,7 @@
export const PAGE_LASTMOD: Record<string, string> = {
"/": "2026-07-30T22:21:18+02:00",
"/about": "2026-09-03T00:41:06+02:00",
"/ai": "2026-10-01T18:06:41+02:00",
"/get-started": "2026-08-22T19:46:44+02:00",
"/plans": "2026-08-22T19:46:44+02:00",
"/buy": "2026-08-31T18:15:28+02:00",