diff --git a/docs/otel.md b/docs/otel.md index e79141532..44f5dd04b 100644 --- a/docs/otel.md +++ b/docs/otel.md @@ -864,7 +864,17 @@ wrapper. Attributes use the `lorekit.*` namespace. | `install_command.copied` | A visitor copied a shell command | `lorekit.install_command.id`, `lorekit.install_command.surface`, `lorekit.install_command.succeeded` | | `ui.button_click` | A `Button`/`IconButton` carrying an `analyticsId` was clicked | `lorekit.ui.button_id`, `lorekit.ui.button_variant`, `lorekit.ui.button_size` (both optional) | -### Content (`/docs`, `/blog`) +### Content (`/blog`, and the home page `/`) + +Emitted by `components/content/ReadingTelemetry.tsx` (the pure model is +`lib/analytics/reading.ts`). The home page mounts the same component with +`lorekit.content.type=landing` and `lorekit.content.slug=home`, measured against +the hero-to-closing-card wrapper, with the section heading ids from +`components/landing/sections.ts` (`hero-title`, `live-demo`, `learning-loop`, +`how-it-works`, `what-you-get`, `where-lore-lives`, `limitations`, +`get-started`). Blog and home page therefore share one schema: split them with a +group-by on `lorekit.content.type`. `docs` and `learn` exist in the type but are +not mounted yet. | Event | Emitted when | Attributes | |-------|--------------|------------| @@ -872,6 +882,22 @@ wrapper. Attributes use the `lorekit.*` namespace. | `content.section_read` | A reader left a section after dwelling in it | `lorekit.content.type`, `lorekit.content.slug`, `lorekit.content.section.id`, `.section.dwell_ms`, `.section.dwell_bucket`, `.section.index` (omitted when the heading is not in the TOC) | | `content.read` | End-of-page-view reading summary (one per view) | `lorekit.content.type`, `lorekit.content.slug`, `.max_depth_percent`, `.engaged_ms`, `.engaged_bucket`, `.sections_read`, `.completed`, `.top_section.id` (optional) | +### Home page (`landing.*`) + +| Event | Emitted when | Attributes | +|-------|--------------|------------| +| `landing.cta_clicked` | A link on the home page was followed (click or middle-click), via `components/landing/LandingLink.tsx` | `lorekit.landing.cta` (`get-token`/`offline-guide`/`sign-in`/`docs`/`blog`/`github`), `lorekit.landing.placement` (`header`/`hero`/`get-started`) | +| `landing.demo_tab_selected` | A visitor picked a use case in the live demo (the auto-playing first tab is not counted) | `lorekit.landing.demo_tab` (`self-healing`/`promotion`/`pr-review`) | + +`cta` is the visitor's INTENT, not the link text: the hero asks "hosted or +offline?" right under the install command, so `get-token` vs `offline-guide` +from `placement=hero` is the page's direct read on which path people want — read +it next to `install_command.copied` (`landing-hero` / `landing-get-started`). +The header is shared with `/login`; filter on `page.url.path` to keep only the +home page. The home-page funnel is therefore: page view → `content.scroll_depth` +milestones → `content.section_read` per section → `install_command.copied` / +`landing.cta_clicked`. + ### Lore Explorer (`lore.*`) Everything a reader touches on `/lore` is their own content — a scope, a label, diff --git a/packages/web/src/app/(auth)/login/page.tsx b/packages/web/src/app/(auth)/login/page.tsx index 967d59178..715b03c81 100644 --- a/packages/web/src/app/(auth)/login/page.tsx +++ b/packages/web/src/app/(auth)/login/page.tsx @@ -1,237 +1,75 @@ import { Suspense } from 'react'; import type { Metadata } from 'next'; -import Image from 'next/image'; import Link from 'next/link'; -import { LoginButton } from '@/components/auth/LoginButton'; + import { AuthHashCatcher } from '@/components/auth/AuthHashCatcher'; +import { LoginButton } from '@/components/auth/LoginButton'; +import { LandingBackdrop } from '@/components/landing/LandingBackdrop'; +import { LandingHeader } from '@/components/landing/LandingHeader'; import { SiteFooter } from '@/components/layout/SiteFooter'; -import { BookOpen, Brain, GitBranch, Zap } from 'lucide-react'; -import { TerminalTheater } from '@/components/landing/TerminalTheater'; -import { CopyCommand } from '@/components/landing/CopyCommand'; -import { GetStartedButton } from '@/components/learn/GetStartedButton'; export const metadata: Metadata = { title: 'Sign in', - description: 'Shared, persistent memory for AI coding agents.', + description: 'Sign in to LoreKit — shared, persistent memory for AI coding agents.', }; -const FEATURES = [ - { - icon: Brain, - title: 'Agents that remember', - description: - 'LoreKit gives your AI coding agents a shared memory store. Memories written in one session are available in the next — across repos, branches, and tools.', - }, - { - icon: GitBranch, - title: 'Scope-aware memory', - description: - 'Memories are namespaced by scope: global, project, repo, or branch. Agents read the narrowest scope first and fall back automatically.', - }, - { - icon: Zap, - title: 'Wired to your workflow', - description: - 'Connect via MCP in one config line. A GitHub webhook turns resolved PR review comments into candidate memories automatically.', - }, - { - icon: BookOpen, - title: 'Browse and curate', - description: - 'The dashboard lets you explore every memory your agents have written, archive stale entries, and watch activity in real time.', - }, -] as const; - -// Docs and Blog share one style and one breakpoint: hidden below `sm` so the -// mobile header keeps room for the GitHub icon and the Sign in button. -const HEADER_TEXT_LINK_CLASS = - 'hidden items-center rounded-lg px-3 py-2 text-sm font-medium text-[var(--color-content-secondary)] transition-colors duration-200 hover:text-[var(--color-content-primary)] focus-visible:outline-2 focus-visible:outline-[var(--color-accent)] sm:inline-flex'; - +/** + * The sign-in page, and only that. + * + * It used to carry the whole marketing site as well, because `/` redirected + * here — so the product's pitch lived on an auth route, and a visitor met a + * sign-in form before an explanation. The pitch now lives at `/`; what remains + * here is the one job the URL names, plus a way back for anyone who arrived + * without knowing what they are signing in to. + */ export default function LoginPage() { return (
{/* Renders nothing on an ordinary visit. Only fires when Supabase bounced - an implicit-flow auth result to the Site URL (i.e. here) instead of the - callback route — see AuthHashCatcher. + an implicit-flow auth result to the Site URL instead of the callback + route — see AuthHashCatcher. */} -
-
-
-
-
+ -
-
- LoreKit - LoreKit -
-
- - Docs - - - Blog - - - - - - + -
-
+ } + /> - {/* Hero */} -
- {/* Trust bar */} -
- - - Free - - - - - - Open source · MIT - - - Works with Claude Code, Cursor & Codex - -
- - {/* Main headline */} -

- Persistent memory -
- for your AI agents +
+

+ Sign in to LoreKit

-

- Your AI agent forgets everything when a session ends. LoreKit gives it persistent memory — - so it never asks the same question twice. +

+ An account connects the same memory store to every machine, teammate, and CI + run. The CLI itself works offline without one.

- {/* Primary CTA */}
-

- Sign in free · 2-minute setup -

-
-
- - {/* Narrative sections */} -
- - - {/* Get started section — original concise card + link to full guide */} -
-

- Get started in 2 minutes -

-

- Free · Open source +

+ Free — 5,000 memories, no card

-
    -
  1. - 1 -
    -

    Sign in and get your token

    -

    GitHub, email + password, or a magic link — takes 30 seconds

    -
    -
  2. -
  3. - 2 -
    -

    Run one command

    - -
    -
  4. -
  5. - 3 -
    -

    Your agent now remembers

    -

    Memories persist across sessions, repos, and tools — automatically

    -
    -
  6. -
- {/* Explore docs & guides — links to the public /docs index */} -
-
- {/* Feature cards */} -
- {FEATURES.map(({ icon: Icon, title, description }) => ( -
+ New here?{' '} + -
- -
-
-

- {title} -

-

- {description} -

-
-
- ))} + See what LoreKit does + +

diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index e7fbd196c..bb91ef157 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -16,6 +16,7 @@ --color-content-tertiary: #4f5668; --color-accent: #f5a623; /* amber — the LoreKit signature colour */ + --color-accent-bright: #ffd27a; /* the light end of the amber — gradient highlights only, never text on its own */ --color-accent-subtle: #2a2010; /* dark amber tint for backgrounds */ --color-accent-glow: #f5a62340; /* glow rings */ @@ -109,6 +110,140 @@ body:has([data-app-shell]) { border-radius: 4px; } +/* ── Landing motion ──────────────────────────────────────────────────────── */ +/* Two entrances for the public pages, both animating `opacity` + `transform` + only, so they run on the compositor and never cost a layout or paint frame. + + `lk-rise` is the hero's own choreography: it runs once, off the clock, + staggered by `--lk-delay` so the eye lands on the headline and the supporting + lines catch up (the delay between steps is ~30–70 % of the duration, past + which a stagger stops reading as one gesture and starts reading as a queue). + + `lk-reveal` is the same shape driven by SCROLL POSITION instead of time, via + a `view()` timeline — no IntersectionObserver, no client component, nothing + to hydrate for what is decoration on a static marketing page. + + The load-bearing detail in both is that THE RESTING STATE IS THE FINISHED + STATE: the offset lives inside the keyframe's `from`, never on the element's + base class. The usual `opacity: 0` + observer pattern leaves the page blank + whenever the reveal never runs, which is exactly what happens in a browser + without `animation-timeline` (Safari and Firefox at the time of writing) and + for a visitor who asked for reduced motion. Here those visitors get the + content, just without the motion. + + `animation-range` deliberately ends at `cover 15%` rather than `entry 100%`: + a section taller than the viewport never completes its `entry` range, so an + entry-scoped reveal would strand it permanently half-faded. */ +@media (prefers-reduced-motion: no-preference) { + .lk-rise { + animation: lk-rise-in 360ms var(--ease-out-smooth) both; + animation-delay: var(--lk-delay, 0ms); + } + + @supports (animation-timeline: view()) { + .lk-reveal { + animation: lk-rise-in linear both; + animation-timeline: view(); + animation-range: cover 0% cover 15%; + } + } +} + +@keyframes lk-rise-in { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* `lk-tilt` lays the product window back and lifts it flat as it scrolls into + view — the demo arrives like a screen being raised to face you. Same contract + as above: transform only, and the resting state is FLAT, so Safari/Firefox + and reduced-motion visitors get an ordinary upright window. + + The range ends at `cover 35%`, well before the window is centred, so by the + time anyone is reading the terminal it has stopped moving and its text + renders crisp rather than through a 3D transform. */ +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: view()) { + .lk-tilt { + transform-origin: 50% 0%; + animation: lk-tilt-in linear both; + animation-timeline: view(); + animation-range: entry 0% cover 35%; + } + } +} + +@keyframes lk-tilt-in { + from { transform: perspective(1400px) rotateX(18deg) scale(0.92); } + to { transform: none; } +} + +/* `lk-step` is `lk-reveal` staggered BY SCROLL: each item's range starts 5 % + later than the one before (`--lk-step` = its index), so a row of cards that + enters the viewport together still rises one after another. A stagger by + `animation-delay` does nothing on a scroll timeline, which has no clock. + + `lk-draw` draws a rule from its left end (scaleX 0 → 1), finishing as the + cards it connects finish rising. Both keep the contract: transform/opacity + only, and fully drawn / fully risen at rest. */ +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: view()) { + .lk-step { + animation: lk-rise-in linear both; + animation-timeline: view(); + animation-range: cover calc(var(--lk-step, 0) * 5%) cover calc(15% + var(--lk-step, 0) * 5%); + } + + .lk-draw { + transform-origin: 0 50%; + animation: lk-draw-in linear both; + animation-timeline: view(); + animation-range: cover 5% cover 35%; + } + } +} + +@keyframes lk-draw-in { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} + +/* ── Landing signature ───────────────────────────────────────────────────── */ +/* The public pages repeat exactly two moves, and nothing else decorates: + + 1. `lk-glow-frame` — an amber hairline that is brightest at one corner and + fades out, plus the brand's amber glow. Reserved for the surfaces that + carry an ACTION or the product itself (the hero command, the demo window, + the closing call to action, the one highlighted card per section), so the + eye learns that amber edge = "this is the thing to look at". + 2. `lk-gradient-text` — the amber ramp on the single phrase per heading that + is the payoff. Never on body copy: it is a highlight, not a colour. + + The frame is a two-layer background (padding-box fill over a border-box + gradient) rather than a pseudo-element, so it needs no extra DOM and never + sits on top of the content. Set `--lk-frame-fill` to change the fill. */ +.lk-glow-frame { + border: 1px solid transparent; + background: + linear-gradient(var(--lk-frame-fill, var(--color-bg-raised)), var(--lk-frame-fill, var(--color-bg-raised))) padding-box, + linear-gradient(135deg, var(--color-accent) 0%, var(--color-border) 35%, var(--color-border) 70%, var(--color-accent-glow) 100%) border-box; + box-shadow: 0 0 0 1px rgb(0 0 0 / 0.4), 0 24px 80px -24px var(--color-accent-glow); +} + +/* Text fallback first: a browser without `background-clip: text` shows solid + amber, never transparent text. */ +.lk-gradient-text { + color: var(--color-accent); +} +@supports ((-webkit-background-clip: text) or (background-clip: text)) { + .lk-gradient-text { + background-image: linear-gradient(180deg, var(--color-accent-bright) 0%, var(--color-accent) 70%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } +} + /* ── Reduced motion ──────────────────────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index e2d347855..8987f2de8 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,9 +1,31 @@ +import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; -import { getVerifiedUser } from '@/lib/auth/verified-user'; + +import { AuthHashCatcher } from '@/components/auth/AuthHashCatcher'; +import { ReadingTelemetry } from '@/components/content/ReadingTelemetry'; +import { FeatureBento } from '@/components/landing/FeatureBento'; +import { GetStartedCard } from '@/components/landing/GetStartedCard'; +import { HowItWorks } from '@/components/landing/HowItWorks'; +import { LandingBackdrop } from '@/components/landing/LandingBackdrop'; +import { LandingHeader } from '@/components/landing/LandingHeader'; +import { LandingHero } from '@/components/landing/LandingHero'; +import { LearningLoop } from '@/components/landing/LearningLoop'; +import { Limitations } from '@/components/landing/Limitations'; +import { LANDING_SECTION_ORDER } from '@/components/landing/sections'; +import { TerminalTheater } from '@/components/landing/TerminalTheater'; +import { WhereLoreLives } from '@/components/landing/WhereLoreLives'; +import { SiteFooter } from '@/components/layout/SiteFooter'; import { classifyAuthCallback } from '@/lib/auth-callback-params'; +import { getVerifiedUser } from '@/lib/auth/verified-user'; type SearchParams = Record; +export const metadata: Metadata = { + title: 'Shared memory for your coding agents', + description: + 'LoreKit gives your AI coding agents a shared, persistent memory. One command to connect — hosted across every machine, teammate, and CI run, or fully offline with no account.', +}; + function toURLSearchParams(input: SearchParams): URLSearchParams { const params = new URLSearchParams(); for (const [key, value] of Object.entries(input)) { @@ -13,6 +35,19 @@ function toURLSearchParams(input: SearchParams): URLSearchParams { return params; } +/** + * The public home page. + * + * It used to be a bare `redirect('/login')`, which made the front door an auth + * route titled "Sign in": a visitor who did not already know what LoreKit was + * met a form before an explanation, and the product's strongest verifiable + * claims — one command, works with no account, MIT, self-hostable — sat below + * the fold of a page they had no reason to scroll. `/login` now does the one + * job its URL names, and this page does the other. + * + * A signed-in visitor still never sees it: they are sent to `/lore`, the app's + * home slot, exactly as before. + */ export default async function RootPage({ searchParams, }: { @@ -23,11 +58,15 @@ export default async function RootPage({ // Supabase falls back to the project's **Site URL** when an email link's // `redirect_to` is not on the allow-list — which lands the user here, // mid-authentication, instead of on /api/auth/callback. Forward the auth - // params on rather than dropping them at /login. + // params on rather than dropping them on the marketing page. // // Only the query-string shapes can be rescued server-side; an implicit-flow - // `#access_token=…` fragment never reaches the server and is picked up by - // AuthHashCatcher after this redirect lands on /login. + // `#access_token=…` fragment never reaches the server and is picked up by the + // AuthHashCatcher mounted below. It is mounted here IN ADDITION to /login, not + // moved from it: this page used to bounce every visitor to /login, so the + // /login catcher received the fragments that land on the Site URL. With the + // bounce gone those fragments stay here, while a fragment that lands on + // /login directly still needs the one there. if (classifyAuthCallback(params).kind !== 'none') { if (!params.has('next')) params.set('next', '/welcome'); redirect(`/api/auth/callback?${params.toString()}`); @@ -41,5 +80,54 @@ export default async function RootPage({ // no setup instructions. See Sidebar.tsx's nav and lore/page.tsx. redirect('/lore'); } - redirect('/login'); + + // Reading order is an argument, not a feature list: + // promise (hero) → proof (the live demo, peeking above the fold as the + // hero's product shot) → why it is not just a rules file (the learning + // loop) → how → what you get → where your data sits → what it won't do → + // the same action again. + // The demo sits directly under the hero because a visitor decides within the + // first screen whether to keep reading, and "session B already knows" lands + // faster as a picture than as any sentence above it. + // + // `overflow-x-clip`, not `overflow-hidden`: hidden makes
a scroll + // container, and the sticky header would stick to a box that never scrolls. + // + // Reading telemetry is the blog's (`ReadingTelemetry`, `contentType: + // 'landing'`), measured against the `data-reading-content` wrapper — hero to + // closing card, header and footer excluded — so 100 % means "reached the + // closing call to action", not "scrolled past the footer links". The selector + // is explicit because the default (`article`) would match the first card on + // the page. The wrapper is a real box, not `display: contents`: an element + // with no box measures zero high, which `readPercent` reports as 0 %. + // `LANDING_SECTION_ORDER` must list the sections in the order rendered below. + return ( +
+ + + + + +
+ + +
+ + + + + + + +
+
+ + +
+ ); } diff --git a/packages/web/src/components/auth/LoginButton.tsx b/packages/web/src/components/auth/LoginButton.tsx index a915c0522..1eb964adc 100644 --- a/packages/web/src/components/auth/LoginButton.tsx +++ b/packages/web/src/components/auth/LoginButton.tsx @@ -17,21 +17,9 @@ import { import { validatePassword } from '@/lib/password-policy'; import { authCallbackOrigin, buildAuthCallbackUrl } from '@/lib/auth-callback-url'; import { safeNextPath } from '@/lib/auth-redirect'; -import { FIELD_CLASS } from './field-styles'; +import { GitHubMark } from '@/components/ui/GitHubMark'; -function GitHubIcon({ className }: { className?: string }) { - return ( - - - - ); -} +import { FIELD_CLASS } from './field-styles'; function MailIcon({ className }: { className?: string }) { return ( @@ -306,7 +294,7 @@ export function LoginButton({ compact = false }: LoginButtonProps) { className="flex h-9 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-elevated)] px-3.5 text-sm font-medium text-[var(--color-content-primary)] transition-all duration-200 hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-subtle)] hover:text-[var(--color-accent)] focus-visible:outline-2 focus-visible:outline-[var(--color-accent)] disabled:cursor-not-allowed disabled:opacity-50" aria-busy={loading} > - + {loading ? 'Redirecting...' : 'Sign in'}

@@ -528,7 +516,7 @@ export function LoginButton({ compact = false }: LoginButtonProps) { className="group relative flex h-12 min-w-[220px] items-center justify-center gap-2.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-elevated)] px-6 text-sm font-semibold text-[var(--color-content-primary)] shadow-[0_0_0_1px_transparent] transition-all duration-200 hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-subtle)] hover:text-[var(--color-accent)] hover:shadow-[0_0_20px_var(--color-accent-glow)] focus-visible:outline-2 focus-visible:outline-[var(--color-accent)] disabled:cursor-not-allowed disabled:opacity-50" aria-busy={loading} > - + {loading ? 'Redirecting...' : 'Continue with GitHub'} diff --git a/packages/web/src/components/landing/CopyCommand.tsx b/packages/web/src/components/landing/CopyCommand.tsx index b9cb78b72..b8ea39c96 100644 --- a/packages/web/src/components/landing/CopyCommand.tsx +++ b/packages/web/src/components/landing/CopyCommand.tsx @@ -4,9 +4,11 @@ import { useState } from 'react'; import { Check, Copy } from 'lucide-react'; import { track, type CopySurface, type InstallCommandId } from '@/lib/analytics/track'; +import { cn } from '@/lib/cn'; /** - * A single-line, copyable terminal command shown on the public landing page's + * A single-line, copyable terminal command shown on the public landing page — + * in the hero, where it is the primary call to action, and again in the * "Get started" card. Mirrors the copy affordance of the dashboard's * `CopyableCode` (ClientConfigTabs) but sized for one inline command. * @@ -27,14 +29,25 @@ export function CopyCommand({ command, commandId, surface, + size = 'sm', }: { command: string; /** Bounded id for telemetry — see `lib/analytics/track.ts`. */ commandId: InstallCommandId; /** Where this affordance is rendered. */ surface: CopySurface; + /** + * `sm` is the inline form used inside a step list. `lg` is the hero form, + * where this affordance IS the page's primary call to action: it gets a + * 40px-tall copy control (WCAG 2.2 SC 2.5.8 asks for 24px — a primary CTA + * should clear that comfortably), body-sized command text, the amber + * `lk-glow-frame`, and a FILLED copy button — the one solid-amber control on + * the page, so nothing else competes with it for the first click. + */ + size?: 'sm' | 'lg'; }) { const [copied, setCopied] = useState(false); + const isLarge = size === 'lg'; function handleCopy() { // Start from a resolved promise so a SYNCHRONOUS failure becomes a @@ -60,18 +73,49 @@ export function CopyCommand({ } return ( -
- - {command} +
+ + {/* The prompt says "this goes in a terminal" at a glance. It sits + outside the copied string and is unselectable, so neither the + button nor a manual drag-select ever picks up a stray `$`. */} + + $ + + {command}
); diff --git a/packages/web/src/components/landing/FeatureBento.tsx b/packages/web/src/components/landing/FeatureBento.tsx new file mode 100644 index 000000000..2e2a8e692 --- /dev/null +++ b/packages/web/src/components/landing/FeatureBento.tsx @@ -0,0 +1,242 @@ +import type { ReactNode } from 'react'; +import { Archive, BarChart3, Eye, GitPullRequest } from 'lucide-react'; + +import { cn } from '@/lib/cn'; + +import { SectionHeading } from './SectionHeading'; +import { LANDING_SECTION_ID } from './sections'; + +/** + * "03 / what you get" — four capabilities, each with a small picture of the + * thing itself instead of an icon and a sentence. + * + * It replaces two things: the four generic feature cards the old page had + * (which restated the hero in longer words, and were rightly removed), and the + * standalone "Memory isn't free" panel, which is now the widest cell here. That + * section stays in the page's own voice — the honest version of the claim is + * the differentiating one — it just no longer needs a whole screen to itself. + * + * Every claim is the product's current behaviour, written to its real limits: + * - review comments become CANDIDATE lessons with a TTL, filtered for signal + * (`supabase/functions/mcp/webhook.ts`), not "durable lessons automatically"; + * - grooming previews first and archives, never hard-deletes, and an archived + * lesson comes back with `lorekit restore` (`packages/cli/src/commands.mjs`); + * - the cost figures are the read/opened ratio `/insights` already computes; + * - the Lore Explorer lists, filters, edits and archives every lesson. + * (Team sharing and tool coverage used to be a cell here too; "02 / how it + * works" now tells that story in full, so it is not repeated.) + * The visuals are illustrations of those behaviours, so they are `aria-hidden` + * and carry no numbers that could be mistaken for a measurement. + */ +export function FeatureBento() { + return ( +
+ + Memory that earns its place{' '} + in your context. + + } + lede="A store that learns from the work you already do, and tells you when a lesson has stopped paying for itself." + /> + +
+ } + title="Memory isn’t free, so we measure it" + body="Every lesson delivered into a session costs tokens whether the agent uses it or not. LoreKit tracks how often each lesson is actually chosen versus merely delivered, estimates what your store costs you a month, and shows you the lore that is earning nothing." + visual={} + /> + } + title="Learns from your code reviews" + body="Point a GitHub webhook at a repo and review comments become candidate lessons. Noise is filtered out, and a candidate expires unless it proves useful." + visual={} + /> + } + title="Every lesson in plain sight" + body="Browse, search and filter everything your agents have learned in the dashboard — who wrote it, where it applies — and edit or archive it in place. Local lore is plain markdown you can grep." + visual={} + /> + } + title="Groomed, never hard-deleted" + body="Retention policies find the lore nobody has opened in months and archive it — previewed first, then reviewed by you or swept nightly. Anything archived comes back with one command." + visual={} + /> +
+
+ ); +} + +function BentoCell({ + title, + body, + icon, + visual, + highlighted = false, + className, +}: { + title: string; + body: string; + icon: ReactNode; + visual: ReactNode; + /** The one cell per grid that gets the amber frame — see `lk-glow-frame`. */ + highlighted?: boolean; + className?: string; +}) { + return ( +
+
+ {visual} +
+
+

+ + {icon} + + {title} +

+

{body}

+
+
+ ); +} + +// ─── Visuals ──────────────────────────────────────────────────────────────── +// Static, presentational, and drawn from the same tokens and mono voice as the +// product UI. Widths are proportions for the picture, not data. + +const MONO_PANEL = + 'rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)]/80 p-4 font-mono text-[11px] leading-relaxed'; + +function UtilityVisual() { + const rows = [ + { key: 'fix/ssr-window', chosen: 'w-[78%]', idle: false }, + { key: 'style/exports', chosen: 'w-[46%]', idle: false }, + { key: 'ci/node-16-cache', chosen: 'w-[3%]', idle: true }, + ]; + + return ( +
+
+ lesson utility + + + delivered + + + chosen + + +
+
    + {rows.map((row) => ( +
  • +
    + {row.key} + {row.idle && ( + + earning nothing + + )} +
    +
    +
    +
    +
  • + ))} +
+
+ ); +} + +function ReviewVisual() { + return ( +
+
+

+ @reviewer on PR #42 +

+

+ Use the custom logger here, not console.log. +

+
+
↓ webhook
+
+ review/logging + + candidate · expires + +
+
+ ); +} + +/** A slice of the Lore Explorer list: scope badge, key, and the agent that wrote it. */ +function ExplorerVisual() { + const rows = [ + { scope: 'repo', color: 'var(--color-scope-repo)', key: 'ci/pg-ready', agent: 'claude' }, + { scope: 'branch', color: 'var(--color-scope-branch)', key: 'fix/ssr-window', agent: 'cursor' }, + // Webhook lessons land under the delivering repo's scope, never global. + { scope: 'repo', color: 'var(--color-scope-repo)', key: 'review/logging', agent: 'pr-webhook' }, + ]; + + return ( +
+
+ ⌕ + search lore… +
+
    + {rows.map((row) => ( +
  • + + {row.scope} + + {row.key} + + {row.agent} + +
  • + ))} +
+
+ ); +} + +function GroomVisual() { + return ( +
+

+ $ + lorekit groom --scope repo::acme/api --unseen-days 90 +

+

3 lessons would be archived:

+
    +
  • repo::acme/api::ci/node-16-cache
  • +
  • repo::acme/api::fix/old-webpack-alias
  • +
  • repo::acme/api::style/legacy-tabs
  • +
+

Re-run with --run to archive them.

+
+ ); +} diff --git a/packages/web/src/components/landing/GetStartedCard.tsx b/packages/web/src/components/landing/GetStartedCard.tsx new file mode 100644 index 000000000..7bf26fa95 --- /dev/null +++ b/packages/web/src/components/landing/GetStartedCard.tsx @@ -0,0 +1,131 @@ +import { CopyCommand } from './CopyCommand'; +import { LandingLink } from './LandingLink'; +import { StepBadge } from './StepBadge'; +import { LANDING_SECTION_ID } from './sections'; + +const INLINE_LINK_CLASS = + 'rounded font-medium text-[var(--color-accent)] underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-[var(--color-accent)]'; + +/** + * The closing call to action on the home page — the page's last screen, and so + * the one a visitor leaves with. It repeats the hero's command at full size + * (and the same filled copy button) rather than a smaller echo of it: by this + * point the reader has the whole argument, and the action should be exactly as + * easy to take here as it was at the top. + * + * Step 1 is deliberately the command and not the sign-in: it is the first step + * of both the hosted and the offline path. Step 2 says what the installer will + * ask and what each answer means, because a blank token does NOT by itself + * give an offline setup (see `NEXT_STEPS` in `LandingHero.tsx`); the old step 2 + * — "your agent starts remembering" — promised a result one command does not + * produce. + * + * The copy surface is `landing-get-started`, kept apart from the hero's + * `landing-hero` so "did the command alone convert" and "did the whole page + * convert" stay separable in the data. Its links report `landing.cta_clicked` + * with `placement: 'get-started'` for the same reason. + */ +export function GetStartedCard() { + return ( +
+ {/* A warm pool of light at the top edge — the page's last moment of + colour, behind the final heading. Decorative and static. */} +
+ +
+

+ get started +

+

+ Give your agents a memory. +

+

+ Free · Open source · No card, because there is nothing to charge for yet +

+ +
    +
  1. + +
    +

    + Run one command +

    + +
    +
  2. +
  3. + +
    +

    + Paste a token, or go offline +

    +

    + + Sign in + {' '} + for a free token to share lessons across machines, teammates and CI. Or skip + it and{' '} + + switch to local mode + {' '} + — lessons stay on this machine as markdown you can read and commit. +

    +
    +
  4. +
  5. + +
    +

    + Check it’s wired +

    +

    + + npx @lorekit/cli doctor + {' '} + confirms the connection, the token and the scopes. With hooks on, every + session after starts with the lessons that apply to it. +

    +
    +
  6. +
+ +
+ + Explore docs & guides + → + +
+
+
+ ); +} diff --git a/packages/web/src/components/landing/HowItWorks.tsx b/packages/web/src/components/landing/HowItWorks.tsx new file mode 100644 index 000000000..861550e14 --- /dev/null +++ b/packages/web/src/components/landing/HowItWorks.tsx @@ -0,0 +1,219 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/cn'; + +import { SectionHeading } from './SectionHeading'; +import { StepBadge } from './StepBadge'; +import { LANDING_SECTION_ID } from './sections'; + +type Step = { + title: string; + body: string; + /** What the step looks like in practice — an illustration, `aria-hidden`. */ + visual: ReactNode; +}; + +/** + * "02 / how it works" — from one laptop to the whole team. + * + * It used to retell the loop "01" already tells (session A writes → store → + * session B reads), one section apart. The loop is the WHY; this section is + * the HOW, and the how a visitor actually needs is the part "01" assumes: how + * an agent gets connected, how a lesson stays with the repo it is about, and + * how the teammate on day 4 came to have it at all. Every claim is current: + * + * - `install` wires skills, the MCP server and the four Claude Code hooks + * (`CLAUDE_HOOK_EVENTS` in `packages/cli/src/shared/config.mjs`) and reports + * the scope it read from the git remote. Cursor is three copied files + * (`plugins/lorekit-cursor/README.md`), so the copy says so. + * - Scopes resolve narrow → broad: branch, repo, project, global + * (`docs/scope-format.md`). + * - Team sharing is org-first: an admin binds a scope (or `repo::acme/*`) and + * writes under it route to one org-owned copy every member reads and writes; + * anything unbound stays personal (`/docs/organization`). CI reads with a + * read-only `lk_ro_` token. + */ +const STEPS: Step[] = [ + { + title: 'Connect each agent', + body: 'One command wires the MCP server, the skills and the hooks into Claude Code, and picks up your repo’s scope from its git remote. Cursor is three copied files.', + visual: , + }, + { + title: 'Lessons stay with their repo', + body: 'Every lesson lands under a scope: a branch, a repo, a project, or everywhere. Agents read narrowest first, so one repo’s lore never crowds another’s.', + visual: , + }, + { + title: 'One shared set for the team', + body: 'Bind repo::acme/* to your org and every teammate’s agent reads and writes the same lessons — one copy, updated for everyone at once. CI reads with a read-only token; anything unbound stays personal.', + visual: , + }, +]; + +export function HowItWorks() { + return ( +
+ + Wire it once.{' '} + The whole team learns. + + } + lede="The teammate on day 4 got the lesson because of three things: every agent is connected, every lesson knows which repo it belongs to, and the team shares one store." + /> + +
    + {STEPS.map((step, index) => ( +
  1. + {/* One fixed height for all three pictures, so the step text below + them starts on the same line in every column. */} +
    + {step.visual} +
    +
    + +
    +

    + {step.title} +

    +

    + {step.body} +

    +
    +
    +
  2. + ))} +
+
+ ); +} + +// ─── Visuals ──────────────────────────────────────────────────────────────── + +const PANEL = + 'flex h-full flex-col rounded-2xl p-4 font-mono text-[11px] leading-relaxed'; + +function PanelLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** Mirrors the shape of `install`'s own status lines (skill / mcp / hooks / token / scope). */ +function InstallVisual() { + const rows = [ + { mark: '✓', label: 'skills', value: 'lorekit-memory +2' }, + { mark: '✓', label: 'mcp', value: 'lorekit → .mcp.json' }, + { mark: '✓', label: 'hooks', value: 'all · 4 lifecycle events' }, + { mark: '✓', label: 'token', value: 'read+write (lk_rw_*)' }, + { mark: '·', label: 'scope', value: 'repo::acme/api' }, + ]; + + return ( +
+ your machine · once +

+ $ + npx @lorekit/cli install +

+
    + {rows.map((row) => ( +
  • + + {row.mark} + + {row.label} + + {row.value} + +
  • + ))} +
+
+ ); +} + +/** + * Narrow → broad, drawn as widening bars in the product's own scope colours. + * The narrowest bar is 76 %, not less: at the three-column width (~280px of + * panel) that is the least that still fits `branch::acme/api::feat/x` and its + * order label without truncating the one string the picture is about. + */ +function ScopeVisual() { + const scopes = [ + { scope: 'branch::acme/api::feat/x', order: '1st', color: 'var(--color-scope-branch)', width: 'w-[76%]' }, + { scope: 'repo::acme/api', order: '2nd', color: 'var(--color-scope-repo)', width: 'w-[84%]' }, + { scope: 'project::acme', order: '3rd', color: 'var(--color-scope-project)', width: 'w-[92%]' }, + { scope: 'global', order: 'last', color: 'var(--color-scope-global)', width: 'w-full' }, + ]; + + return ( +
+ read order · narrow → broad +
    + {scopes.map((row) => ( +
  • + + {row.scope} + + {row.order} +
  • + ))} +
+
+ ); +} + +/** Three writers and readers, one org-owned copy. The store gets the amber frame. */ +function TeamVisual() { + const members = [ + { who: 'you · claude code', access: 'read+write' }, + { who: 'teammate · cursor', access: 'read+write' }, + { who: 'ci · lk_ro_ token', access: 'read-only' }, + ]; + + return ( +
+ org · acme +
    + {members.map((member) => ( +
  • + {member.who} + + {member.access} + +
  • + ))} +
+ ↓ ↑ +
+ repo::acme/* + + one shared copy + +
+
+ ); +} diff --git a/packages/web/src/components/landing/LandingBackdrop.tsx b/packages/web/src/components/landing/LandingBackdrop.tsx new file mode 100644 index 000000000..4d376a8e3 --- /dev/null +++ b/packages/web/src/components/landing/LandingBackdrop.tsx @@ -0,0 +1,50 @@ +/** + * The amber aurora + grid wash behind the public pages. + * + * Purely decorative (`aria-hidden`, `pointer-events-none`) and shared by `/` + * and `/login` so the two never drift into two different-looking front doors. + * Static by design: a moving gradient behind a whole page is the one background + * effect that reliably costs paint frames on a low-end laptop, and it competes + * with the entrance motion the content itself carries. `fixed` keeps it on its + * own composited layer, so scrolling never repaints it. + * + * Colours come from the theme tokens — the amber is the brand accent and the + * violet is the `global` scope colour — so a palette change reaches the + * backdrop instead of leaving two hard-coded hexes behind. + * + * The grid is masked to fade out from the top centre: an even grid across the + * whole viewport reads as graph paper, while one that dissolves behind the + * headline reads as light falling on it. + */ +export function LandingBackdrop() { + return ( +
+ {/* Top beam — the brightest point sits just above the headline. */} +
+
+
+
+
+ ); +} diff --git a/packages/web/src/components/landing/LandingHeader.tsx b/packages/web/src/components/landing/LandingHeader.tsx new file mode 100644 index 000000000..89f71e15a --- /dev/null +++ b/packages/web/src/components/landing/LandingHeader.tsx @@ -0,0 +1,87 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; + +import { GitHubMark } from '@/components/ui/GitHubMark'; + +import { LandingLink } from './LandingLink'; + +// Docs and Blog share one style and one breakpoint: hidden below `sm` so the +// mobile header keeps room for the GitHub icon and the Sign in control. +const HEADER_TEXT_LINK_CLASS = + 'hidden items-center rounded-lg px-3 py-2 text-sm font-medium text-[var(--color-content-secondary)] transition-colors duration-200 hover:text-[var(--color-content-primary)] focus-visible:outline-2 focus-visible:outline-[var(--color-accent)] sm:inline-flex'; + +/** + * The public site header, shared by the marketing home page (`/`) and the sign-in + * page (`/login`). + * + * It takes the sign-in control as a slot rather than rendering one itself, + * because the two pages need different things there and the difference is + * behavioural, not cosmetic: on `/` the control is a plain link to the sign-in + * page (a header button that fires GitHub OAuth on click does something the + * label "Sign in" does not say), while `/login` passes the compact + * `LoginButton`, which is that OAuth trigger and is correct once the visitor + * has already chosen to sign in. + * + * Every outbound link reports `landing.cta_clicked` with `placement: 'header'`. + * The header renders on `/login` too, so those clicks carry that page's path — + * filter on `page.url.path` when only the home page is wanted. The logo is not + * tracked: it links to the page the visitor is already on. + * + * Sticky with a translucent, blurred fill, so Docs and Sign in stay one click + * away on a long page without the bar ever hiding the content scrolling under + * it. Sticky only works because the pages' `
` clips with `overflow-x-clip` + * rather than `overflow-hidden` — the latter makes `
` a scroll container + * and the header would stick to a box that never scrolls. + */ +export function LandingHeader({ action }: { action?: ReactNode }) { + return ( +
+
+ + + LoreKit + + + +
+
+ ); +} diff --git a/packages/web/src/components/landing/LandingHero.tsx b/packages/web/src/components/landing/LandingHero.tsx new file mode 100644 index 000000000..d18a7d4d1 --- /dev/null +++ b/packages/web/src/components/landing/LandingHero.tsx @@ -0,0 +1,182 @@ +import { ArrowRight, Check } from 'lucide-react'; + +import { GitHubMark } from '@/components/ui/GitHubMark'; +import type { LandingCta } from '@/lib/analytics/track'; + +import { CopyCommand } from './CopyCommand'; +import { LandingLink } from './LandingLink'; +import { LANDING_SECTION_ID } from './sections'; + +/** Each claim is checkable: the plan limit is the free-plan memory cap + * (`docs/limits.md`), the tool list names the integration level rather than + * implying every MCP host is equally supported, and all three stores exist + * today (see "Where your lore lives" further down). */ +const TRUST_POINTS = [ + 'Free · 5,000 memories', + 'Claude Code · Cursor · any MCP client', + 'Local, hosted, or self-hosted', +] as const; + +/** + * What happens after the command, stated as the two real paths. + * + * `install` prompts for a token. With one, the agent talks to the hosted store. + * Left blank, it STILL writes the hosted MCP entry (`packages/cli/src/commands/ + * install.mjs`) — offline needs the local MCP entry plus `"mode": "local"` in + * `.lorekit.json` (`/docs/offline`, steps 3 and 6). The page used to say "works + * offline with no account" directly under the command, which a visitor reads + * as "this one command gives me an offline setup" — and then meets an agent + * pointed at a server it has no token for. Naming both paths, at equal weight, + * costs one line and removes that first-run failure. + * + * Each path reports its own `landing.cta_clicked` intent, so the ratio of + * `get-token` to `offline-guide` from `placement: 'hero'` is the direct answer + * to "which path do visitors want?" — read it next to `install_command.copied`. + */ +const NEXT_STEPS: readonly { + label: string; + body: string; + href: string; + linkText: string; + cta: LandingCta; +}[] = [ + { + label: 'Hosted', + body: 'Sign in for a free token and paste it when the installer asks.', + href: '/login', + linkText: 'Get a token', + cta: 'get-token', + }, + { + label: 'Offline', + body: 'No account: skip the token, then switch the store to local mode.', + href: '/docs/offline', + linkText: 'Offline guide', + cta: 'offline-guide', + }, +]; + +/** + * The home page's first screen. + * + * Its primary call to action is the install command, not the sign-in button, + * and that ordering is the point: the command is step one of BOTH ways to use + * LoreKit, and one of them needs no account at all, so asking a first-time + * visitor to authenticate before anything else would be a wall in front of a + * door that is already open. Sign-in is offered as the next step for the thing + * an account actually buys — sharing lore across machines and teammates. + * + * The headline leads with the problem the visitor already has ("your agents + * forget") and resolves it with the differentiator rather than with storage: + * "remembers" is what a CLAUDE.md line also does, while "learn" names the loop + * a rules file cannot run — lessons the agent writes itself on friction, handed + * back when the same error recurs, promoted when they keep recurring, archived + * when nobody uses them. The lede says exactly those four things, each of + * which is a shipped mechanic (see `LearningLoop.tsx`). + * + * The trust points sit UNDER the command rather than above the headline: they + * answer "is this safe to try?", which is a question asked at the moment of the + * click, not before the visitor knows what the product is. Above the headline + * they competed with it for the first look. + * + * Lines are staggered (`--lk-delay`) so the eye lands on the headline and the + * supporting lines catch up; see `lk-rise` in `globals.css`. + */ +export function LandingHero() { + return ( +
+ + + + MIT + + Open source — read every line on GitHub + + + +

+ Your coding agents forget. + {/* `pb-1` keeps the descenders inside the clipped gradient box. */} + LoreKit makes them learn. +

+ +

+ When your agent hits a wall and works its way out, it writes down what it + learned — and the next agent to hit that wall gets the lesson with the error, on + any machine, in any tool. What keeps recurring becomes a rule. What nobody uses + gets archived. +

+ +
+
+ +
+ +
    + {NEXT_STEPS.map((step) => ( +
  • + + {step.label} + + {step.body} + + {step.linkText} + + +
  • + ))} +
+
+ +
    + {TRUST_POINTS.map((point) => ( +
  • + + {point} +
  • + ))} +
+
+ ); +} diff --git a/packages/web/src/components/landing/LandingLink.tsx b/packages/web/src/components/landing/LandingLink.tsx new file mode 100644 index 000000000..cf6791e40 --- /dev/null +++ b/packages/web/src/components/landing/LandingLink.tsx @@ -0,0 +1,71 @@ +'use client'; + +import Link from 'next/link'; +import type { MouseEvent, ReactNode } from 'react'; + +import { track, type LandingCta, type LandingPlacement } from '@/lib/analytics/track'; + +/** + * A home-page link that reports `landing.cta_clicked` when it is followed. + * + * Every link on the page goes through this one component, because the + * question the data has to answer is comparative — hosted or offline? header + * or hero? — and a comparison with one side untracked reads as a preference. + * + * `cta` and `placement` are closed unions (see `lib/analytics/track.ts`), so a + * new link costs a type error until someone decides what intent it represents, + * rather than arriving with a free-text label as its id. + * + * External hrefs open in a new tab, so the current page — and the SDK batch + * carrying this event — stays alive. Internal hrefs are client-side `next/link` + * navigations, which never unload the document either. A middle-click opens a + * tab without firing `click`, so it is counted from `auxclick`. + */ +export function LandingLink({ + href, + cta, + placement, + className, + children, + ariaLabel, +}: { + href: string; + cta: LandingCta; + placement: LandingPlacement; + className?: string; + children: ReactNode; + ariaLabel?: string; +}) { + const report = () => track({ name: 'landing.cta_clicked', cta, placement }); + const onAuxClick = (event: MouseEvent) => { + if (event.button === 1) report(); + }; + + if (/^https?:\/\//.test(href)) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} diff --git a/packages/web/src/components/landing/LearningLoop.tsx b/packages/web/src/components/landing/LearningLoop.tsx new file mode 100644 index 000000000..94b19fb7b --- /dev/null +++ b/packages/web/src/components/landing/LearningLoop.tsx @@ -0,0 +1,345 @@ +import type { CSSProperties } from 'react'; +import { Archive, Lightbulb, TrendingUp, Zap, type LucideIcon } from 'lucide-react'; + +import { cn } from '@/lib/cn'; + +import { SectionHeading } from './SectionHeading'; +import { LANDING_SECTION_ID } from './sections'; + +type LineKind = 'command' | 'error' | 'agent' | 'lore' | 'added' | 'muted'; + +type Stage = { + verb: string; + when: string; + icon: LucideIcon; + lines: { kind: LineKind; text: string }[]; + /** The number the story is about: how much the same problem still costs. */ + stat: string; + statCaption: string; + outcome: string; +}; + +/** + * One lesson's month, in four beats. Each beat is a mechanic LoreKit has today: + * + * 1. LEARNS — the hooks nudge the agent to write a lesson when a session hit + * friction (errored tool calls, a stuck loop); the agent writes it, not you. + * 2. RECALLS — on a failing tool call the hook searches the store with the + * tool name and error text and injects up to three matching lessons before + * the agent retries (`packages/cli/src/core`, Claude Code's adapter). + * 3. PROMOTES — a lesson re-written in later sessions accrues `seen_count`; + * session start ranks the most-recurring first, and the `lorekit-setup` loop + * hardens recurring lessons into permanent rules (Insights labels them + * "load-bearing — promote it to a rule"). + * 4. PRUNES — a retention policy archives lore nobody has opened in N days, + * swept nightly or reviewed by you; `lorekit restore` brings it back. + * + * The scenario and its numbers are a story, labelled as one in the UI — not a + * measurement. What is literal is the mechanism in each beat. + */ +const STAGES: Stage[] = [ + { + verb: 'Learns', + when: 'day 1 · your laptop', + icon: Lightbulb, + lines: [ + { kind: 'command', text: 'pnpm test:integration' }, + { kind: 'error', text: '✗ ECONNREFUSED :5432 ×3' }, + { kind: 'agent', text: 'db not ready yet' }, + { kind: 'added', text: 'memory.write ci/pg-ready' }, + ], + stat: '4', + statCaption: 'tries', + outcome: 'Fixed on the fourth try — and written down by the agent. Nobody asked it to.', + }, + { + verb: 'Recalls', + when: 'day 4 · a teammate’s laptop', + icon: Zap, + lines: [ + { kind: 'command', text: 'pnpm test:integration' }, + { kind: 'error', text: '✗ ECONNREFUSED :5432' }, + { kind: 'lore', text: 'ci/pg-ready' }, + { kind: 'agent', text: 'waits on pg_isready' }, + ], + stat: '1', + statCaption: 'try', + outcome: 'The lesson arrived with the error, on a machine that had never seen it.', + }, + { + verb: 'Promotes', + when: 'day 9 · CI', + icon: TrendingUp, + lines: [ + { kind: 'muted', text: 'ci/pg-ready · seen 3×' }, + { kind: 'agent', text: 'recurring: promote' }, + { kind: 'added', text: 'rule: wait on pg_isready' }, + ], + stat: '0', + statCaption: 'tries', + outcome: 'Three hits in three places earned it a permanent rule. Now it’s applied before the run.', + }, + { + verb: 'Prunes', + when: 'day 90 · nightly sweep', + icon: Archive, + lines: [ + { kind: 'muted', text: 'policy · unopened ≥ 90 days' }, + { kind: 'muted', text: 'build/webpack-alias · 112d' }, + { kind: 'added', text: 'archived — restorable' }, + ], + stat: '−1', + statCaption: 'from context', + outcome: 'Lore nobody opens stops riding along in every session. Archived, never deleted.', + }, +]; + +const COMPARISON = [ + { + aspect: 'Written by', + rulesFile: 'You, when you remember to.', + lore: 'Your agents, the moment they hit friction.', + }, + { + aspect: 'Knows', + rulesFile: 'What you predicted would matter.', + lore: 'What actually broke — and the fix that worked.', + }, + { + aspect: 'Shows up', + rulesFile: 'All of it, every session.', + lore: 'What matches: at session start, on each prompt, on the failing call.', + }, + { + aspect: 'Over time', + rulesFile: 'Grows until someone prunes it.', + lore: 'Promotes what recurs. Archives what nobody uses.', + }, +] as const; + +/** + * "01 / why not just CLAUDE.md?" — the page's differentiator, stated as the + * objection a developer actually has. + * + * The section it replaces showed a convention ("Zod, not Yup") being written + * once and read back. True, but it is exactly what a rules file already does, + * so it argued for a CLAUDE.md line rather than for LoreKit. What a rules file + * CANNOT do is the loop: learn something nobody knew to write down, hand it to + * the next agent at the moment the same error recurs, notice it recurring and + * promote it, and retire what stopped earning its place. That is the story the + * timeline tells, and the comparison underneath states it plainly for whoever + * skipped the picture. + * + * Positioned as complementary, not a replacement: promotion ends in a rule, so + * LoreKit is how a team finds out what deserves to be in its CLAUDE.md. + */ +export function LearningLoop() { + return ( +
+ + Your CLAUDE.md is frozen.{' '} + Your lore keeps learning. + + } + lede="A rules file holds what you knew to write down before anything broke. LoreKit captures what your agents find out the hard way — and keeps sorting it: what recurs becomes a rule, what nobody uses gets archived." + /> + +

+ a month in the life of one lesson +

+ + {/* Four across only from `xl`: below 1280px a quarter of the row leaves + the transcripts ~26 monospace characters, and every line is written + to fit 28. Two columns from `sm`, one below. */} +
    + {/* The rail through the four stage icons, drawn left to right as the + section scrolls in (`lk-draw`); fully drawn at rest. It stops at the + last icon's centre: 25 % of the row minus ¾ of the 16px gap and the + 18px icon radius. */} + + + + + {STAGES.map((stage, index) => ( + + ))} +
+ + {/* The loop closing: from the last beat back to the first. Wide screens + only — stacked, the cards already read top to bottom. The label sits + UNDER the line rather than on it: masking the line needs an opaque + box, and no flat colour matches the glow behind this section. */} +
+
+ + ▲ + +

+ and round again — every session, every agent +

+
+ + +
+ ); +} + +function StageCard({ stage, index }: { stage: Stage; index: number }) { + const Icon = stage.icon; + const isPromotion = stage.verb === 'Promotes'; + + return ( +
  • + {/* The icon has the rail's row to itself; the labels sit BELOW it. Side + by side, the rail (drawn at the icon's centre line) ran straight + through the gap between the "day" label and the verb. The icon is + opaque and above the rail, so the rail reads as running through it. */} +
    + + + +

    + {stage.when} +

    +

    {stage.verb}

    +
    + +
    +
    + + {stage.stat} + + {stage.statCaption} +
    + + {/* A fixed four-line height, so a three-line transcript doesn't leave + its outcome sitting higher than its neighbours'. */} +
      + {stage.lines.map((line) => ( + + ))} +
    + +

    + {stage.outcome} +

    +
    +
  • + ); +} + +function TranscriptLine({ kind, text }: { kind: LineKind; text: string }) { + switch (kind) { + case 'command': + return ( +
  • + $ + {text} +
  • + ); + case 'error': + return
  • {text}
  • ; + case 'agent': + return ( +
  • + agent › + {text} +
  • + ); + case 'lore': + return ( +
  • + ↳ lore › {text} +
  • + ); + case 'added': + return
  • + {text}
  • ; + case 'muted': + return
  • {text}
  • ; + } +} + +function ComparisonTable() { + return ( +
    +
    + + + + + + + + + + + {COMPARISON.map((row) => ( + + + + + + ))} + +
    A rules file compared with LoreKit
    + Aspect + + CLAUDE.md / AGENTS.md + + LoreKit +
    + {row.aspect} + + {row.rulesFile} + + {row.lore} +
    +
    + +

    + Keep your CLAUDE.md. LoreKit is how you find out what has earned a place in it. +

    +

    + Error-matched recall runs on Claude Code’s hooks; Cursor reads lessons through its + rule and an end-of-turn hook. +

    +
    + ); +} diff --git a/packages/web/src/components/landing/Limitations.tsx b/packages/web/src/components/landing/Limitations.tsx new file mode 100644 index 000000000..844facb94 --- /dev/null +++ b/packages/web/src/components/landing/Limitations.tsx @@ -0,0 +1,47 @@ +import { SectionHeading } from './SectionHeading'; +import { LANDING_SECTION_ID } from './sections'; + +const LIMITS = [ + 'It doesn’t read your codebase. It stores what your agent writes to it, and nothing else.', + 'It doesn’t make an agent smarter on its own. If nothing writes a lesson, there is nothing to read — which is why the plugins wire lifecycle hooks instead of leaving it to the agent’s discretion.', + 'It can surface a lesson you didn’t need. Scopes narrow what an agent sees, grooming prunes what has stopped earning its place, and every memory is visible to you in the dashboard.', + 'Codex support is experimental. Claude Code and Cursor are the supported paths; anything speaking MCP works too.', + 'There is no paid tier yet. If the free limits ever change, your lore exports to plain markdown files you own.', +] as const; + +/** + * The limits, in the product's own voice. + * + * This is the section a sceptical developer screenshots, and it is cheaper to + * write it ourselves than to have someone discover each line on day one. Every + * entry names something that is true right now — an overstated claim withdrawn + * later reads as a retraction, while a limit removed later reads as progress. + * + * It is deliberately the plainest section on the page: no amber frame, no + * gradient phrase. Dressing the limits up would read as spin. + */ +export function Limitations() { + return ( +
    + +
      + {LIMITS.map((limit) => ( +
    • + + — + + {limit} +
    • + ))} +
    +
    + ); +} diff --git a/packages/web/src/components/landing/SectionHeading.tsx b/packages/web/src/components/landing/SectionHeading.tsx new file mode 100644 index 000000000..76b75c844 --- /dev/null +++ b/packages/web/src/components/landing/SectionHeading.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/cn'; + +/** + * The heading block every home-page section opens with: a mono eyebrow, the + * section title, and an optional one-line lede. + * + * The eyebrow is the page's ornamental signature — `01 / the problem` in Fira + * Code amber, the same voice as the scope strings and tool calls the product + * itself prints — so the sections read as one document with a table of + * contents rather than a stack of unrelated cards. Numbering is passed in, not + * derived, because the page decides the reading order and a section must not + * renumber itself when it is reused elsewhere. + * + * The title runs at 30–36px against 16px body copy (≥ 2× contrast). The + * sections used to open with 20px headings over 14px text, which made every + * section look like a sidebar card rather than a chapter. + * + * A two-sentence title puts its gradient payoff on its own line (`block` on the + * payoff span) rather than trusting `text-balance`: balancing splits by width, + * so "Every session starts from zero. Yours don't have to." broke as "…starts + * from / zero. Yours…", stranding a word of the first sentence with the second. + */ +export function SectionHeading({ + id, + eyebrow, + title, + lede, + align = 'center', +}: { + /** Target of the section's `aria-labelledby`. */ + id: string; + eyebrow: string; + title: ReactNode; + lede?: ReactNode; + align?: 'center' | 'left'; +}) { + const centered = align === 'center'; + + return ( +
    +

    + {eyebrow} +

    +

    + {title} +

    + {lede && ( +

    + {lede} +

    + )} +
    + ); +} diff --git a/packages/web/src/components/landing/StepBadge.tsx b/packages/web/src/components/landing/StepBadge.tsx new file mode 100644 index 000000000..ff0f7a906 --- /dev/null +++ b/packages/web/src/components/landing/StepBadge.tsx @@ -0,0 +1,19 @@ +/** + * The numbered circle that marks a step, shared by "How it works" and the + * closing "Get started" list so the two step sequences on the page are visibly + * the same kind of thing. It used to be one class string copied into both + * files, which is how two badges drift a pixel apart. + * + * Decorative to assistive tech: both lists are `
      `, which already announces + * "1 of 3", so reading the digit again would say every number twice. + */ +export function StepBadge({ step }: { step: number }) { + return ( + + {step} + + ); +} diff --git a/packages/web/src/components/landing/TerminalTheater.tsx b/packages/web/src/components/landing/TerminalTheater.tsx index 278861689..32134b21a 100644 --- a/packages/web/src/components/landing/TerminalTheater.tsx +++ b/packages/web/src/components/landing/TerminalTheater.tsx @@ -4,9 +4,13 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { motion, AnimatePresence, useReducedMotion } from 'motion/react'; import { Cloud, HardDrive } from 'lucide-react'; +import { track, type LandingDemoTab } from '@/lib/analytics/track'; import { useAmbientAnimation } from '@/lib/hooks/useAmbientAnimation'; import { createPausableTimers, type PausableTimers } from '@/lib/pausable-timers'; +import { SectionHeading } from './SectionHeading'; +import { LANDING_SECTION_ID } from './sections'; + // ─── Types ──────────────────────────────────────────────────────────────────── /** Where a memory lives: the on-disk offline store or the hosted remote store. */ @@ -44,6 +48,8 @@ type ScriptLine = { }; type UseCase = { + /** Stable telemetry id — the label is copy and may change; this may not. */ + id: LandingDemoTab; label: string; act1: ScriptLine[]; act2: ScriptLine[]; @@ -53,6 +59,7 @@ type UseCase = { const USE_CASES: UseCase[] = [ { + id: 'self-healing', label: 'Self-healing', act1: [ { type: 'command', text: 'pnpm build', pauseAfter: 240 }, @@ -114,43 +121,59 @@ const USE_CASES: UseCase[] = [ ], }, { - label: 'Code conventions', + // A rules file can hold a convention someone already knew. What it cannot + // do is notice that the same failure keeps coming back and decide it has + // earned a rule — that is this tab. The recurrence count is what a + // re-written lesson accrues (`seen_count`); the promotion is the agent + // acting on it, as the `lorekit-setup` loop wires it to. + id: 'promotion', + label: 'Promotion', act1: [ + { type: 'command', text: 'pnpm test:e2e', pauseAfter: 240 }, { - type: 'command', - text: 'memory.write { scope: "repo::lorekit", key: "style/indent", value: "2 spaces" }', - pauseAfter: 60, - card: { id: 'indent', key: 'style/indent', value: '2 spaces', store: 'local' }, + type: 'error', + text: '✗ Timeout: #checkout-button not visible (CI only)', + pauseAfter: 320, }, - { type: 'success', text: '✓ Saved', pauseAfter: 320 }, { - type: 'command', - text: 'memory.write { scope: "repo::lorekit", key: "style/exports", value: "no default exports" }', - pauseAfter: 60, - card: { id: 'exports', key: 'style/exports', value: 'no default exports', store: 'local' }, + type: 'agent', + text: '> Agent: Same flake as Monday and Wednesday — CSS animations\n still run in CI. Turned them off in the e2e config.', + pauseAfter: 260, }, - { type: 'success', text: '✓ Saved', pauseAfter: 320 }, { type: 'command', - text: 'memory.write { scope: "repo::lorekit", key: "db/columns", value: "snake_case" }', + text: 'memory.write { scope: "repo::lorekit", key: "ci/e2e-animations", value: "disable CSS animations in e2e config" }', pauseAfter: 60, - card: { id: 'db', key: 'db/columns', value: 'snake_case', store: 'remote' }, + card: { + id: 'e2e', + key: 'ci/e2e-animations', + value: 'disable CSS animations in e2e config', + source: 'seen 3×', + store: 'remote', + }, }, - { type: 'success', text: '✓ Saved', pauseAfter: 800 }, + { type: 'success', text: '✓ Saved (seen 3× — recurring)', pauseAfter: 400 }, + { + type: 'agent', + text: '> Agent: Third time in three sessions. Promoting it to a\n permanent rule for the e2e suite.', + pauseAfter: 260, + }, + { type: 'success', text: '✓ Rule added: disable animations before e2e runs', pauseAfter: 800 }, ], act2: [ { type: 'separator', text: '— New session —', pauseAfter: 320 }, { type: 'command', text: 'memory.list { scope: "repo::lorekit" }', pauseAfter: 60 }, - { type: 'info', text: ' ↳ 3 memories loaded', pauseAfter: 400 }, + { type: 'info', text: ' ↳ 1 memory loaded (+1 rule)', pauseAfter: 400 }, { type: 'agent', - text: '> Agent: I see snake_case for DB columns and 2-space indentation.\n Continuing with those conventions.', + text: '> Agent: Rule on file: animations off in CI. Applying it\n before the run, not after the timeout.', pauseAfter: 300, }, - { type: 'success', text: '✓ No context re-establishment needed', pauseAfter: 2000 }, + { type: 'success', text: '✓ Green on the first run', pauseAfter: 2000 }, ], }, { + id: 'pr-review', label: 'PR review', act1: [ { @@ -252,11 +275,10 @@ function TypewriterLine({ // layout shift. With the loop restarting forever, the login page accumulated // thousands of CLS entries per visit (p75 0.21, worst 0.58). // - // The theater does NOT sit above the sign-in CTA: `(auth)/login/page.tsx` - // renders the primary `LoginButton` at L155 and `` at - // L165, so the shifting text is below it — only the header's compact sign-in - // button (L111) is above. The cost is the accrued shift entries themselves, - // which CLS sums for the whole session; it is not a CTA moving under a cursor. + // This matters more now that the theater is the home page's product shot, + // rendered directly under the hero: every section and the closing call to + // action sit BELOW it, so a line that grew mid-type would push live controls + // around under the visitor's cursor, on top of the CLS entries it books. // // So: a full, invisible copy of the text reserves the space, and the typed // prefix is painted over it. The overlay is out of flow, so the number of @@ -486,7 +508,8 @@ export function TerminalTheater() { await wait(line.text.length * CHAR_DELAY + 80); // Stagger light-up one card at a time as each memory "loads" - if (line.type === 'info' && line.text.includes('memories loaded')) { + // `memory` OR `memories`: a one-lesson act prints "1 memory loaded". + if (line.type === 'info' && /memor(?:y|ies) loaded/.test(line.text)) { const cardCount = useCase.act1.filter((l) => l.card).length; for (let i = 0; i < cardCount; i++) { const cardIndex = i; @@ -545,21 +568,25 @@ export function TerminalTheater() { return (
      - {/* Heading */} -
      -

      - Watch memories form and persist -

      -

      - Session A writes. Session B loads. No re-explaining. -

      -
      + + Session A writes.{' '} + Session B already knows. + + } + lede="Two sessions, one store, no re-explaining." + /> - {/* Terminal window */} -
      + {/* Terminal window — the page's product shot. `lk-tilt` lays it back and + lifts it flat as it scrolls in; `lk-glow-frame` is the amber edge the + page reserves for the product and its actions. */} +
      {/* Title bar */}
      @@ -578,7 +605,14 @@ export function TerminalTheater() { {USE_CASES.map((uc, i) => (
      {/* Caption */} -

      - Memories survive session ends · Shared across Claude Code, Cursor & Codex +

      + Memories survive session ends · Shared across Claude Code, Cursor & any MCP client

      ); diff --git a/packages/web/src/components/landing/WhereLoreLives.tsx b/packages/web/src/components/landing/WhereLoreLives.tsx new file mode 100644 index 000000000..3e0d4848a --- /dev/null +++ b/packages/web/src/components/landing/WhereLoreLives.tsx @@ -0,0 +1,103 @@ +import { Cloud, HardDrive, Server, type LucideIcon } from 'lucide-react'; + +import { cn } from '@/lib/cn'; + +import { SectionHeading } from './SectionHeading'; +import { LANDING_SECTION_ID } from './sections'; + +type Store = { + name: string; + summary: string; + body: string; + icon: LucideIcon; + /** The option that needs nothing from us — framed so it is never a footnote. */ + needsNoAccount?: boolean; +}; + +const STORES: Store[] = [ + { + name: 'Local', + summary: 'No account, no network.', + body: 'Markdown files under ~/.lorekit and /.lorekit — greppable, diffable, and yours. Commit them to share via git, or gitignore them to keep them private.', + icon: HardDrive, + needsNoAccount: true, + }, + { + name: 'Hosted', + summary: 'Shared across machines and teammates.', + body: 'Every memory is personal by default; org sharing is opt-in. The free plan holds 5,000 active memories at 120 requests a minute.', + icon: Cloud, + }, + { + name: 'Self-hosted', + summary: 'Your own infrastructure.', + body: 'The whole stack — MCP server, dashboard, database — deploys to your own Supabase and Vercel projects. MIT licensed.', + icon: Server, + }, +]; + +/** + * Where the data physically sits, stated before anyone has to ask. + * + * "Is this a service that stores my repository's internals?" is the first + * question a developer asks about a memory product and the last thing most + * landing pages answer. All three answers are true of LoreKit today, and the + * local one — which needs no account at all — is listed FIRST and carries the + * page's amber frame. Burying the option that needs nothing from us would be + * steering rather than informing. + */ +export function WhereLoreLives() { + return ( +
      + + Your lore, your call. + + } + lede="The same memory tools run against any of the three." + /> + +
        + {STORES.map(({ name, summary, body, icon: Icon, needsNoAccount }) => ( +
      • +
        + + + + {needsNoAccount && ( + + Fully offline + + )} +
        +

        {name}

        +

        {summary}

        +

        {body}

        +
      • + ))} +
      + +

      + Start local and move up later —{' '} + + lorekit migrate + {' '} + previews the whole plan before it changes anything. +

      +
      + ); +} diff --git a/packages/web/src/components/landing/sections.spec.ts b/packages/web/src/components/landing/sections.spec.ts new file mode 100644 index 000000000..a7bd6de9a --- /dev/null +++ b/packages/web/src/components/landing/sections.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { LANDING_SECTION_ID, LANDING_SECTION_ORDER } from './sections'; + +// The reading telemetry can only bill time to a section whose id is in the +// ORDER list, and it reports each section's position in that list. A section +// added to the id map but not to the order would be read on the page and +// invisible in the data — with no error anywhere — so that is what this pins. +describe('landing section ids', () => { + it('lists every section in the reading order exactly once', () => { + expect([...LANDING_SECTION_ORDER].sort()).toEqual(Object.values(LANDING_SECTION_ID).sort()); + expect(new Set(LANDING_SECTION_ORDER).size).toBe(LANDING_SECTION_ORDER.length); + }); + + it('starts with the hero and ends with the closing call to action', () => { + expect(LANDING_SECTION_ORDER[0]).toBe(LANDING_SECTION_ID.hero); + expect(LANDING_SECTION_ORDER[LANDING_SECTION_ORDER.length - 1]).toBe(LANDING_SECTION_ID.getStarted); + }); + + it('uses ids that are valid, stable DOM ids (lowercase kebab-case)', () => { + for (const id of LANDING_SECTION_ORDER) expect(id).toMatch(/^[a-z]+(-[a-z]+)*$/); + }); +}); diff --git a/packages/web/src/components/landing/sections.ts b/packages/web/src/components/landing/sections.ts new file mode 100644 index 000000000..585eeed92 --- /dev/null +++ b/packages/web/src/components/landing/sections.ts @@ -0,0 +1,44 @@ +/** + * The home page's section heading ids — the single source for both the + * headings themselves and the reading telemetry that bills time to them. + * + * `ReadingTelemetry` resolves "which section is the visitor in" by looking up + * these ids in the DOM. A heading renamed in its component but not in the list + * handed to telemetry would not error — it would simply stop appearing in + * `content.section_read`, and the dashboard would show that section as unread. + * Each section imports its id from here, so the two cannot drift. + * + * A plain module on purpose: `TerminalTheater` is a client component, and a + * value exported from a `'use client'` file reaches a server component as a + * client reference, not as the string. + */ +export const LANDING_SECTION_ID = { + hero: 'hero-title', + liveDemo: 'live-demo', + learningLoop: 'learning-loop', + howItWorks: 'how-it-works', + whatYouGet: 'what-you-get', + whereLoreLives: 'where-lore-lives', + limitations: 'limitations', + getStarted: 'get-started', +} as const; + +/** + * The same ids in DOCUMENT order — the order `app/page.tsx` renders them in. + * + * Order is load-bearing twice: the section resolver walks headings top to + * bottom and stops at the first one below the reading line, and the position + * in this list is what `content.section_read` reports as + * `lorekit.content.section.index` ("did anyone reach section 6?"). Reorder the + * page, reorder this list in the same change. + */ +export const LANDING_SECTION_ORDER = [ + LANDING_SECTION_ID.hero, + LANDING_SECTION_ID.liveDemo, + LANDING_SECTION_ID.learningLoop, + LANDING_SECTION_ID.howItWorks, + LANDING_SECTION_ID.whatYouGet, + LANDING_SECTION_ID.whereLoreLives, + LANDING_SECTION_ID.limitations, + LANDING_SECTION_ID.getStarted, +] as const; diff --git a/packages/web/src/components/layout/SiteFooter.tsx b/packages/web/src/components/layout/SiteFooter.tsx index 30d9c2328..a8122172d 100644 --- a/packages/web/src/components/layout/SiteFooter.tsx +++ b/packages/web/src/components/layout/SiteFooter.tsx @@ -1,6 +1,8 @@ import type { ComponentPropsWithoutRef } from 'react'; import { BookText, FileCode, Newspaper } from 'lucide-react'; +import { GitHubMark } from '@/components/ui/GitHubMark'; + const DISCORD_URL = 'https://discord.gg/B9m6BzMRjg'; const ICON_LINK_CLASS = 'inline-flex items-center gap-1.5 transition-colors duration-200 hover:text-[var(--color-content-primary)]'; @@ -26,15 +28,7 @@ export function SiteFooter({ className = '', ...props }: ComponentPropsWithoutRe rel="noopener noreferrer" className={ICON_LINK_CLASS} > - - - + GitHub diff --git a/packages/web/src/components/learn/GetStartedButton.tsx b/packages/web/src/components/learn/GetStartedButton.tsx deleted file mode 100644 index 052299fb8..000000000 --- a/packages/web/src/components/learn/GetStartedButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import Link from 'next/link'; - -/** - * "Explore docs & guides" call-to-action shown at the bottom of the login - * page's getting-started card. Links straight to the public docs index rather - * than opening a dialog, so the user lands on the full guides in one click. - */ -export function GetStartedButton() { - return ( -
      - - Explore docs & guides - - - - -
      - ); -} diff --git a/packages/web/src/components/ui/GitHubMark.tsx b/packages/web/src/components/ui/GitHubMark.tsx new file mode 100644 index 000000000..a025cd529 --- /dev/null +++ b/packages/web/src/components/ui/GitHubMark.tsx @@ -0,0 +1,21 @@ +/** + * The GitHub mark, as one component. + * + * The same 1 KB path used to be pasted inline in four places (the landing + * header and hero, the site footer, and the sign-in button), so a fix to one + * copy never reached the others. Decorative by default — every caller pairs it + * with visible text or an `aria-label` on the surrounding link. + */ +export function GitHubMark({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/packages/web/src/lib/analytics/reading.ts b/packages/web/src/lib/analytics/reading.ts index 6d7a090cc..4c521626f 100644 --- a/packages/web/src/lib/analytics/reading.ts +++ b/packages/web/src/lib/analytics/reading.ts @@ -13,8 +13,12 @@ * can never disagree about what "skimmed" means. */ -/** Which public surface the content lives on. Bounded — one value per route family. */ -export type ContentType = 'blog' | 'docs' | 'learn'; +/** + * Which public surface the content lives on. Bounded — one value per route family. + * `landing` is the public home page (`/`), whose sections are read with exactly + * the blog's model so the two can share one dashboard. + */ +export type ContentType = 'blog' | 'docs' | 'learn' | 'landing'; /** * The scroll-depth milestones we report, in percent of the content element. diff --git a/packages/web/src/lib/analytics/track.spec.ts b/packages/web/src/lib/analytics/track.spec.ts index d6a4960e5..da6bf69b2 100644 --- a/packages/web/src/lib/analytics/track.spec.ts +++ b/packages/web/src/lib/analytics/track.spec.ts @@ -171,6 +171,81 @@ describe('track — ui.button_click', () => { }); }); +describe('track — landing.cta_clicked', () => { + it('maps the intent and the placement onto lorekit.landing.*', () => { + track({ name: 'landing.cta_clicked', cta: 'get-token', placement: 'hero' }); + + expect(lastCall().name).toBe('landing.cta_clicked'); + expect(lastCall().attributes).toEqual({ + 'lorekit.landing.cta': 'get-token', + 'lorekit.landing.placement': 'hero', + }); + }); + + it('keeps the two answers to "hosted or offline?" apart', () => { + track({ name: 'landing.cta_clicked', cta: 'get-token', placement: 'hero' }); + track({ name: 'landing.cta_clicked', cta: 'offline-guide', placement: 'hero' }); + + expect(calls.map((c) => c.attributes['lorekit.landing.cta'])).toEqual([ + 'get-token', + 'offline-guide', + ]); + }); + + it('sends only the two bounded ids — never a label or a URL', () => { + track({ name: 'landing.cta_clicked', cta: 'github', placement: 'header' }); + + expect(Object.keys(lastCall().attributes).sort()).toEqual([ + 'lorekit.landing.cta', + 'lorekit.landing.placement', + ]); + }); +}); + +describe('track — landing.demo_tab_selected', () => { + it('records the tab by its stable id', () => { + track({ name: 'landing.demo_tab_selected', tab: 'pr-review' }); + + expect(lastCall().name).toBe('landing.demo_tab_selected'); + expect(lastCall().attributes).toEqual({ 'lorekit.landing.demo_tab': 'pr-review' }); + }); +}); + +describe('track — content.* on the home page', () => { + // The home page reuses the blog's reading model; what has to hold is that it + // arrives on the SAME attributes, told apart only by the content type, so one + // dashboard can split the two surfaces with a single group-by. + it('reports landing scroll depth on the blog attributes with type=landing', () => { + track({ name: 'content.scroll_depth', contentType: 'landing', slug: 'home', depthPercent: 50 }); + + expect(lastCall().attributes).toEqual({ + 'lorekit.content.type': 'landing', + 'lorekit.content.slug': 'home', + 'lorekit.content.depth_percent': 50, + }); + }); + + it('reports a landing section dwell with its index and bucket', () => { + track({ + name: 'content.section_read', + contentType: 'landing', + slug: 'home', + sectionId: 'learning-loop', + sectionIndex: 2, + dwellMs: 7_200, + }); + + expect(lastCall().attributes).toEqual({ + 'lorekit.content.type': 'landing', + 'lorekit.content.slug': 'home', + 'lorekit.content.section.id': 'learning-loop', + 'lorekit.content.section.index': 2, + 'lorekit.content.section.dwell_ms': 7_200, + 'lorekit.content.section.dwell_bucket': '2_5_15s', + }); + }); +}); + describe('track — best effort', () => { it('swallows an SDK failure instead of breaking the caller', () => { state.throwNext = true; diff --git a/packages/web/src/lib/analytics/track.ts b/packages/web/src/lib/analytics/track.ts index af11bbcae..1bfd4221c 100644 --- a/packages/web/src/lib/analytics/track.ts +++ b/packages/web/src/lib/analytics/track.ts @@ -30,6 +30,14 @@ * static developer-authored slug, plus the * visual variant/size). * + * ### Public home page (`landing.*`) + * - `landing.cta_clicked` — a link on the home page was followed: which + * intent (`cta`) from which spot (`placement`). + * - `landing.demo_tab_selected` — a visitor switched the live demo's use case. + * The home page ALSO emits the three `content.*` reading events, with + * `contentType: 'landing'` — the same model the blog uses, so "how far do they + * scroll / which sections hold them" has one schema across both surfaces. + * * ### Lore Explorer (`lore.*`) * - `lore.memory_opened` — a memory was selected into the detail panel. * - `lore.memory_closed` — the detail panel was dismissed, with WHY. @@ -121,8 +129,51 @@ export type CommandSource = 'palette' | 'shortcut'; */ export type InstallCommandId = 'cli-install'; -/** Where the copy affordance was rendered. Bounded for the same reason. */ -export type CopySurface = 'login-get-started' | 'blog-cta'; +/** + * Where the copy affordance was rendered. Bounded for the same reason. + * + * `landing-hero` and `landing-get-started` are the two positions on the public + * home page, and they are kept apart because they answer different questions: + * the hero measures whether the command ALONE converts a first-time visitor, + * the card measures whether someone who read the whole page converts at the end + * of it. Collapsing them would hide which half of the page is doing the work. + * + * `login-get-started` is retained rather than deleted: the card it named moved + * from `/login` to `/`, but the value is already recorded against past events + * and dropping it from the union would make that history untypeable. + */ +export type CopySurface = + | 'landing-hero' + | 'landing-get-started' + | 'login-get-started' + | 'blog-cta'; + +/** + * What a home-page link is FOR — the visitor's intent, not its label or URL. + * + * `get-token` and `offline-guide` are the two answers to the question the hero + * asks right under the install command (hosted or offline?), so their ratio is + * the page's most direct read on which path visitors want. `sign-in` is the + * same destination as `get-token` reached from a different promise (the header + * button, the closing card's inline link), which is why it is a separate value. + */ +export type LandingCta = + | 'get-token' + | 'offline-guide' + | 'sign-in' + | 'docs' + | 'blog' + | 'github'; + +/** + * Where on the page the link sits. The header is shared with `/login`, so a + * `header` click from the sign-in page arrives with `page.url.path=/login` — + * filter on the path when only the home page is wanted. + */ +export type LandingPlacement = 'header' | 'hero' | 'get-started'; + +/** The live demo's use-case tabs, by stable id (never the visible label). */ +export type LandingDemoTab = 'self-healing' | 'promotion' | 'pr-review'; /** Discriminated union of every tracked event. Add new events here. */ export type AnalyticsEvent = @@ -188,6 +239,9 @@ export type AnalyticsEvent = /** The button's size, when set — a bounded enum. */ size?: ButtonSize; } + // ── Public home page ────────────────────────────────────────────────────── + | { name: 'landing.cta_clicked'; cta: LandingCta; placement: LandingPlacement } + | { name: 'landing.demo_tab_selected'; tab: LandingDemoTab } // ── Lore Explorer ───────────────────────────────────────────────────────── | { name: 'lore.memory_opened'; @@ -392,6 +446,15 @@ function toAttributes(event: AnalyticsEvent): Record { return attrs; } + // ── Public home page ──────────────────────────────────────────────────── + case 'landing.cta_clicked': + return { + 'lorekit.landing.cta': event.cta, + 'lorekit.landing.placement': event.placement, + }; + case 'landing.demo_tab_selected': + return { 'lorekit.landing.demo_tab': event.tab }; + // ── Lore Explorer ─────────────────────────────────────────────────────── case 'lore.memory_opened': { const attrs: Record = {