+ + {icon} + + {title} +
+{body}
+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 (
- 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.
- Sign in free · 2-minute setup
-
- Free · Open source
+
+ Free — 5,000 memories, no card
Sign in and get your token GitHub, email + password, or a magic link — takes 30 seconds Run one command Your agent now remembers Memories persist across sessions, repos, and tools — automatically
- {description}
-
- Persistent memory
-
- for your AI agents
+
+ Sign in to LoreKit
-
- Get started in 2 minutes
-
-
-
- {/* Explore docs & guides — links to the public /docs index */}
-
- {title}
-
-
- {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
+
+
+
+ -
+
+
+
+ Run one command
+
+
+
+
+ -
+
+
+
+ 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.
+
+
+
+ -
+
+
+
+ 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.
+
+
+
+
+
+
+
+ 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) => (
+ -
+ {/* 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}
+
+
+
+
+ ))}
+
+
+ );
+}
+
+// ─── 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 (
+
+
+
+ A rules file compared with LoreKit
+
+
+
+ Aspect
+
+
+ CLAUDE.md / AGENTS.md
+
+
+ LoreKit
+
+
+
+
+ {COMPARISON.map((row) => (
+
+
+ {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 = {