From d38c6e426f54742f1c692184c1cfbe9526312efc Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Mon, 21 Sep 2026 08:24:42 +0000 Subject: [PATCH 1/6] feat(web): rebuild the landing page as a marketing page, split from /login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/` redirected every anonymous visitor to `/login`, so the front door was an auth route titled "Sign in": a form before an explanation, with the product's strongest verifiable claims — one command, works with no account, MIT, self-hostable — below the fold of a page nobody had a reason to scroll. `/` is now the marketing page and `/login` does only the job its URL names. The hero's primary action is the install command rather than sign-in, because the CLI's offline mode needs no account and an auth-first ordering described a requirement that does not exist. Three overstated claims are corrected: "never asks the same question twice" (an absolute the retrieval model cannot guarantee), a bare "Free" badge (now "Free · 5,000 memories", the actual free-plan cap), and a flat Claude Code / Cursor / Codex list (Codex is feature-flagged and experimental). Two new sections state what the page was missing: where lore physically lives, and what LoreKit does not do. --- packages/web/src/app/(auth)/login/page.tsx | 231 +++--------------- packages/web/src/app/globals.css | 44 ++++ packages/web/src/app/page.tsx | 64 ++++- .../src/components/landing/CopyCommand.tsx | 43 +++- .../src/components/landing/GetStartedCard.tsx | 77 ++++++ .../web/src/components/landing/HowItWorks.tsx | 67 +++++ .../components/landing/LandingBackdrop.tsx | 31 +++ .../src/components/landing/LandingHeader.tsx | 71 ++++++ .../src/components/landing/LandingHero.tsx | 102 ++++++++ .../src/components/landing/Limitations.tsx | 41 ++++ .../web/src/components/landing/MemoryCost.tsx | 33 +++ .../components/landing/TerminalTheater.tsx | 14 +- .../src/components/landing/WhereLoreLives.tsx | 66 +++++ packages/web/src/lib/analytics/track.ts | 20 +- 14 files changed, 690 insertions(+), 214 deletions(-) create mode 100644 packages/web/src/components/landing/GetStartedCard.tsx create mode 100644 packages/web/src/components/landing/HowItWorks.tsx create mode 100644 packages/web/src/components/landing/LandingBackdrop.tsx create mode 100644 packages/web/src/components/landing/LandingHeader.tsx create mode 100644 packages/web/src/components/landing/LandingHero.tsx create mode 100644 packages/web/src/components/landing/Limitations.tsx create mode 100644 packages/web/src/components/landing/MemoryCost.tsx create mode 100644 packages/web/src/components/landing/WhereLoreLives.tsx diff --git a/packages/web/src/app/(auth)/login/page.tsx b/packages/web/src/app/(auth)/login/page.tsx index 06f49732f..715b03c81 100644 --- a/packages/web/src/app/(auth)/login/page.tsx +++ b/packages/web/src/app/(auth)/login/page.tsx @@ -1,232 +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; - +/** + * 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 - - - - - - + -
-
+ } + /> - {/* 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..6b3fd2f78 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -109,6 +109,50 @@ 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); } +} + /* ── 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..1f17313dd 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,9 +1,28 @@ +import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; -import { getVerifiedUser } from '@/lib/auth/verified-user'; + +import { AuthHashCatcher } from '@/components/auth/AuthHashCatcher'; +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 { Limitations } from '@/components/landing/Limitations'; +import { MemoryCost } from '@/components/landing/MemoryCost'; +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 — offline with no account, or hosted across every machine, teammate, and CI run.', +}; + function toURLSearchParams(input: SearchParams): URLSearchParams { const params = new URLSearchParams(); for (const [key, value] of Object.entries(input)) { @@ -13,6 +32,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 +55,13 @@ 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 has to be mounted HERE now: this page no + // longer redirects to /login, so the catcher that used to receive those + // fragments there would never run. if (classifyAuthCallback(params).kind !== 'none') { if (!params.has('next')) params.set('next', '/welcome'); redirect(`/api/auth/callback?${params.toString()}`); @@ -41,5 +75,25 @@ export default async function RootPage({ // no setup instructions. See Sidebar.tsx's nav and lore/page.tsx. redirect('/lore'); } - redirect('/login'); + + return ( +
+ + + + + + +
+ + + + + + +
+ + +
+ ); } diff --git a/packages/web/src/components/landing/CopyCommand.tsx b/packages/web/src/components/landing/CopyCommand.tsx index b9cb78b72..4b14ba4a0 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,23 @@ 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) and body-sized command text. + */ + 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 +71,38 @@ export function CopyCommand({ } return ( -
- +
+ {command}
); diff --git a/packages/web/src/components/landing/GetStartedCard.tsx b/packages/web/src/components/landing/GetStartedCard.tsx new file mode 100644 index 000000000..523446670 --- /dev/null +++ b/packages/web/src/components/landing/GetStartedCard.tsx @@ -0,0 +1,77 @@ +import { GetStartedButton } from '@/components/learn/GetStartedButton'; + +import { CopyCommand } from './CopyCommand'; + +const STEP_BADGE_CLASS = + 'flex size-6 shrink-0 items-center justify-center rounded-full border border-[var(--color-accent)]/40 bg-[var(--color-accent-subtle)] font-mono text-xs font-semibold text-[var(--color-accent)]'; + +/** + * The closing call to action on the home page — the same three steps that used + * to sit on `/login`, now placed where they belong: after the page has said + * what the product is, rather than above the fold in place of that explanation. + * + * Step 1 is deliberately the command and not the sign-in, matching the hero and + * the actual product: the CLI's offline mode needs no account, so an ordering + * that put "create an account" first would describe a requirement that does not + * exist. + */ +export function GetStartedCard() { + return ( +
+

+ Get started in 2 minutes +

+

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

+ +
    +
  1. + 1 +
    +

    + Run one command +

    + +
    +
  2. +
  3. + 2 +
    +

    + Your agent starts remembering +

    +

    + Lessons persist across sessions on this machine, stored as markdown you can + read and commit +

    +
    +
  4. +
  5. + 3 +
    +

    + Sign in when you want them shared +

    +

    + A token connects the same store to every machine, teammate, and CI run — and + brings your local lessons with it +

    +
    +
  6. +
+ + +
+ ); +} diff --git a/packages/web/src/components/landing/HowItWorks.tsx b/packages/web/src/components/landing/HowItWorks.tsx new file mode 100644 index 000000000..652ee480a --- /dev/null +++ b/packages/web/src/components/landing/HowItWorks.tsx @@ -0,0 +1,67 @@ +const STEPS = [ + { + title: 'Your agent writes what it learns', + body: 'One memory.write call when something is worth keeping — a gotcha, a convention, a fix that took three attempts.', + }, + { + title: 'The lesson is stored under a scope', + body: 'This branch, this repo, this project, or everywhere. The scope is what keeps one repository’s lore out of another’s.', + }, + { + title: 'The next session reads it back', + body: 'Narrowest scope first, then wider — so an agent gets this repo’s lessons without drowning in everything else you have ever recorded.', + }, +] as const; + +/** The ASCII diagram from the repo README, reused so the front door and the + * README describe the product in the same shape. Decorative: the three steps + * below carry the same content in prose, which is what a screen reader — and a + * phone, where a fixed-width block would need horizontal scrolling — gets + * instead. */ +const DIAGRAM = ` ┌─ your agent, any tool, any machine ─┐ +learns something │ memory.write { scope, key, value } │ +in a session ───→│ │──→ one store, your call: +recalls it next │ memory.list { scope } │←── remote (shared) or local +time it needs it └──────────────────────────────────────┘`; + +export function HowItWorks() { + return ( +
+

+ One store. Every agent, every machine. +

+

+ Two MCP tool calls are the whole mechanic. +

+ +
+        {DIAGRAM}
+      
+ +
    + {STEPS.map(({ title, body }, index) => ( +
  1. + + {index + 1} + +

    + {title} +

    +

    + {body} +

    +
  2. + ))} +
+
+ ); +} diff --git a/packages/web/src/components/landing/LandingBackdrop.tsx b/packages/web/src/components/landing/LandingBackdrop.tsx new file mode 100644 index 000000000..3b2c1b9da --- /dev/null +++ b/packages/web/src/components/landing/LandingBackdrop.tsx @@ -0,0 +1,31 @@ +/** + * The amber halo + 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. + */ +export function LandingBackdrop() { + return ( +
+
+
+
+
+ ); +} diff --git a/packages/web/src/components/landing/LandingHeader.tsx b/packages/web/src/components/landing/LandingHeader.tsx new file mode 100644 index 000000000..df02c8bf5 --- /dev/null +++ b/packages/web/src/components/landing/LandingHeader.tsx @@ -0,0 +1,71 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; + +/** + * 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. + */ +export function LandingHeader({ action }: { action?: ReactNode }) { + return ( +
+ + + LoreKit + + +
+ + Docs + + + + + + + {action ?? ( + + Sign in + + )} +
+
+ ); +} diff --git a/packages/web/src/components/landing/LandingHero.tsx b/packages/web/src/components/landing/LandingHero.tsx new file mode 100644 index 000000000..b40a5a10e --- /dev/null +++ b/packages/web/src/components/landing/LandingHero.tsx @@ -0,0 +1,102 @@ +import Link from 'next/link'; + +import { CopyCommand } from './CopyCommand'; + +/** + * 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 CLI works with no account at all, so + * asking a first-time visitor to authenticate before they can try anything + * 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 — and the copy says so instead of implying the product + * needs one. + * + * The lines are staggered (`--lk-delay`) rather than arriving together, so the + * eye lands on the headline and the supporting lines catch up; see the + * `lk-rise` block in `globals.css` for the motion contract. + */ +export function LandingHero() { + return ( +
+ {/* Trust bar. Each claim is checkable: the plan limit is the free-plan + memory cap, the licence is in the repo, and the tool list names the + integration level rather than implying all three are equal. */} + + +

+ Shared memory for{' '} + your coding agents +

+ +

+ Your agent works something out — why the build only breaks in CI, which migration + needs the flag — and forgets it when the session ends. LoreKit writes it down and + reads it back in every session after, on every machine, in every tool. +

+ +
+
+ +
+ +

+ Works offline with no account. Sign in when you want lessons shared across + machines and teammates. +

+ +

+ + Sign in + + · + + Read the docs + +

+
+
+ ); +} diff --git a/packages/web/src/components/landing/Limitations.tsx b/packages/web/src/components/landing/Limitations.tsx new file mode 100644 index 000000000..1e40b4d45 --- /dev/null +++ b/packages/web/src/components/landing/Limitations.tsx @@ -0,0 +1,41 @@ +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. + */ +export function Limitations() { + return ( +
+

+ What LoreKit doesn’t do +

+
    + {LIMITS.map((limit) => ( +
  • + + — + + {limit} +
  • + ))} +
+
+ ); +} diff --git a/packages/web/src/components/landing/MemoryCost.tsx b/packages/web/src/components/landing/MemoryCost.tsx new file mode 100644 index 000000000..f2103eae7 --- /dev/null +++ b/packages/web/src/components/landing/MemoryCost.tsx @@ -0,0 +1,33 @@ +/** + * The section that says memory has a price. + * + * It is on the page deliberately. Every lesson injected into a session is paid + * for in context whether the agent uses it or not, and a memory product that + * does not say so is selling a cost as a feature. LoreKit measures the ratio + * and shows it, so the honest version of the claim is also the differentiating + * one — which is why this sits in the page's own voice rather than in a FAQ. + */ +export function MemoryCost() { + return ( +
+
+

+ Memory isn’t free, so we measure it +

+

+ 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. +

+

+ Retention policies then archive it — reviewed by you, or swept nightly, and + never a hard delete. +

+
+
+ ); +} diff --git a/packages/web/src/components/landing/TerminalTheater.tsx b/packages/web/src/components/landing/TerminalTheater.tsx index 278861689..5c39d7110 100644 --- a/packages/web/src/components/landing/TerminalTheater.tsx +++ b/packages/web/src/components/landing/TerminalTheater.tsx @@ -252,11 +252,11 @@ 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. + // The theater does NOT sit above any call to action: `app/page.tsx` renders + // the hero — install command, sign-in and docs links — and only reaches this + // several sections further down, so the shifting text is always below every + // control. The cost is the accrued shift entries themselves, which CLS sums + // for the whole session; it is not a CTA moving under a cursor. // // 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 @@ -551,10 +551,10 @@ export function TerminalTheater() { {/* Heading */}

- Watch memories form and persist + Session A writes. Session B already knows.

- Session A writes. Session B loads. No re-explaining. + Two sessions, one store, no re-explaining.

diff --git a/packages/web/src/components/landing/WhereLoreLives.tsx b/packages/web/src/components/landing/WhereLoreLives.tsx new file mode 100644 index 000000000..9290b08c4 --- /dev/null +++ b/packages/web/src/components/landing/WhereLoreLives.tsx @@ -0,0 +1,66 @@ +const STORES = [ + { + 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.', + }, + { + 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.', + }, + { + 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.', + }, +] as const; + +/** + * 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 rather than as a + * footnote, because burying the option that needs nothing from us would be + * steering rather than informing. + */ +export function WhereLoreLives() { + return ( +
+

+ Where your lore lives is your call +

+

+ The same memory tools run against any of the three. +

+ +
    + {STORES.map(({ name, summary, body }) => ( +
  • +

    + {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/lib/analytics/track.ts b/packages/web/src/lib/analytics/track.ts index af11bbcae..39711689f 100644 --- a/packages/web/src/lib/analytics/track.ts +++ b/packages/web/src/lib/analytics/track.ts @@ -121,8 +121,24 @@ 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'; /** Discriminated union of every tracked event. Add new events here. */ export type AnalyticsEvent = From d7111d2d4bf944fc7c16a8230ca7636b7f13a1c1 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Sun, 11 Oct 2026 05:06:25 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(web):=20make=20the=20landing=20page=20?= =?UTF-8?q?persuade=20=E2=80=94=20product=20shot,=20problem,=20bento,=20ho?= =?UTF-8?q?nest=20next=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raises the PR's honest copy to a marketing page that sells: - Hero leads with the problem and resolves it in one fixation ("Your coding agents forget. LoreKit remembers."), the README's own framing. The install command gets the amber frame and the page's only filled button; trust points move under it, to the moment of the click. - The live demo becomes the hero's product shot, directly below it, and tilts flat as it scrolls in (CSS view() timeline, transform-only, flat at rest). - New "01 / the problem" before/after, and a "03 / what you get" bento that absorbs MemoryCost and adds review-comment learning, one store for every tool, and grooming — each with a small picture of the behaviour. - One heading system (mono numbered eyebrow, 30-36px title) and two repeated signature moves (lk-glow-frame, lk-gradient-text) across every section. - Sticky blurred header; the Blog link from #685 now lives in the shared LandingHeader (the merge kept the slim /login). Copy corrected against the CLI: install with a blank token still writes the hosted MCP entry, so "works offline with no account" directly under the command promised a setup one command does not produce. The hero and the closing card now name both real paths — hosted (sign in, paste a token) and offline (skip the token, switch to local mode) — at equal weight. Webhook lessons are described as candidates that expire, matching webhook.ts. Review findings applied: AuthHashCatcher comment no longer claims a move; LandingBackdrop uses tokens, not hex; StepBadge replaces the duplicated class; one GitHubMark replaces four inline copies of the SVG path. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/web/src/app/globals.css | 61 +++++ packages/web/src/app/page.tsx | 34 ++- .../web/src/components/auth/LoginButton.tsx | 20 +- .../src/components/landing/BeforeAfter.tsx | 162 +++++++++++++ .../src/components/landing/CopyCommand.tsx | 37 ++- .../src/components/landing/FeatureBento.tsx | 226 ++++++++++++++++++ .../src/components/landing/GetStartedCard.tsx | 153 +++++++----- .../web/src/components/landing/HowItWorks.tsx | 57 +++-- .../components/landing/LandingBackdrop.tsx | 35 ++- .../src/components/landing/LandingHeader.tsx | 97 ++++---- .../src/components/landing/LandingHero.tsx | 180 +++++++++----- .../src/components/landing/Limitations.tsx | 21 +- .../web/src/components/landing/MemoryCost.tsx | 33 --- .../src/components/landing/SectionHeading.tsx | 54 +++++ .../web/src/components/landing/StepBadge.tsx | 19 ++ .../components/landing/TerminalTheater.tsx | 44 ++-- .../src/components/landing/WhereLoreLives.tsx | 84 +++++-- .../web/src/components/layout/SiteFooter.tsx | 12 +- packages/web/src/components/ui/GitHubMark.tsx | 21 ++ 19 files changed, 1024 insertions(+), 326 deletions(-) create mode 100644 packages/web/src/components/landing/BeforeAfter.tsx create mode 100644 packages/web/src/components/landing/FeatureBento.tsx delete mode 100644 packages/web/src/components/landing/MemoryCost.tsx create mode 100644 packages/web/src/components/landing/SectionHeading.tsx create mode 100644 packages/web/src/components/landing/StepBadge.tsx create mode 100644 packages/web/src/components/ui/GitHubMark.tsx diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 6b3fd2f78..6010a08ba 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 */ @@ -153,6 +154,66 @@ body:has([data-app-shell]) { 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; } +} + +/* ── 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 1f17313dd..a31a4241c 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -2,13 +2,14 @@ import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; import { AuthHashCatcher } from '@/components/auth/AuthHashCatcher'; +import { BeforeAfter } from '@/components/landing/BeforeAfter'; +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 { Limitations } from '@/components/landing/Limitations'; -import { MemoryCost } from '@/components/landing/MemoryCost'; import { TerminalTheater } from '@/components/landing/TerminalTheater'; import { WhereLoreLives } from '@/components/landing/WhereLoreLives'; import { SiteFooter } from '@/components/layout/SiteFooter'; @@ -20,7 +21,7 @@ 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 — offline with no account, or hosted across every machine, teammate, and CI run.', + '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 { @@ -59,9 +60,11 @@ export default async function RootPage({ // // 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 the - // AuthHashCatcher mounted below. It has to be mounted HERE now: this page no - // longer redirects to /login, so the catcher that used to receive those - // fragments there would never run. + // 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()}`); @@ -76,24 +79,35 @@ export default async function RootPage({ redirect('/lore'); } + // 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) → the pain it removes → 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. 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/BeforeAfter.tsx b/packages/web/src/components/landing/BeforeAfter.tsx new file mode 100644 index 000000000..759a0a47b --- /dev/null +++ b/packages/web/src/components/landing/BeforeAfter.tsx @@ -0,0 +1,162 @@ +import { Check, X } from 'lucide-react'; + +import { cn } from '@/lib/cn'; + +import { SectionHeading } from './SectionHeading'; + +type TranscriptLine = { + day: string; + who: 'you' | 'agent'; + text: string; + /** The line that lands the point — the relapse, or the remembered answer. */ + emphasis?: boolean; +}; + +const WITHOUT: TranscriptLine[] = [ + { day: 'mon', who: 'you', text: 'we validate with Zod here, not Yup' }, + { day: 'tue', who: 'you', text: 'reminder: Zod, not Yup' }, + { day: 'wed', who: 'you', text: 'again — Zod.' }, + { day: 'thu', who: 'agent', text: 'pnpm add yup', emphasis: true }, +]; + +const WITH: TranscriptLine[] = [ + { day: 'mon', who: 'agent', text: 'memory.write conventions/validation "Zod, not Yup"' }, + { day: 'tue', who: 'agent', text: 'memory.list ↳ 1 lesson loaded' }, + { day: 'wed', who: 'agent', text: 'memory.list ↳ 1 lesson loaded' }, + { day: 'thu', who: 'agent', text: 'import { z } from "zod"', emphasis: true }, +]; + +/** + * "01 / the problem" — the cost of forgetting, shown rather than described. + * + * The demo above it shows the MECHANISM (session A writes, session B reads). + * This section shows what that is worth to the person at the keyboard: the + * same sentence typed four days running, and the relapse anyway. It is the one + * part of the page that argues with feeling instead of facts, so it stays a + * single, recognisable scene — a convention every reader has had to repeat — + * rather than an invented statistic about hours lost. + * + * The "with" column uses the real tool names and the real `memory.list` output + * shape, so it promises exactly what the product does: a lesson written once + * is read back in later sessions. It does not claim the agent can never + * relapse — that depends on the agent reading what it is given. + * + * Server-rendered and static; the transcripts are presentational, so each + * column also carries a plain-language summary line for screen readers and + * skimmers alike. + */ +export function BeforeAfter() { + return ( +
+ + Every session starts from zero.{' '} + Yours don’t have to. + + } + lede="The agent that untangled your build an hour ago has no memory of it now. So you re-explain — and it still gets it wrong on Thursday." + /> + +
+ + +
+
+ ); +} + +function TranscriptCard({ + variant, + title, + summary, + lines, +}: { + variant: 'without' | 'with'; + title: string; + summary: string; + lines: TranscriptLine[]; +}) { + const isWith = variant === 'with'; + const Icon = isWith ? Check : X; + + return ( +
+
+ + + +

{title}

+
+ +
    + {lines.map((line) => ( +
  1. + {line.day} + + {line.who} › + + + {line.text} + +
  2. + ))} +
+ +

+ {summary} +

+
+ ); +} diff --git a/packages/web/src/components/landing/CopyCommand.tsx b/packages/web/src/components/landing/CopyCommand.tsx index 4b14ba4a0..b8ea39c96 100644 --- a/packages/web/src/components/landing/CopyCommand.tsx +++ b/packages/web/src/components/landing/CopyCommand.tsx @@ -40,7 +40,9 @@ export function CopyCommand({ * `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) and body-sized command text. + * 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'; }) { @@ -73,31 +75,42 @@ 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}

-
    + {/* Columns are sized in `ch`, not px: "agent ›" is 7 characters in any + monospace font, so the speaker column can never wrap its `›` onto a + second line the way a fixed 44px column did. */} +
      {lines.map((line) => ( -
    1. - {line.day} +
    2. + {line.day} - Memory that earns its place in your context. + 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." diff --git a/packages/web/src/components/landing/HowItWorks.tsx b/packages/web/src/components/landing/HowItWorks.tsx index 5d7180d16..ebdd4f748 100644 --- a/packages/web/src/components/landing/HowItWorks.tsx +++ b/packages/web/src/components/landing/HowItWorks.tsx @@ -1,31 +1,54 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/cn'; + import { SectionHeading } from './SectionHeading'; import { StepBadge } from './StepBadge'; -const STEPS = [ +type Step = { + title: string; + body: string; + /** What the node looks like in practice — an illustration, `aria-hidden`. */ + visual: ReactNode; + /** The MCP call that carries the lesson on to the NEXT step, if any. */ + handoff?: 'memory.write' | 'memory.list'; +}; + +/** + * "02 / how it works" — the mechanism as one left-to-right flow: + * session A → `memory.write` → one store → `memory.list` → session B. + * + * This replaces two things that said the same thing twice: the README's ASCII + * box diagram (which rendered with its top-right corner one column short of the + * right edge, left-aligned in a frame twice its width) and three separate step + * cards joined by a rule that only showed as stubs in the gaps between them. + * Now each step IS a node of the diagram, so the picture and the prose are one + * object, and the calls that connect them are labelled on the arrows. + * + * The example is the same lesson the live demo above writes (`fix/ssr-window`), + * so a reader who watched the demo sees the same lesson take the same path. + * + * Below `lg` the flow turns vertical and the arrows point down. + */ +const STEPS: Step[] = [ { title: 'Your agent writes what it learns', body: 'One memory.write call when something is worth keeping — a gotcha, a convention, a fix that took three attempts.', + visual: , + handoff: 'memory.write', }, { title: 'The lesson is stored under a scope', body: 'This branch, this repo, this project, or everywhere. The scope is what keeps one repository’s lore out of another’s.', + visual: , + handoff: 'memory.list', }, { title: 'The next session reads it back', body: 'Narrowest scope first, then wider — so an agent gets this repo’s lessons without drowning in everything else you have ever recorded.', + visual: , }, -] as const; - -/** The ASCII diagram from the repo README, reused so the front door and the - * README describe the product in the same shape. Decorative: the three steps - * below carry the same content in prose, which is what a screen reader — and a - * phone, where a fixed-width block would need horizontal scrolling — gets - * instead. */ -const DIAGRAM = ` ┌─ your agent, any tool, any machine ─┐ -learns something │ memory.write { scope, key, value } │ -in a session ───→│ │──→ one store, your call: -recalls it next │ memory.list { scope } │←── remote (shared) or local -time it needs it └──────────────────────────────────────┘`; +]; export function HowItWorks() { return ( @@ -41,40 +64,111 @@ export function HowItWorks() { lede="Two MCP tool calls are the whole mechanic. The CLI wires the hooks that make your agent call them." /> - {/* Framed like a file so it reads as the README excerpt it is. */} -
      -
      - README.md -
      -
      -          {DIAGRAM}
      -        
      -
      - - {/* The connecting rule runs through the badges on wide screens, so the - three cards read as one left-to-right sequence rather than a grid of - unrelated features. */} -
        - - {STEPS.map(({ title, body }, index) => ( -
      1. - -

        - {title} -

        -

        {body}

        + {/* `gap-x-20` is the arrow's runway: the horizontal handoff arrow lives in + that gap, so its width and the gap are the same number. */} +
          + {STEPS.map((step, index) => ( +
        1. +
          + {step.visual} +
          + +
          + +

          + {step.title} +

          +

          + {step.body} +

          +
          + + {step.handoff && }
        2. ))}
); } + +/** + * The labelled arrow from one step to the next. Two renderings of the same + * thing: vertical between stacked steps below `lg`, and horizontal in the grid + * gap from `lg` up. The horizontal one is placed so its LINE, not its label, + * meets the nodes at their middle: 44px top + 15px label + 4px gap + 5px to the + * line's centre = 68px, half of the 136px node height. + */ +function Handoff({ call }: { call: Step['handoff'] }) { + return ( + <> +
+ + {call} + ▼ +
+ +
+ {call} + + + ▶ + +
+ + ); +} + +// ─── Nodes ────────────────────────────────────────────────────────────────── +// One fixed height so the handoff arrows meet every node at its middle. + +const NODE = + 'flex h-[136px] flex-col justify-center gap-1.5 rounded-xl px-4 text-left font-mono text-[11px] leading-relaxed'; + +function NodeLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function SessionANode() { + return ( +
+ session A · any tool + ✗ window is not defined + + agent › fixed with a typeof guard + +
+ ); +} + +/** The store is the product, so it is the node that gets the amber frame. */ +function StoreNode() { + return ( +
+ one store · local or hosted + + repo::acme/web + + + fix/ssr-window{' '} + “guard window with typeof” + +
+ ); +} + +function SessionBNode() { + return ( +
+ session B · any machine + ↳ 1 lesson loaded + ✓ right the first time +
+ ); +} diff --git a/packages/web/src/components/landing/SectionHeading.tsx b/packages/web/src/components/landing/SectionHeading.tsx index 8ff23a658..76b75c844 100644 --- a/packages/web/src/components/landing/SectionHeading.tsx +++ b/packages/web/src/components/landing/SectionHeading.tsx @@ -16,6 +16,11 @@ import { cn } from '@/lib/cn'; * 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, diff --git a/packages/web/src/components/landing/TerminalTheater.tsx b/packages/web/src/components/landing/TerminalTheater.tsx index 15aaa0fc4..c78864eb2 100644 --- a/packages/web/src/components/landing/TerminalTheater.tsx +++ b/packages/web/src/components/landing/TerminalTheater.tsx @@ -554,7 +554,8 @@ export function TerminalTheater() { eyebrow="live demo" title={ <> - Session A writes. Session B already knows. + Session A writes.{' '} + Session B already knows. } lede="Two sessions, one store, no re-explaining." From 33522051dc03a67414683a089263eb65dc108ae0 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Sun, 11 Oct 2026 05:45:23 +0000 Subject: [PATCH 4/6] =?UTF-8?q?feat(web):=20measure=20the=20home=20page=20?= =?UTF-8?q?=E2=80=94=20reading=20depth,=20sections,=20and=20every=20CTA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the blog's reading model rather than inventing a second one: ReadingTelemetry now mounts on / with contentType 'landing' (slug 'home'), measured against the hero-to-closing-card wrapper. The home page therefore emits the same content.scroll_depth / content.section_read / content.read events as a blog post, split by lorekit.content.type. - sections.ts is the single source of the eight section heading ids; every section imports its id from it and the page hands the ordered list to the telemetry, so a renamed heading cannot silently drop out of the data (sections.spec.ts pins the list). - landing.cta_clicked (cta x placement, both closed unions) via one LandingLink for every link on the page: header, hero GitHub pill, the hero's hosted/offline choice, the closing card. get-token vs offline-guide from the hero is the direct read on which path visitors want. - landing.demo_tab_selected: which demo use case a visitor picks (the auto-playing first tab is not counted). - GetStartedButton (only caller was the closing card) folds into it as a tracked link. - docs/otel.md: new landing.* table; the Content section no longer claims /docs is tracked — it never mounted ReadingTelemetry. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- docs/otel.md | 28 ++++++- packages/web/src/app/page.tsx | 37 ++++++--- .../src/components/landing/BeforeAfter.tsx | 5 +- .../src/components/landing/FeatureBento.tsx | 5 +- .../src/components/landing/GetStartedCard.tsx | 43 ++++++++--- .../web/src/components/landing/HowItWorks.tsx | 5 +- .../src/components/landing/LandingHeader.tsx | 31 +++++--- .../src/components/landing/LandingHero.tsx | 44 +++++++---- .../src/components/landing/LandingLink.tsx | 71 ++++++++++++++++++ .../src/components/landing/Limitations.tsx | 5 +- .../components/landing/TerminalTheater.tsx | 20 ++++- .../src/components/landing/WhereLoreLives.tsx | 5 +- .../src/components/landing/sections.spec.ts | 23 ++++++ .../web/src/components/landing/sections.ts | 44 +++++++++++ .../src/components/learn/GetStartedButton.tsx | 22 ------ packages/web/src/lib/analytics/reading.ts | 8 +- packages/web/src/lib/analytics/track.spec.ts | 75 +++++++++++++++++++ packages/web/src/lib/analytics/track.ts | 47 ++++++++++++ 18 files changed, 434 insertions(+), 84 deletions(-) create mode 100644 packages/web/src/components/landing/LandingLink.tsx create mode 100644 packages/web/src/components/landing/sections.spec.ts create mode 100644 packages/web/src/components/landing/sections.ts delete mode 100644 packages/web/src/components/learn/GetStartedButton.tsx diff --git a/docs/otel.md b/docs/otel.md index e79141532..e8ee00331 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`, `the-problem`, +`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`/`code-conventions`/`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/page.tsx b/packages/web/src/app/page.tsx index a31a4241c..7550afa10 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; import { AuthHashCatcher } from '@/components/auth/AuthHashCatcher'; +import { ReadingTelemetry } from '@/components/content/ReadingTelemetry'; import { BeforeAfter } from '@/components/landing/BeforeAfter'; import { FeatureBento } from '@/components/landing/FeatureBento'; import { GetStartedCard } from '@/components/landing/GetStartedCard'; @@ -10,6 +11,7 @@ import { LandingBackdrop } from '@/components/landing/LandingBackdrop'; import { LandingHeader } from '@/components/landing/LandingHeader'; import { LandingHero } from '@/components/landing/LandingHero'; 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'; @@ -89,22 +91,39 @@ export default async function RootPage({ // // `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/landing/BeforeAfter.tsx b/packages/web/src/components/landing/BeforeAfter.tsx index 7caca4282..938cf6e89 100644 --- a/packages/web/src/components/landing/BeforeAfter.tsx +++ b/packages/web/src/components/landing/BeforeAfter.tsx @@ -3,6 +3,7 @@ import { Check, X } from 'lucide-react'; import { cn } from '@/lib/cn'; import { SectionHeading } from './SectionHeading'; +import { LANDING_SECTION_ID } from './sections'; type TranscriptLine = { day: string; @@ -50,9 +51,9 @@ const WITH: TranscriptLine[] = [ */ export function BeforeAfter() { return ( -
+
diff --git a/packages/web/src/components/landing/FeatureBento.tsx b/packages/web/src/components/landing/FeatureBento.tsx index a29b177c5..b8d0624be 100644 --- a/packages/web/src/components/landing/FeatureBento.tsx +++ b/packages/web/src/components/landing/FeatureBento.tsx @@ -4,6 +4,7 @@ import { Archive, BarChart3, GitPullRequest, Plug } 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 @@ -26,9 +27,9 @@ import { SectionHeading } from './SectionHeading'; */ export function FeatureBento() { return ( -
+
diff --git a/packages/web/src/components/landing/GetStartedCard.tsx b/packages/web/src/components/landing/GetStartedCard.tsx index be4d7c07f..7bf26fa95 100644 --- a/packages/web/src/components/landing/GetStartedCard.tsx +++ b/packages/web/src/components/landing/GetStartedCard.tsx @@ -1,9 +1,7 @@ -import Link from 'next/link'; - -import { GetStartedButton } from '@/components/learn/GetStartedButton'; - 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)]'; @@ -24,12 +22,13 @@ const INLINE_LINK_CLASS = * * 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. + * 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 @@ -45,7 +44,7 @@ export function GetStartedCard() { get started

Give your agents a memory. @@ -76,14 +75,24 @@ export function GetStartedCard() { 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.

@@ -105,7 +114,17 @@ export function GetStartedCard() { - +
+ + Explore docs & guides + → + +
); diff --git a/packages/web/src/components/landing/HowItWorks.tsx b/packages/web/src/components/landing/HowItWorks.tsx index ebdd4f748..3fe362086 100644 --- a/packages/web/src/components/landing/HowItWorks.tsx +++ b/packages/web/src/components/landing/HowItWorks.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { cn } from '@/lib/cn'; import { SectionHeading } from './SectionHeading'; +import { LANDING_SECTION_ID } from './sections'; import { StepBadge } from './StepBadge'; type Step = { @@ -52,9 +53,9 @@ const STEPS: Step[] = [ export function HowItWorks() { return ( -
+
diff --git a/packages/web/src/components/landing/LandingHeader.tsx b/packages/web/src/components/landing/LandingHeader.tsx index fab5a6d70..89f71e15a 100644 --- a/packages/web/src/components/landing/LandingHeader.tsx +++ b/packages/web/src/components/landing/LandingHeader.tsx @@ -4,6 +4,8 @@ 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 = @@ -21,6 +23,11 @@ const HEADER_TEXT_LINK_CLASS = * `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` @@ -48,28 +55,30 @@ export function LandingHeader({ action }: { action?: ReactNode }) {
diff --git a/packages/web/src/components/landing/LandingHero.tsx b/packages/web/src/components/landing/LandingHero.tsx index 58e67433e..38c619c34 100644 --- a/packages/web/src/components/landing/LandingHero.tsx +++ b/packages/web/src/components/landing/LandingHero.tsx @@ -1,9 +1,11 @@ -import Link from 'next/link'; 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 @@ -26,21 +28,33 @@ const TRUST_POINTS = [ * 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 = [ +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', - cta: 'Get a token', + 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', - cta: 'Offline guide', + linkText: 'Offline guide', + cta: 'offline-guide', }, -] as const; +]; /** * The home page's first screen. @@ -68,13 +82,13 @@ const NEXT_STEPS = [ export function LandingHero() { return (
- @@ -86,10 +100,10 @@ export function LandingHero() { aria-hidden className="size-3 transition-transform duration-200 group-hover:translate-x-0.5" /> - +

@@ -130,16 +144,18 @@ export function LandingHero() { {step.label} {step.body} - - {step.cta} + {step.linkText} - + ))} 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/Limitations.tsx b/packages/web/src/components/landing/Limitations.tsx index 2afbf7863..844facb94 100644 --- a/packages/web/src/components/landing/Limitations.tsx +++ b/packages/web/src/components/landing/Limitations.tsx @@ -1,4 +1,5 @@ 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.', @@ -21,9 +22,9 @@ const LIMITS = [ */ export function Limitations() { return ( -
+
@@ -583,7 +590,14 @@ export function TerminalTheater() { {USE_CASES.map((uc, i) => (

- Your agent works something out — why the build only breaks in CI, which migration - needs the flag — and forgets it when the session ends. LoreKit writes it down and - reads it back in every session after, on every machine, in every tool. + 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.

+ + 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. */} +
+
+ + ▲ + + + 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 ( +
  • +
    + {/* Opaque, and above the rail, so the rail reads as running THROUGH the + icons rather than over them. */} + + + +
    +

    + {stage.when} +

    +

    {stage.verb}

    +
    +
    + +
    +
    + + {stage.stat} + + {stage.statCaption} +
    + +
      + {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/TerminalTheater.tsx b/packages/web/src/components/landing/TerminalTheater.tsx index 4a1fec659..32134b21a 100644 --- a/packages/web/src/components/landing/TerminalTheater.tsx +++ b/packages/web/src/components/landing/TerminalTheater.tsx @@ -121,41 +121,55 @@ const USE_CASES: UseCase[] = [ ], }, { - id: 'code-conventions', - 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 }, ], }, { @@ -494,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; diff --git a/packages/web/src/components/landing/sections.ts b/packages/web/src/components/landing/sections.ts index 08e88e058..585eeed92 100644 --- a/packages/web/src/components/landing/sections.ts +++ b/packages/web/src/components/landing/sections.ts @@ -15,7 +15,7 @@ export const LANDING_SECTION_ID = { hero: 'hero-title', liveDemo: 'live-demo', - problem: 'the-problem', + learningLoop: 'learning-loop', howItWorks: 'how-it-works', whatYouGet: 'what-you-get', whereLoreLives: 'where-lore-lives', @@ -35,7 +35,7 @@ export const LANDING_SECTION_ID = { export const LANDING_SECTION_ORDER = [ LANDING_SECTION_ID.hero, LANDING_SECTION_ID.liveDemo, - LANDING_SECTION_ID.problem, + LANDING_SECTION_ID.learningLoop, LANDING_SECTION_ID.howItWorks, LANDING_SECTION_ID.whatYouGet, LANDING_SECTION_ID.whereLoreLives, diff --git a/packages/web/src/lib/analytics/track.spec.ts b/packages/web/src/lib/analytics/track.spec.ts index 02a098c57..da6bf69b2 100644 --- a/packages/web/src/lib/analytics/track.spec.ts +++ b/packages/web/src/lib/analytics/track.spec.ts @@ -230,7 +230,7 @@ describe('track — content.* on the home page', () => { name: 'content.section_read', contentType: 'landing', slug: 'home', - sectionId: 'the-problem', + sectionId: 'learning-loop', sectionIndex: 2, dwellMs: 7_200, }); @@ -238,7 +238,7 @@ describe('track — content.* on the home page', () => { expect(lastCall().attributes).toEqual({ 'lorekit.content.type': 'landing', 'lorekit.content.slug': 'home', - 'lorekit.content.section.id': 'the-problem', + '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', diff --git a/packages/web/src/lib/analytics/track.ts b/packages/web/src/lib/analytics/track.ts index 1e87d70a5..1bfd4221c 100644 --- a/packages/web/src/lib/analytics/track.ts +++ b/packages/web/src/lib/analytics/track.ts @@ -173,7 +173,7 @@ export type LandingCta = 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' | 'code-conventions' | 'pr-review'; +export type LandingDemoTab = 'self-healing' | 'promotion' | 'pr-review'; /** Discriminated union of every tracked event. Add new events here. */ export type AnalyticsEvent = From 356c095cec4350caa8150cc7e1b0292da013cb0e Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Sun, 11 Oct 2026 06:06:51 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(web):=20un-overlap=2001=20and=2002=20?= =?UTF-8?q?=E2=80=94=20loop=20is=20the=20why,=20how-it-works=20is=20setup?= =?UTF-8?q?=20+=20team?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the preview review: - 01 visual bugs: the rail through the stage icons ran between each stage's day label and verb (labels sat beside the icon on the rail's line) — labels now sit under the icon. Transcripts get a fixed four-line height so outcomes line up across cards (no more bottom-pinned text with gaps). The "and round again" label no longer masks the dashed line with an opaque box that didn't match the glow; it sits under the line. - 02 retold 01's loop (write -> store -> read). Now it answers how the day-4 teammate got the lesson: connect each agent (install's real status lines; Cursor is three copied files), lessons stay with their repo (narrow -> broad scope ladder in the scope colours), one shared set for the team (bind repo::acme/* to the org; members read+write one copy, CI reads with an lk_ro_ token, unbound stays personal). "Wire it once. The whole team learns." No arrow labels to cramp. - Bento: "One store for every tool" duplicated the new team step; replaced with "Every lesson in plain sight" (Lore Explorer + greppable markdown). Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../src/components/landing/FeatureBento.tsx | 60 +++-- .../web/src/components/landing/HowItWorks.tsx | 250 ++++++++++-------- .../src/components/landing/LearningLoop.tsx | 34 +-- 3 files changed, 203 insertions(+), 141 deletions(-) diff --git a/packages/web/src/components/landing/FeatureBento.tsx b/packages/web/src/components/landing/FeatureBento.tsx index b8d0624be..2e2a8e692 100644 --- a/packages/web/src/components/landing/FeatureBento.tsx +++ b/packages/web/src/components/landing/FeatureBento.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { Archive, BarChart3, GitPullRequest, Plug } from 'lucide-react'; +import { Archive, BarChart3, Eye, GitPullRequest } from 'lucide-react'; import { cn } from '@/lib/cn'; @@ -21,7 +21,10 @@ import { LANDING_SECTION_ID } from './sections'; * (`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 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. */ @@ -56,10 +59,10 @@ export function FeatureBento() { visual={} /> } - title="One store for every tool" - body="Claude Code, Cursor and any MCP client read and write the same lessons — on your laptop, your teammate’s, and in CI with a read-only token." - visual={} + icon={} + 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={} /> -
    - {tools.map((tool) => ( - - {tool} - - ))} +
    +
    + ⌕ + search lore…
    - - - one store - - Codex: experimental +
      + {rows.map((row) => ( +
    • + + {row.scope} + + {row.key} + + {row.agent} + +
    • + ))} +
    ); } diff --git a/packages/web/src/components/landing/HowItWorks.tsx b/packages/web/src/components/landing/HowItWorks.tsx index 3fe362086..861550e14 100644 --- a/packages/web/src/components/landing/HowItWorks.tsx +++ b/packages/web/src/components/landing/HowItWorks.tsx @@ -3,51 +3,51 @@ import type { ReactNode } from 'react'; import { cn } from '@/lib/cn'; import { SectionHeading } from './SectionHeading'; -import { LANDING_SECTION_ID } from './sections'; import { StepBadge } from './StepBadge'; +import { LANDING_SECTION_ID } from './sections'; type Step = { title: string; body: string; - /** What the node looks like in practice — an illustration, `aria-hidden`. */ + /** What the step looks like in practice — an illustration, `aria-hidden`. */ visual: ReactNode; - /** The MCP call that carries the lesson on to the NEXT step, if any. */ - handoff?: 'memory.write' | 'memory.list'; }; /** - * "02 / how it works" — the mechanism as one left-to-right flow: - * session A → `memory.write` → one store → `memory.list` → session B. + * "02 / how it works" — from one laptop to the whole team. * - * This replaces two things that said the same thing twice: the README's ASCII - * box diagram (which rendered with its top-right corner one column short of the - * right edge, left-aligned in a frame twice its width) and three separate step - * cards joined by a rule that only showed as stubs in the gaps between them. - * Now each step IS a node of the diagram, so the picture and the prose are one - * object, and the calls that connect them are labelled on the arrows. + * 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: * - * The example is the same lesson the live demo above writes (`fix/ssr-window`), - * so a reader who watched the demo sees the same lesson take the same path. - * - * Below `lg` the flow turns vertical and the arrows point down. + * - `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: 'Your agent writes what it learns', - body: 'One memory.write call when something is worth keeping — a gotcha, a convention, a fix that took three attempts.', - visual: , - handoff: 'memory.write', + 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: 'The lesson is stored under a scope', - body: 'This branch, this repo, this project, or everywhere. The scope is what keeps one repository’s lore out of another’s.', - visual: , - handoff: 'memory.list', + 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: 'The next session reads it back', - body: 'Narrowest scope first, then wider — so an agent gets this repo’s lessons without drowning in everything else you have ever recorded.', - 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: , }, ]; @@ -59,32 +59,36 @@ export function HowItWorks() { eyebrow="02 / how it works" title={ <> - One store. Every agent, every machine. + Wire it once.{' '} + The whole team learns. } - lede="Two MCP tool calls are the whole mechanic. The CLI wires the hooks that make your agent call them." + 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." /> - {/* `gap-x-20` is the arrow's runway: the horizontal handoff arrow lives in - that gap, so its width and the gap are the same number. */} -
      +
        {STEPS.map((step, index) => ( -
      1. -
        +
      2. + {/* 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} -

        +
        +

        + {step.title} +

        +

        + {step.body} +

        +
        - - {step.handoff && }
      3. ))}
      @@ -92,84 +96,124 @@ export function HowItWorks() { ); } -/** - * The labelled arrow from one step to the next. Two renderings of the same - * thing: vertical between stacked steps below `lg`, and horizontal in the grid - * gap from `lg` up. The horizontal one is placed so its LINE, not its label, - * meets the nodes at their middle: 44px top + 15px label + 4px gap + 5px to the - * line's centre = 68px, half of the 136px node height. - */ -function Handoff({ call }: { call: Step['handoff'] }) { - return ( - <> -
      - - {call} - ▼ -
      - -
      - {call} - - - ▶ - -
      - - ); -} - -// ─── Nodes ────────────────────────────────────────────────────────────────── -// One fixed height so the handoff arrows meet every node at its middle. +// ─── Visuals ──────────────────────────────────────────────────────────────── -const NODE = - 'flex h-[136px] flex-col justify-center gap-1.5 rounded-xl px-4 text-left font-mono text-[11px] leading-relaxed'; +const PANEL = + 'flex h-full flex-col rounded-2xl p-4 font-mono text-[11px] leading-relaxed'; -function NodeLabel({ children }: { children: ReactNode }) { +function PanelLabel({ children }: { children: ReactNode }) { return ( - + {children} ); } -function SessionANode() { +/** 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 ( -
      - session A · any tool - ✗ window is not defined - - agent › fixed with a typeof guard - +
      + your machine · once +

      + $ + npx @lorekit/cli install +

      +
        + {rows.map((row) => ( +
      • + + {row.mark} + + {row.label} + + {row.value} + +
      • + ))} +
      ); } -/** The store is the product, so it is the node that gets the amber frame. */ -function StoreNode() { +/** + * 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 ( -
      - one store · local or hosted - - repo::acme/web - - - fix/ssr-window{' '} - “guard window with typeof” - +
      + read order · narrow → broad +
        + {scopes.map((row) => ( +
      • + + {row.scope} + + {row.order} +
      • + ))} +
      ); } -function SessionBNode() { +/** 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 ( -
      - session B · any machine - ↳ 1 lesson loaded - ✓ right the first time +
      + org · acme +
        + {members.map((member) => ( +
      • + {member.who} + + {member.access} + +
      • + ))} +
      + ↓ ↑ +
      + repo::acme/* + + one shared copy + +
      ); } diff --git a/packages/web/src/components/landing/LearningLoop.tsx b/packages/web/src/components/landing/LearningLoop.tsx index a3b0f36a6..94b19fb7b 100644 --- a/packages/web/src/components/landing/LearningLoop.tsx +++ b/packages/web/src/components/landing/LearningLoop.tsx @@ -173,15 +173,17 @@ export function LearningLoop() {
    {/* The loop closing: from the last beat back to the first. Wide screens - only — stacked, the cards already read top to bottom. */} + 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 - +

    @@ -198,21 +200,21 @@ function StageCard({ stage, index }: { stage: Stage; index: number }) { className="lk-step relative flex flex-col" style={{ '--lk-step': index } as CSSProperties} > -
    - {/* Opaque, and above the rail, so the rail reads as running THROUGH the - icons rather than over them. */} + {/* 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.when} +

    +

    {stage.verb}

    {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}