Merge branch 'main' into feat/aibook-fun-deploy

This commit is contained in:
plt
2026-09-02 09:51:34 +00:00
24 changed files with 2183 additions and 90 deletions
+11
View File
@@ -33,3 +33,14 @@ Note: Framer Sites don't run Next.js natively. For a live site with routing (A/B
- 3 sample tiers shown ($10 / $47yr / $4,888)
- No NFT/crypto/Web3 language (Kickstarter compliance scrubbed)
- Event tracking only (not content) — PostHog/Cloudflare Analytics slot available
## Reading demo
A sample reading environment is wired into this Next.js app (pages router).
```bash
npm install
npm run dev
```
Then open [http://localhost:3000/read](http://localhost:3000/read). One chapter is filled (Real Estate, with nested RE-01 and RE-02). This is a demo, not the live book.
+21 -8
View File
@@ -1,12 +1,29 @@
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
const LINKS = [
{ href: "#recipe", label: "A recipe" },
{ href: "#contents", label: "Contents" },
{ href: "#letter", label: "Why" },
{ href: "#faq", label: "FAQ" },
{ href: "/read", label: "Library" },
];
function NavItem({ href, label, onClick }) {
if (href.startsWith("/")) {
return (
<Link href={href} onClick={onClick}>
{label}
</Link>
);
}
return (
<a href={href} onClick={onClick}>
{label}
</a>
);
}
export default function Nav() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
@@ -39,16 +56,14 @@ export default function Nav() {
return (
<nav ref={navRef} className={`nav${scrolled ? " scrolled" : ""}${open ? " open" : ""}`}>
<div className="nav-in">
<a className="logo" href="#top" onClick={close}>
<Link className="logo" href="/" onClick={close}>
AIBook<b>.</b>
<span className="dot">fun</span>
</a>
</Link>
<div className="nav-links">
{LINKS.map((l) => (
<a key={l.href} href={l.href}>
{l.label}
</a>
<NavItem key={l.href} href={l.href} label={l.label} />
))}
</div>
@@ -70,9 +85,7 @@ export default function Nav() {
<div className="nav-menu" id="mobile-menu">
{LINKS.map((l) => (
<a key={l.href} href={l.href} onClick={close}>
{l.label}
</a>
<NavItem key={l.href} href={l.href} label={l.label} onClick={close} />
))}
<a href="#get" className="btn btn-primary" onClick={close}>
Get the preview
+1 -1
View File
@@ -34,7 +34,7 @@ export default function Recipe() {
<div className="recipe reveal">
<div className="recipe-top">
<span className="recipe-id">
recipe <b>RE-04</b> · v1.2 · ~15 min
recipe <b>RE-01</b> · v1.2 · ~15 min
</span>
<span className="recipe-vert">Real Estate</span>
</div>
@@ -0,0 +1,60 @@
import { collectChapterKit } from "../../data/sample-chapter";
export default function ChapterKit({ recipes }) {
const kit = collectChapterKit(recipes);
if (!kit.tools.length && !kit.files.length) return null;
return (
<section className="chapter-kit" id="need" aria-label="What you need">
<h2 className="chapter-kit-title">What you need</h2>
<p className="chapter-kit-lede">
{recipes.length} Try-it{recipes.length === 1 ? "" : "s"} on sample data. Nothing
from a live client file.
</p>
<dl className="kit-grid">
<div>
<dt>Tools</dt>
<dd>
<ul>
{kit.tools.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
</dd>
</div>
<div>
<dt>Files</dt>
<dd>
<ul>
{kit.files.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
</dd>
</div>
<div>
<dt>Model</dt>
<dd>
<ul>
{kit.models.map((m) => (
<li key={m}>{m}</li>
))}
</ul>
</dd>
</div>
<div>
<dt>Try-its</dt>
<dd>
<ul>
{kit.tryits.map((t) => (
<li key={t.id}>
{t.id} · {t.time} · {t.cost}
</li>
))}
</ul>
</dd>
</div>
</dl>
</section>
);
}
@@ -0,0 +1,99 @@
import { useEffect, useMemo, useState } from "react";
function OutlineBody({ groups, complete, count, total, active, showTitle }) {
return (
<>
<p className="outline-progress">
{count}/{total} Try-its complete
</p>
{showTitle ? <p className="outline-heading">In this chapter</p> : null}
{groups.map((g) => (
<div key={g.label} className="outline-group">
<p className="outline-group-label spec">{g.label}</p>
<ul>
{g.items.map((item) => {
const done = item.recipeId ? !!complete[item.recipeId] : false;
const isActive = active === item.id;
return (
<li key={item.id}>
<a
href={`#${item.id}`}
className={isActive ? "is-active" : undefined}
aria-current={isActive ? "location" : undefined}
>
<span>{item.label}</span>
{done ? (
<span className="outline-done" aria-label="Try-it complete">
✓
</span>
) : null}
</a>
</li>
);
})}
</ul>
</div>
))}
</>
);
}
export default function ChapterOutline({
groups,
complete = {},
count = 0,
total = 0,
variant = "desktop",
}) {
const ids = useMemo(
() => groups.flatMap((g) => g.items.map((item) => item.id)),
[groups]
);
const [active, setActive] = useState(ids[0] || "");
useEffect(() => {
const els = ids.map((id) => document.getElementById(id)).filter(Boolean);
if (!els.length || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]?.target?.id) setActive(visible[0].target.id);
},
{ rootMargin: "-88px 0px -55% 0px", threshold: [0, 0.1, 0.25, 0.5, 1] }
);
els.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [ids]);
if (variant === "mobile") {
return (
<details className="chapter-outline-mobile">
<summary>In this chapter</summary>
<OutlineBody
groups={groups}
complete={complete}
count={count}
total={total}
active={active}
showTitle={false}
/>
</details>
);
}
return (
<nav className="chapter-outline" aria-label="In this chapter">
<OutlineBody
groups={groups}
complete={complete}
count={count}
total={total}
active={active}
showTitle
/>
</nav>
);
}
@@ -0,0 +1,24 @@
import Link from "next/link";
export default function ReaderCrumbs({ items }) {
return (
<nav className="crumbs" aria-label="Breadcrumb">
<ol>
{items.map((item, i) => (
<li key={item.label}>
{i > 0 ? (
<span className="crumbs-sep" aria-hidden="true">
/
</span>
) : null}
{item.current || !item.href ? (
<span aria-current="page">{item.label}</span>
) : (
<Link href={item.href}>{item.label}</Link>
)}
</li>
))}
</ol>
</nav>
);
}
@@ -0,0 +1,32 @@
import Link from "next/link";
export default function ReaderFooter() {
return (
<footer className="footer reader-footer">
<div className="foot-in">
<div className="foot-top">
<div>
<div className="logo">
AIBook<b>.</b>
<span className="dot">fun</span>
</div>
<p className="foot-blurb">
Reading demo. Not the live book. Workflows sit inside a chapter,
not on a shelf of cards.
</p>
</div>
<div className="foot-links">
<Link href="/">Home</Link>
<Link href="/read">Library</Link>
<Link href="/#get">Preview</Link>
</div>
</div>
<div className="colophon">
© 2026 WeOwnAI · MIT-licensed docs · This page is a reading-environment demo.
<br />
Activation is stored in this browser only. No chatbot. No live client data.
</div>
</div>
</footer>
);
}
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import Link from "next/link";
export default function ReaderNav({ here = "library" }) {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<nav className={`nav reader-nav${scrolled ? " scrolled" : ""}`}>
<div className="nav-in">
<Link className="logo" href="/">
AIBook<b>.</b>
<span className="dot">fun</span>
</Link>
<div className="nav-links reader-links">
{here === "library" ? (
<span className="nav-current" aria-current="page">
Library
</span>
) : (
<Link href="/read" className="nav-title">
Library
</Link>
)}
</div>
<Link href="/#get" className="btn btn-primary nav-cta">
Get the preview
</Link>
</div>
</nav>
);
}
@@ -0,0 +1,60 @@
/** Same terminal chrome as the marketing Recipe (.term / .term-bar). */
export function TermRE01() {
return (
<div className="term" role="img" aria-label="Sample terminal output from the workflow">
<div className="term-bar">
<i></i>
<i></i>
<i></i>
<span>agent — property-brief · sample dataset (non-confidential)</span>
</div>
<pre>
<span className="c-dim">{"$ "}</span>
<span className="c-user">{"brief"}</span>
{" --input "}
<span className="c-key">{"sample_listings.csv"}</span>
{" --template market-1pager\n\n"}
<span className="c-dim">{"✓ redacted 3 fields (owner_name, phone, exact_address)\n✓ 41 comps in radius · median $/sqft computed\n\n"}</span>
<span className="c-ok">{"MARKET BRIEF — Maple Heights (sample)\n"}</span>
{" · Median list: $420/sqft (▲ 4.1% QoQ)\n · Days on market: 22 (was 31)\n · Inventory: 1.8 mo — seller-leaning\n · Read: price to comp, expect <2 wks at ask\n\n"}
<span className="c-dim">{"# cost this run: $0.03 · model: claude · 2,140 tokens"}</span>
</pre>
</div>
);
}
export function TermRE02() {
return (
<div className="term" role="img" aria-label="Sample terminal output from the listing-copy workflow">
<div className="term-bar">
<i></i>
<i></i>
<i></i>
<span>agent — listing-copy · sample comps (public facts)</span>
</div>
<pre>
<span className="c-dim">{"$ "}</span>
<span className="c-user">{"listing-copy"}</span>
{" --input "}
<span className="c-key">{"sample_comps.json"}</span>
{" --voice house-voice --out draft.md\n\n"}
<span className="c-dim">{"✓ used 6 public comps (no CRM fields present)\n✓ 184 words · reading grade 8\n\n"}</span>
<span className="c-ok">{"LISTING DRAFT · 14xx Cedar (sample street, public facts)\n"}</span>
{" · Bright corner lot, 3 bed / 2 bath, maple kitchen from 2019.\n · Priced to the block: comps closed $398–$441/sqft in 60 days.\n · No owner name, phone, or showing list in the prompt.\n\n"}
<span className="c-dim">{"# cost this run: $0.02 · model: claude · 1,280 tokens"}</span>
</pre>
</div>
);
}
const TERMS = {
"re-01": TermRE01,
"re-02": TermRE02,
};
export default function TermOutput({ id }) {
const Cmp = TERMS[id];
if (!Cmp) return null;
return <Cmp />;
}
@@ -0,0 +1,262 @@
import { useState } from "react";
import Link from "next/link";
import TermOutput from "./TermOutput";
const JUMP = [
{ id: "kit", label: "Kit" },
{ id: "problem", label: "Problem" },
{ id: "actors", label: "Actors" },
{ id: "setup", label: "Setup" },
{ id: "safeguards", label: "Safeguards" },
{ id: "method", label: "Method" },
{ id: "evidence", label: "Evidence" },
{ id: "tryit", label: "Try-it" },
];
function ShieldCheck() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 1.5 2.5 4v3.5c0 3 2.3 5.4 5.5 7 3.2-1.6 5.5-4 5.5-7V4L8 1.5Z"
stroke="#1C5D4C"
strokeWidth="1.3"
strokeLinejoin="round"
/>
<path
d="m5.6 8 1.7 1.7L10.6 6"
stroke="#1C5D4C"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CheckMark() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.4" />
<path
d="m5.2 8.2 1.8 1.8 3.8-4"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const el = document.createElement("textarea");
el.value = text;
el.setAttribute("readonly", "");
el.style.position = "fixed";
el.style.left = "-9999px";
document.body.appendChild(el);
el.select();
const ok = document.execCommand("copy");
el.remove();
return ok;
} catch {
return false;
}
}
}
function WorkflowKit({ recipe }) {
const k = recipe.kit;
if (!k) return null;
const rows = [
["You run", k.youRun],
["Tools", (k.tools || []).join(" · ")],
["Files", (k.files || []).join(" · ")],
["Model", k.model],
["Time", recipe.time],
["Cost", recipe.cost],
["You produce", k.artifact],
].filter(([, v]) => v);
return (
<dl className="workflow-kit" id={`${recipe.anchor}-kit`} aria-label="What this workflow uses">
{rows.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
);
}
function Field({ anchor, name, label, children }) {
return (
<div className="row" id={`${anchor}-${name}`}>
<h4 className="k">{label}</h4>
<div className="v">{children}</div>
</div>
);
}
export default function WorkflowBlock({
recipe,
complete = false,
onToggleComplete,
focused = false,
index = 1,
total = 1,
}) {
const [copied, setCopied] = useState(false);
const a = recipe.anchor;
const onShare = async () => {
const ok = await copyText(recipe.shareBlurb);
if (!ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
};
return (
<article
id={a}
className={`recipe recipe-nested${focused ? " recipe-focus" : ""}`}
aria-labelledby={`${a}-title`}
>
<div className="recipe-top">
<span className="recipe-id">
Workflow {index} of {total} · <b>{recipe.id}</b> · v{recipe.version} · {recipe.time} · {recipe.cost}
</span>
<span className="recipe-vert">{recipe.vertical}</span>
</div>
<div className="recipe-body">
<h3 className="recipe-title" id={`${a}-title`}>
{recipe.title}
</h3>
<WorkflowKit recipe={recipe} />
<nav className="recipe-jump" aria-label="In this workflow">
{JUMP.map((j) => (
<a key={j.id} href={`#${a}-${j.id}`}>
{j.label}
</a>
))}
</nav>
<Field anchor={a} name="problem" label="Problem">
{recipe.problem} <span className="sub">{recipe.problemJob}</span>
</Field>
<Field anchor={a} name="actors" label="Actors">
<ul className="plain-list">
{recipe.actors.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</Field>
<Field anchor={a} name="setup" label="Setup">
<ul className="plain-list">
{recipe.setup.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</Field>
<Field anchor={a} name="safeguards" label="Safeguards">
{recipe.safeguards.map((line) => (
<div className="guard" key={line}>
<ShieldCheck />
<span>{line}</span>
</div>
))}
</Field>
<Field anchor={a} name="method" label="Method">
<ol className="steps">
{recipe.method.map((step, i) => (
<li key={i}>
<div className="step-copy">
{step.text}
{step.term ? <TermOutput id={step.term} /> : null}
</div>
</li>
))}
</ol>
</Field>
<Field anchor={a} name="evidence" label="Evidence">
{recipe.evidence.map((line) => (
<p key={line} className="evidence-p">
{line}
</p>
))}
</Field>
<Field anchor={a} name="tryit" label="Try-it">
<p>
About {recipe.tryit.minutes} minutes. {recipe.tryit.sample}{" "}
{recipe.tryit.produce}
</p>
<p className="sub-line">
{recipe.tryit.done}{" "}
<a className="inline-sample" href={recipe.tryit.href} download>
{recipe.tryit.hrefLabel}
</a>
</p>
<p>
<span className="chip">◷ {recipe.tryit.chip}</span>
</p>
{onToggleComplete ? (
<div className="tryit-actions">
<button
type="button"
className={`btn ${complete ? "btn-primary tryit-done" : "btn-ghost"}`}
aria-pressed={complete}
onClick={() => onToggleComplete(recipe.id)}
>
{complete ? <CheckMark /> : null}
{complete ? "Counted as activation (demo)" : "Mark complete"}
</button>
</div>
) : null}
</Field>
<Field anchor={a} name="share" label="Share">
<p>Copy a short result line. Share the outcome, not the recipe text.</p>
<div className="tryit-actions">
<button type="button" className="btn btn-ghost" onClick={onShare}>
{copied ? "Copied" : "Copy result line"}
</button>
</div>
</Field>
<Field anchor={a} name="record" label="Record">
<div className="recipe-record">
<span>Author: {recipe.author}</span>
<span>Sources: {recipe.sources}</span>
<span>Last updated: {recipe.lastUpdated}</span>
{focused ? (
<span>
<Link href={`/read/real-estate#${recipe.anchor}`}>
Back to the chapter
</Link>
</span>
) : (
<span>
<Link href={`/read/recipe/${recipe.id}`}>Open on paper</Link>
</span>
)}
</div>
</Field>
</div>
</article>
);
}
@@ -0,0 +1,35 @@
export default function WorkflowRail({ recipes, complete = {}, total }) {
return (
<aside className="chapter-rail" aria-label="Workflows in this chapter">
<p className="spec">Workflows in this chapter</p>
<ol className="rail-list">
{recipes.map((r, i) => {
const done = !!complete[r.id];
return (
<li key={r.id}>
<a className={`rail-card${done ? " is-done" : ""}`} href={`#${r.anchor}`}>
<span className="rail-kicker">
{i + 1} of {total} · {r.id}
</span>
<span className="rail-title">{r.shortTitle}</span>
<span className="rail-meta">
{r.time} · {r.cost}
{done ? " · done" : ""}
</span>
{r.kit && r.kit.tools && r.kit.tools.length ? (
<span className="rail-kit">{r.kit.tools.join(" · ")}</span>
) : null}
{r.kit && r.kit.artifact ? (
<span className="rail-kit rail-out">{r.kit.artifact}</span>
) : null}
</a>
</li>
);
})}
</ol>
<a className="rail-skip" href={`#${recipes[0]?.anchor}-tryit`}>
Skip to the first Try-it
</a>
</aside>
);
}
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useMemo, useState } from "react";
function keyFor(id) {
return `aibook-demo-activation:${id}`;
}
/**
* Demo-only activation state. Completing a Try-it writes
* localStorage key `aibook-demo-activation:<RECIPE-ID>`.
*/
export default function useActivation(ids) {
const list = useMemo(() => ids, [ids.join("|")]);
const [complete, setComplete] = useState(() =>
Object.fromEntries(list.map((id) => [id, false]))
);
useEffect(() => {
const next = {};
list.forEach((id) => {
try {
next[id] = window.localStorage.getItem(keyFor(id)) === "1";
} catch {
next[id] = false;
}
});
setComplete(next);
}, [list]);
const toggle = useCallback((id) => {
setComplete((prev) => {
const value = !prev[id];
try {
window.localStorage.setItem(keyFor(id), value ? "1" : "0");
} catch {
/* private mode: still toggle in memory */
}
return { ...prev, [id]: value };
});
}, []);
const count = list.filter((id) => complete[id]).length;
return { complete, toggle, count, total: list.length };
}
+311
View File
@@ -0,0 +1,311 @@
/**
* Sample chapter + nested recipes for the reading demo.
* Imported by pages under /read. Not a CMS.
*/
export const LIBRARY = [
{
num: "01",
name: "Real Estate",
slug: "real-estate",
href: "/read/real-estate",
desc: "Market briefs and listings, without leaking client data",
status: "ready",
},
{
num: "02",
name: "Financial Advisors",
slug: "financial-advisors",
desc: "Portfolio summaries that stay compliant and reviewed",
status: "coming",
},
{
num: "03",
name: "Fractional C-Suite",
slug: "fractional-c-suite",
desc: "Board updates and plans with real escalation paths",
status: "coming",
},
{
num: "04",
name: "Higher Ed",
slug: "higher-ed",
desc: "Course and research workflows with academic integrity",
status: "coming",
},
{
num: "05",
name: "Communities",
slug: "communities",
desc: "Moderation and programming that respects members",
status: "coming",
},
{
num: "06",
name: "Technology",
slug: "technology",
desc: "Agent patterns, evals, and the responsible defaults",
status: "coming",
},
];
const RE01 = {
id: "RE-01",
anchor: "re-01",
version: "1.2",
time: "~15 min",
cost: "≈ $0.03",
vertical: "Real Estate",
shortTitle: "Market brief",
title: "Draft a property-market brief with an AI agent without leaking client data.",
problem:
"You need a neighborhood market brief for a client meeting this week. The listing sheet and client notes cannot go into a public model.",
problemJob:
"Job: turn messy local data into a credible one-pager in minutes, safely.",
actors: [
"Listing agent runs it.",
"Client receives the brief.",
"Model may draft and classify. A human must review before send.",
],
setup: [
"Tools: local redaction script (brief CLI), Claude or your usual model.",
"Sample files: sample_listings.csv, market-1pager template.",
"Cost: about $0.03 on the sample run (2,140 tokens).",
],
safeguards: [
"Redaction runs on your machine. Raw client data never reaches the model.",
"A human checklist is required before the brief is shared. The checklist is part of the recipe.",
"Stop if identifiers remain after the strip report. Do not prompt until owner_name, phone, and exact_address are gone.",
],
method: [
{
text: "Strip identifiers from the source data locally (a provided script does this).",
},
{
text: "Prompt the agent with the anonymized sample and a fixed brief template.",
term: "re-01",
},
{
text: "Review the draft against the checklist before anything leaves your machine.",
},
],
evidence: [
"A one-pager brief on Maple Heights (sample), ready for a meeting once a human signs the checklist.",
"Sample metrics from that run: median $420/sqft, 22 days on market, 1.8 months of inventory.",
],
tryit: {
minutes: 15,
sample: "Use the included sample_listings.csv (no live client file).",
produce: "Produce the market-1pager brief and walk the human checklist.",
done: "Mark complete when you have a brief you would send after review. Completing this is the activation demo.",
href: "/samples/sample_listings.csv",
hrefLabel: "sample_listings.csv",
chip: "cost to try ≈ $0.03",
},
shareBlurb:
"Ran RE-01 on sample comps. Maple Heights median $420/sqft, 22 DOM.",
author: "Founding library · sample",
sources: "Sample MLS packet (non-confidential); market-1pager template shipped with the recipe.",
lastUpdated: "31 Aug 2026",
kit: {
youRun: "Listing agent",
tools: ["brief CLI (local redaction)", "Claude"],
files: ["sample_listings.csv", "market-1pager template"],
model: "Claude",
artifact: "One-pager market brief",
},
};
const RE02 = {
id: "RE-02",
anchor: "re-02",
version: "1.0",
time: "~12 min",
cost: "≈ $0.02",
vertical: "Real Estate",
shortTitle: "Listing copy",
title: "Rewrite a listing description from public comps without pasting the client address book.",
problem:
"You need listing copy that sounds like this house, not every house on the street. The CRM holds owner notes and the showing list. Those do not go into a public model.",
problemJob:
"Job: draft 180-word listing copy from public facts only, then edit it yourself.",
actors: [
"Listing agent runs it.",
"The public listing receives the copy.",
"Model drafts. A human edits, files, and posts to MLS.",
],
setup: [
"Tools: listing-copy CLI, listing-voice template, Claude or your usual model.",
"Sample files: sample_comps.json (public comps only).",
"Cost: about $0.02 on the sample run (1,280 tokens).",
],
safeguards: [
"Prompt only with public listing facts. No CRM export, no showing list, no owner notes.",
"A human edits the draft before it hits MLS. Do not auto-post.",
"Stop if a private note, phone, or exact owner name appears in the draft.",
],
method: [
{
text: "Export public comps for the block. Leave the CRM closed.",
},
{
text: "Run listing-copy against the sample comps and the house-voice template.",
term: "re-02",
},
{
text: "Edit the draft. Cut anything that could only have come from a private note.",
},
],
evidence: [
"A 184-word listing draft, public facts only, reading grade 8.",
"Comps on the block closed between $398 and $441/sqft in 60 days. The draft prices to that range without naming a client.",
],
tryit: {
minutes: 12,
sample: "Use the included sample_comps.json (public comps, no address book).",
produce: "Produce a ~180-word listing description and a human edit pass.",
done: "Mark complete when the draft has no private fields. Completing this is the activation demo.",
href: "/samples/sample_comps.json",
hrefLabel: "sample_comps.json",
chip: "cost to try ≈ $0.02",
},
shareBlurb:
"Ran RE-02 on sample comps. 184-word listing draft, public facts only.",
author: "Founding library · sample",
sources: "Public comps sample packet; listing-voice template shipped with the recipe.",
lastUpdated: "31 Aug 2026",
kit: {
youRun: "Listing agent",
tools: ["listing-copy CLI", "Claude"],
files: ["sample_comps.json", "listing-voice template"],
model: "Claude",
artifact: "180-word listing draft",
},
};
export const RECIPES = [RE01, RE02];
export const RECIPES_BY_ID = {
"RE-01": RE01,
"RE-02": RE02,
// Old demo URLs. Displayed ID is RE-01 / RE-02.
"RE-04": RE01,
"RE-05": RE02,
};
export const CHAPTER = {
slug: "real-estate",
vertical: "Real Estate",
edition: "2026",
authors: "Founding library · sample",
title: "Work the listing without leaking the client",
promise:
"In this chapter you run two jobs a realtor actually has this week, on sample data, without putting a client file in a public model.",
lastUpdated: "31 Aug 2026",
skip: [
{ id: "RE-01", href: "#re-01", title: "Market brief" },
{ id: "RE-02", href: "#re-02", title: "Listing copy" },
],
stakes: {
id: "stakes",
heading: "The stakes",
paragraphs: [
"A listing that stalls because the market brief is late is a lost weekend. A listing that leaks because you pasted the client sheet into a public model is a lost license. Real estate work runs on both clocks: the showing at 4pm, and the file that must never leave the office.",
"The failure is not that AI sits unused. It is a one-pager that is late, generic, or built from a client name that should not have been in the prompt. Buyers notice fluff. Brokers notice a privacy incident. Neither is recoverable in the same week.",
],
},
constraints: {
id: "constraints",
heading: "Constraints you cannot ignore",
paragraphs: [
"You already work in MLS exports, a CRM, and a phone that rings during the write-up. Done is a brief the client can read in a meeting, with numbers you can stand behind, reviewed by a human. Public models are useful for draft and classify. They are not a filing cabinet.",
"Owner names, phones, exact addresses, and showing lists stay on your machine. Comps, medians, and days on market can travel, once identifiers are stripped. If a field could pick one household out of the block, it does not go in the prompt.",
],
},
practice: {
id: "practice",
heading: "How we work here",
paragraphs: [
"Redact locally. Prompt with a sample or a stripped file. Review against a checklist. Share the artifact, not the workflow text. The first job this week is the neighborhood market brief. Run it on sample comps before you touch a live listing.",
],
},
between: {
id: "between",
heading: "The brief is half the meeting",
paragraphs: [
"The brief gets you into the room with numbers. The other half is the listing copy: public comps, a description that does not sound like every other house on the street, and an address book that never belongs in the prompt. Same rule. Different job.",
],
},
further: [
{ id: "RE-01", href: "#re-01", title: "Draft a property-market brief without leaking client data" },
{ id: "RE-02", href: "#re-02", title: "Rewrite a listing description from public comps" },
{ id: "RE-03", title: "Offer-response notes from public remarks (later)", coming: true },
],
sources: [
"Sample MLS packet shipped with this chapter (non-confidential).",
"market-1pager and listing-voice templates in the recipe kit.",
"Local redaction fields: owner_name, phone, exact_address.",
],
};
/** Outline groups for the chapter map. Headings come from the essay objects. */
export function buildOutline(chapter, recipes) {
return [
{
label: "Start",
items: [{ id: "need", label: "What you need" }],
},
{
label: "Essay",
items: [chapter.stakes, chapter.constraints, chapter.practice, chapter.between].map((s) => ({
id: s.id,
label: s.heading,
})),
},
{
label: "Workflows",
items: recipes.map((r) => ({
id: r.anchor,
recipeId: r.id,
label: r.id + " · " + r.shortTitle,
time: r.time,
shortTitle: r.shortTitle,
tools: r.kit && r.kit.tools ? r.kit.tools.join(", ") : "",
})),
},
{
label: "End",
items: [
{ id: "further", label: "Further workflows" },
{ id: "sources", label: "Sources" },
],
},
];
}
/** Union of recipe kits for the chapter "What you need" strip. */
export function collectChapterKit(recipes) {
const tools = [];
const files = [];
const models = [];
const add = (arr, value) => {
if (value && !arr.includes(value)) arr.push(value);
};
recipes.forEach((r) => {
const k = r.kit || {};
(k.tools || []).forEach((item) => add(tools, item));
(k.files || []).forEach((item) => add(files, item));
add(models, k.model);
});
return {
tools,
files,
models,
tryits: recipes.map((r) => ({
id: r.id,
time: r.time,
cost: r.cost,
artifact: r.kit && r.kit.artifact,
})),
};
}
+56
View File
@@ -0,0 +1,56 @@
import Head from "next/head";
import Link from "next/link";
import ReaderNav from "../../components/reader/ReaderNav";
import ReaderFooter from "../../components/reader/ReaderFooter";
import { LIBRARY, RECIPES } from "../../data/sample-chapter";
export default function ReadLibrary() {
return (
<>
<Head>
<title>Library · AIBook.fun reading demo</title>
<meta
name="description"
content="Library for the 2026 AIBook.fun field guide. Six verticals. One preview chapter open."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<ReaderNav />
<main>
<section className="section contents reader-library">
<div className="wrap reader-wide">
<span className="spec demo-chip">Reading demo · not the live book</span>
<h1 className="library-title">Library</h1>
<p className="library-lede">
Six verticals for the 2026 edition.
</p>
<div className="toc">
{LIBRARY.map((item) =>
item.status === "ready" ? (
<Link href={item.href} key={item.num}>
<span className="num">{item.num}</span>
<span className="name">{item.name}</span>
<span className="desc">{item.desc}</span>
<span className="toc-count">{RECIPES.length} workflows</span>
<span className="arrow" aria-hidden="true">
→
</span>
</Link>
) : (
<div className="toc-soon" key={item.num} aria-disabled="true">
<span className="num">{item.num}</span>
<span className="name">{item.name}</span>
<span className="desc">{item.desc}</span>
<span className="soon">Coming</span>
</div>
)
)}
</div>
</div>
</section>
</main>
<ReaderFooter />
</>
);
}
+208
View File
@@ -0,0 +1,208 @@
import { useEffect, useMemo } from "react";
import Head from "next/head";
import ReaderNav from "../../components/reader/ReaderNav";
import ReaderCrumbs from "../../components/reader/ReaderCrumbs";
import ReaderFooter from "../../components/reader/ReaderFooter";
import WorkflowBlock from "../../components/reader/WorkflowBlock";
import ChapterOutline from "../../components/reader/ChapterOutline";
import WorkflowRail from "../../components/reader/WorkflowRail";
import ChapterKit from "../../components/reader/ChapterKit";
import useActivation from "../../components/reader/useActivation";
import { CHAPTER, RECIPES, buildOutline } from "../../data/sample-chapter";
const RECIPE_IDS = RECIPES.map((r) => r.id);
const ESSAY_KEYS = ["stakes", "constraints", "practice"];
function Essay({ id, heading, paragraphs }) {
return (
<section className="essay" id={id}>
<h2>{heading}</h2>
{paragraphs.map((p) => (
<p key={p}>{p}</p>
))}
</section>
);
}
export default function RealEstateChapter() {
const { complete, toggle, count, total } = useActivation(RECIPE_IDS);
const [first, second] = RECIPES;
const outline = useMemo(() => buildOutline(CHAPTER, RECIPES), []);
const essayGroup = outline.find((g) => g.label === "Essay");
const workflowGroup = outline.find((g) => g.label === "Workflows");
useEffect(() => {
const hash = window.location.hash;
if (!hash) return;
const el = document.getElementById(hash.slice(1));
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, []);
return (
<>
<Head>
<title>{CHAPTER.title} · AIBook.fun reading demo</title>
<meta name="description" content={CHAPTER.promise} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<ReaderNav here="chapter" />
<main>
<article className="chapter-page">
<div className="wrap reader-wide">
<ReaderCrumbs
items={[
{ href: "/read", label: "Library" },
{ label: CHAPTER.vertical, current: true },
]}
/>
</div>
<div className="wrap reader-wide chapter-shell">
<ChapterOutline
groups={outline}
complete={complete}
count={count}
total={total}
/>
<div className="chapter-col">
<span className="spec demo-chip">Reading demo · not the live book</span>
<p className="spec chapter-kicker">
{CHAPTER.vertical} · {CHAPTER.edition} edition
</p>
<h1 className="chapter-title">{CHAPTER.title}</h1>
<p className="chapter-by">{CHAPTER.authors}</p>
<ChapterOutline
variant="mobile"
groups={outline}
complete={complete}
count={count}
total={total}
/>
<p className="chapter-promise">{CHAPTER.promise}</p>
<ChapterKit recipes={RECIPES} />
<section className="chapter-map" aria-label="What's in this chapter">
<h2 className="chapter-map-title">What's in this chapter</h2>
<div className="chapter-map-block">
<p className="spec">Essay</p>
<ul>
{essayGroup.items.map((item) => (
<li key={item.id}>
<a href={`#${item.id}`}>{item.label}</a>
</li>
))}
</ul>
</div>
<div className="chapter-map-block">
<p className="spec">Workflows</p>
<ul>
{workflowGroup.items.map((item) => (
<li key={item.id}>
<a href={`#${item.id}`}>
{item.recipeId} · {item.shortTitle} · {item.time}
{item.tools ? ` · ${item.tools}` : ""}
</a>
</li>
))}
</ul>
</div>
</section>
<div className="skip-row">
<span className="skip-label">Skip to a Try-it</span>
{RECIPES.map((r) => (
<a key={r.id} className="skip-chip" href={`#${r.anchor}`}>
{r.id}
<span className="skip-chip-job">{r.shortTitle}</span>
</a>
))}
</div>
{ESSAY_KEYS.map((key) => (
<Essay
key={key}
id={CHAPTER[key].id}
heading={CHAPTER[key].heading}
paragraphs={CHAPTER[key].paragraphs}
/>
))}
<p className="spec workflow-kicker">
Workflow 1 of {RECIPES.length} in this chapter
</p>
<WorkflowBlock
recipe={first}
index={1}
total={RECIPES.length}
complete={!!complete[first.id]}
onToggleComplete={toggle}
/>
<Essay
id={CHAPTER.between.id}
heading={CHAPTER.between.heading}
paragraphs={CHAPTER.between.paragraphs}
/>
<p className="spec workflow-kicker">
Workflow 2 of {RECIPES.length} in this chapter
</p>
<WorkflowBlock
recipe={second}
index={2}
total={RECIPES.length}
complete={!!complete[second.id]}
onToggleComplete={toggle}
/>
<section className="end-matter" id="further">
<h2>Further workflows in this vertical</h2>
<ul className="further-list">
{CHAPTER.further.map((item) => (
<li key={item.id}>
{item.coming ? (
<>
<span className="recipe-id">
<b>{item.id}</b>
</span>{" "}
{item.title}
</>
) : (
<>
<a href={item.href}>
<span className="recipe-id">
<b>{item.id}</b>
</span>
</a>{" "}
{item.title}
</>
)}
</li>
))}
</ul>
</section>
<section className="end-sources" id="sources">
<h2>Sources</h2>
<ul className="further-list">
{CHAPTER.sources.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
<p className="chapter-updated">Last updated: {CHAPTER.lastUpdated}</p>
</section>
</div>
<WorkflowRail recipes={RECIPES} complete={complete} total={total} />
</div>
</article>
</main>
<ReaderFooter />
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import ReaderNav from "../../../components/reader/ReaderNav";
import ReaderCrumbs from "../../../components/reader/ReaderCrumbs";
import ReaderFooter from "../../../components/reader/ReaderFooter";
import WorkflowBlock from "../../../components/reader/WorkflowBlock";
import useActivation from "../../../components/reader/useActivation";
import { RECIPES, RECIPES_BY_ID } from "../../../data/sample-chapter";
export default function RecipeFocus() {
const router = useRouter();
const raw = typeof router.query.id === "string" ? router.query.id : "";
const recipe = RECIPES_BY_ID[raw] || RECIPES_BY_ID[raw.toUpperCase()] || null;
const { complete, toggle } = useActivation(recipe ? [recipe.id] : []);
if (!router.isReady) {
return (
<>
<ReaderNav here="chapter" />
<main>
<div className="wrap reader-wide recipe-focus-page">
<p className="spec">Loading recipe…</p>
</div>
</main>
</>
);
}
if (!recipe) {
return (
<>
<Head>
<title>Recipe not in this demo · AIBook.fun</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<ReaderNav />
<main>
<div className="wrap reader-wide recipe-focus-page">
<span className="spec demo-chip">Reading demo · not the live book</span>
<h1 className="chapter-title">This recipe is not in the sample.</h1>
<p className="library-lede">
The demo ships two nested workflows, RE-01 and RE-02, inside the Real
Estate chapter.
</p>
<p>
<Link href="/read/real-estate" className="btn btn-primary">
Back to the chapter
</Link>
</p>
</div>
</main>
<ReaderFooter />
</>
);
}
return (
<>
<Head>
<title>{`${recipe.id} on paper · AIBook.fun reading demo`}</title>
<meta name="description" content={recipe.title} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<ReaderNav here="chapter" />
<main>
<div className="wrap reader-wide recipe-focus-page">
<ReaderCrumbs
items={[
{ href: "/read", label: "Library" },
{ href: "/read/real-estate", label: "Real Estate" },
{ label: recipe.id, current: true },
]}
/>
<span className="spec demo-chip">Reading demo · not the live book</span>
<p className="spec chapter-kicker">Print / focus view</p>
<WorkflowBlock
recipe={recipe}
index={Math.max(RECIPES.findIndex((r) => r.id === recipe.id) + 1, 1)}
total={RECIPES.length}
complete={!!complete[recipe.id]}
onToggleComplete={toggle}
focused
/>
</div>
</main>
<ReaderFooter />
</>
);
}
@@ -0,0 +1,19 @@
{
"note": "Public comps only. No CRM fields, no owner names, no phones, no showing lists.",
"block": "14xx Cedar (sample street)",
"subject": {
"beds": 3,
"baths": 2,
"sqft": 1320,
"year_kitchen": 2019,
"lot": "corner, public record"
},
"comps": [
{ "id": "C1", "closed_price_sqft": 398, "dom": 19, "beds": 3, "baths": 2, "closed_days_ago": 18 },
{ "id": "C2", "closed_price_sqft": 441, "dom": 11, "beds": 3, "baths": 2, "closed_days_ago": 27 },
{ "id": "C3", "closed_price_sqft": 412, "dom": 22, "beds": 3, "baths": 2, "closed_days_ago": 41 },
{ "id": "C4", "closed_price_sqft": 425, "dom": 16, "beds": 3, "baths": 3, "closed_days_ago": 9 },
{ "id": "C5", "closed_price_sqft": 405, "dom": 28, "beds": 2, "baths": 2, "closed_days_ago": 55 },
{ "id": "C6", "closed_price_sqft": 433, "dom": 14, "beds": 3, "baths": 2, "closed_days_ago": 33 }
]
}
@@ -0,0 +1,7 @@
id,neighborhood,list_price,sqft,dom,owner_name,phone,exact_address,beds,baths
MH-01,Maple Heights,504000,1200,18,Jane Client,555-0140,12 Oak Street,3,2
MH-02,Maple Heights,612000,1450,24,Sam Owner,555-0141,88 Birch Avenue,3,2
MH-03,Maple Heights,459000,1100,31,R. Patel,555-0142,4 Cedar Court,2,2
MH-04,Maple Heights,670000,1580,12,Lee Family Trust,555-0143,21 Maple Drive,4,3
MH-05,Maple Heights,538000,1288,22,A. Gomez,555-0144,9 Pine Lane,3,2
MH-06,Maple Heights,481000,1140,27,Hidden,555-0145,16 Walnut Place,3,1
1 id neighborhood list_price sqft dom owner_name phone exact_address beds baths
2 MH-01 Maple Heights 504000 1200 18 Jane Client 555-0140 12 Oak Street 3 2
3 MH-02 Maple Heights 612000 1450 24 Sam Owner 555-0141 88 Birch Avenue 3 2
4 MH-03 Maple Heights 459000 1100 31 R. Patel 555-0142 4 Cedar Court 2 2
5 MH-04 Maple Heights 670000 1580 12 Lee Family Trust 555-0143 21 Maple Drive 4 3
6 MH-05 Maple Heights 538000 1288 22 A. Gomez 555-0144 9 Pine Lane 3 2
7 MH-06 Maple Heights 481000 1140 27 Hidden 555-0145 16 Walnut Place 3 1
+450 -8
View File
@@ -104,8 +104,7 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
/* ============ NAV ============ */
.nav {
position: sticky; top: 0; z-index: 50;
background: color-mix(in srgb, var(--color-paper) 82%, transparent);
backdrop-filter: blur(14px) saturate(1.3); -webkit-backdrop-filter: blur(14px) saturate(1.3);
background: var(--color-paper);
border-bottom: 1px solid transparent; transition: border-color 0.3s, box-shadow 0.3s;
}
.nav.scrolled { border-bottom-color: var(--color-line); box-shadow: var(--sh-sm); }
@@ -281,7 +280,7 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
/* ============ RECIPE (value centerpiece) ============ */
.recipe-head { max-width: 40rem; margin-bottom: 44px; }
.recipe-head h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(2rem, 4.3vw, 3.3rem); letter-spacing: -0.025em; line-height: 1.02; margin: 16px 0 18px; }
.recipe-head h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(2rem, 4.3vw, 3.3rem); letter-spacing: -0.025em; line-height: 1.15; margin: 16px 0 18px; }
.recipe-head p { color: var(--color-muted); font-size: 1.08rem; }
.recipe { background: var(--color-card); border: 1px solid var(--color-line); border-radius: 20px; box-shadow: var(--sh-lg); overflow: hidden; }
.recipe-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 28px; border-bottom: 1px solid var(--color-line); background: linear-gradient(var(--color-card), var(--color-paper)); }
@@ -327,7 +326,7 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
/* ============ CONTENTS (table-of-contents) ============ */
.contents { background: var(--color-paper-2); border-top: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); }
.contents h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(1.9rem, 4vw, 3rem); letter-spacing: -0.025em; margin: 14px 0 34px; max-width: 20ch; }
.contents h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(1.9rem, 4vw, 3rem); letter-spacing: -0.025em; line-height: 1.2; margin: 14px 0 34px; max-width: 20ch; }
.toc { border-top: 1px solid var(--color-line-2); }
.toc a { display: grid; grid-template-columns: 52px 1fr auto auto; align-items: center; gap: 20px; padding: 22px 8px; border-bottom: 1px solid var(--color-line); transition: background 0.2s, padding 0.2s; }
.toc a:hover { background: var(--color-pine-wash); padding-left: 16px; }
@@ -352,7 +351,7 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
@media (max-width: 600px) { .letter-in { padding: 0 20px; } }
/* ============ AUTHORS ============ */
.authors h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(1.9rem, 3.6vw, 2.7rem); letter-spacing: -0.025em; margin: 14px 0 10px; }
.authors h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(1.9rem, 3.6vw, 2.7rem); letter-spacing: -0.025em; line-height: 1.2; margin: 14px 0 10px; }
.authors .lede { color: var(--color-muted); max-width: 38rem; margin-bottom: 38px; }
.auth-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.auth { background: var(--color-card); border: 1px solid var(--color-line); border-radius: 16px; padding: 24px; box-shadow: var(--sh-sm); transition: box-shadow 0.2s, transform 0.2s; }
@@ -372,7 +371,7 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
/* ============ EMAIL CAPTURE ============ */
.capture { background: var(--color-paper-2); border-top: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); }
.capture-in { max-width: 640px; margin: 0 auto; text-align: center; padding: 0 28px; }
.capture h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(2rem, 4vw, 3rem); letter-spacing: -0.025em; line-height: 1.02; margin: 16px 0 14px; }
.capture h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(2rem, 4vw, 3rem); letter-spacing: -0.025em; line-height: 1.15; margin: 16px 0 14px; }
.capture p { color: var(--color-muted); font-size: 1.06rem; margin-bottom: 28px; }
.capture form { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
.capture .fine { font-family: var(--font-mono); font-size: 11.5px; color: var(--color-muted); margin-top: 18px; }
@@ -381,7 +380,7 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
/* ============ TIERS PREVIEW ============ */
.tiers-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; margin-bottom: 34px; }
.tiers-head h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(1.9rem, 3.6vw, 2.7rem); letter-spacing: -0.025em; margin: 12px 0 0; }
.tiers-head h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(1.9rem, 3.6vw, 2.7rem); letter-spacing: -0.025em; line-height: 1.2; margin: 12px 0 0; }
.tiers-note { font-family: var(--font-mono); font-size: 12px; color: var(--color-muted); max-width: 22rem; text-align: right; line-height: 1.6; }
.tier-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.tier { background: var(--color-card); border: 1px solid var(--color-line); border-radius: 18px; padding: 30px 26px; display: flex; flex-direction: column; transition: box-shadow 0.2s, transform 0.2s; }
@@ -399,7 +398,7 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
@media (max-width: 820px) { .tier-grid { grid-template-columns: 1fr; } .tiers-note { text-align: left; } }
/* ============ FAQ ============ */
.faq h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(1.9rem, 3.6vw, 2.7rem); letter-spacing: -0.025em; margin-bottom: 34px; }
.faq h2 { font-family: var(--font-display); font-weight: 900; font-size: clamp(1.9rem, 3.6vw, 2.7rem); letter-spacing: -0.025em; line-height: 1.2; margin-bottom: 34px; }
.qa { border-top: 1px solid var(--color-line); }
.qa details { border-bottom: 1px solid var(--color-line); padding: 6px 0; }
.qa summary { cursor: pointer; list-style: none; padding: 20px 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; font-family: var(--font-display); font-weight: 600; font-size: 1.2rem; letter-spacing: -0.02em; }
@@ -421,3 +420,446 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
.foot-links a:hover { color: #f7f5f0; }
.colophon { padding-top: 26px; font-family: var(--font-mono); font-size: 11.5px; color: #7a7266; letter-spacing: 0.02em; line-height: 1.8; }
@media (max-width: 600px) { .foot-in { padding: 48px 20px 40px; } }
/* ============ READER DEMO ============ */
/* Layout the landing page does not already have. Reader pages use a wider wrap.
Same tokens, same ~20px cards and 999px pills. Do not restyle the homepage recipe. */
.demo-chip {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono); font-size: 11px; font-weight: 500;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--color-amber);
background: rgba(180, 121, 31, 0.08);
border: 1px solid rgba(180, 121, 31, 0.22);
padding: 5px 12px; border-radius: 999px;
margin-bottom: 22px;
}
.reader-nav .nav-in,
.reader-footer .foot-in,
.wrap.reader-wide {
max-width: min(1520px, calc(100vw - 40px));
}
.crumbs {
padding: 4px 0 18px;
font-size: 13.5px;
}
.crumbs ol {
display: flex; flex-wrap: wrap; align-items: center;
list-style: none; margin: 0; padding: 0;
}
.crumbs li { display: flex; align-items: center; }
.crumbs-sep {
margin: 0 10px; color: var(--color-muted); font-weight: 400;
}
.crumbs a { color: var(--color-pine); font-weight: 600; }
.crumbs a:hover { color: var(--color-pine-deep); }
.crumbs [aria-current="page"] { color: var(--color-ink); font-weight: 600; }
.reader-nav .nav-title {
font-size: 14.5px; font-weight: 600; color: var(--color-ink);
}
.reader-nav .nav-title:hover { color: var(--color-pine); }
.reader-nav .reader-links { gap: 22px; }
.reader-nav .nav-current {
font-size: 14.5px; font-weight: 600; color: var(--color-ink);
max-width: 22ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
@media (max-width: 860px) {
.reader-nav .nav-links {
display: flex; align-items: center; gap: 16px; font-size: 13.5px;
}
.reader-nav .nav-cta {
display: inline-flex; font-size: 13px; padding: 8px 14px;
}
.reader-nav .nav-current { display: none; }
.reader-nav .nav-toggle { display: none; }
}
.chapter-page { padding: 48px 0 96px; }
.chapter-shell {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 32px 40px;
align-items: start;
}
@media (min-width: 1080px) {
.chapter-shell {
grid-template-columns: 240px minmax(0, 1fr) 280px;
}
}
.chapter-col { min-width: 0; }
.chapter-kicker { margin: 8px 0 14px; }
.chapter-title {
font-family: var(--font-display); font-weight: 900;
font-size: clamp(2.1rem, 4.6vw, 3.4rem);
letter-spacing: -0.028em; line-height: 1.15; margin: 0 0 12px;
max-width: 22ch;
}
.chapter-by {
font-family: var(--font-mono); font-size: 12.5px; color: var(--color-muted);
letter-spacing: 0.04em; margin-bottom: 20px;
}
.chapter-promise {
font-size: 1.12rem; line-height: 1.62; color: var(--color-ink); margin: 0 0 28px;
max-width: 46rem;
}
.chapter-outline {
position: sticky;
top: 68px;
z-index: 30;
align-self: start;
background: var(--color-paper);
border-right: 1px solid var(--color-line);
padding: 16px 20px 28px 0;
max-height: calc(100vh - 68px);
overflow-y: auto;
}
.outline-progress {
font-family: var(--font-mono); font-size: 11.5px; color: var(--color-muted);
letter-spacing: 0.04em; margin: 0 0 14px;
}
.outline-heading {
font-family: var(--font-display); font-weight: 600;
font-size: 1.08rem; letter-spacing: -0.02em; margin: 0 0 16px;
}
.outline-group { margin: 0 0 18px; }
.outline-group:last-child { margin-bottom: 0; }
.outline-group-label { margin: 0 0 8px; }
.outline-group ul { list-style: none; margin: 0; padding: 0; }
.outline-group li { margin: 0; }
.outline-group a {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
padding: 5px 0 5px 10px;
border-left: 2px solid transparent;
font-size: 13.5px; line-height: 1.35; color: var(--color-muted);
}
.outline-group a:hover { color: var(--color-ink); }
.outline-group a.is-active {
color: var(--color-pine); border-left-color: var(--color-pine); font-weight: 600;
}
.outline-done {
font-family: var(--font-mono); font-size: 12px; color: var(--color-pine); flex: none;
}
.chapter-rail {
position: sticky;
top: 84px;
align-self: start;
display: none;
}
@media (min-width: 1080px) {
.chapter-rail { display: block; }
}
.chapter-rail .spec { margin: 0 0 14px; }
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.rail-card {
display: flex; flex-direction: column; gap: 6px;
padding: 16px 18px;
background: var(--color-card);
border: 1px solid var(--color-line);
border-radius: 16px;
box-shadow: var(--sh-sm);
transition: border-color 0.18s, transform 0.14s var(--ease), box-shadow 0.18s;
}
.rail-card:hover {
border-color: var(--color-pine-tint);
transform: translateY(-1px);
box-shadow: var(--sh-md);
}
.rail-card.is-done { border-color: var(--color-pine-tint); background: var(--color-pine-wash); }
.rail-kicker {
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--color-pine);
}
.rail-title {
font-family: var(--font-display); font-weight: 600;
font-size: 1.15rem; letter-spacing: -0.02em; line-height: 1.2;
color: var(--color-ink);
}
.rail-meta {
font-family: var(--font-mono); font-size: 12px; color: var(--color-muted);
letter-spacing: 0.02em;
}
.rail-skip {
display: inline-block; margin-top: 16px;
font-family: var(--font-mono); font-size: 12.5px; font-weight: 600;
color: var(--color-pine);
}
.rail-skip:hover { color: var(--color-pine-deep); }
.chapter-outline-mobile {
margin: 0 0 24px;
padding: 10px 16px 12px;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-card);
}
.chapter-outline-mobile summary {
cursor: pointer; list-style: none;
font-family: var(--font-display); font-weight: 600;
font-size: 1.05rem; letter-spacing: -0.02em;
padding: 6px 0;
}
.chapter-outline-mobile summary::-webkit-details-marker { display: none; }
.chapter-outline-mobile[open] summary { margin-bottom: 10px; }
.chapter-outline-mobile .outline-progress { margin-top: 4px; }
.chapter-kit {
margin: 0 0 28px;
padding: 18px 20px 16px;
background: var(--color-pine-wash);
border: 1px solid var(--color-pine-tint);
border-radius: 16px;
}
.chapter-kit-title {
font-family: var(--font-display); font-weight: 600;
font-size: 1.2rem; letter-spacing: -0.02em;
margin: 0 0 6px;
}
.chapter-kit-lede {
font-size: 14.5px; color: var(--color-muted);
margin: 0 0 16px;
}
.kit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 28px;
margin: 0;
}
@media (min-width: 900px) {
.kit-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.kit-grid { grid-template-columns: 1fr; }
}
.kit-grid dt {
font-family: var(--font-mono); font-size: 11px; font-weight: 500;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--color-pine); margin: 0 0 6px;
}
.kit-grid dd { margin: 0; font-size: 14.5px; line-height: 1.4; color: var(--color-ink); }
.kit-grid ul { list-style: none; margin: 0; padding: 0; }
.kit-grid li { margin: 0 0 4px; }
.kit-grid li:last-child { margin-bottom: 0; }
.workflow-kit {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 12px 16px;
margin: 4px 0 8px;
padding: 14px 0 16px;
border-bottom: 1px dashed var(--color-line-2);
}
.workflow-kit dt {
font-family: var(--font-mono); font-size: 11px; font-weight: 500;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--color-pine); margin: 0 0 4px;
}
.workflow-kit dd {
margin: 0; font-size: 14px; line-height: 1.4; color: var(--color-ink);
}
.rail-kit {
font-size: 12.5px; line-height: 1.35; color: var(--color-muted);
}
.rail-out { color: var(--color-ink); }
.chapter-map {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 40px;
margin: 0 0 8px;
padding: 18px 0 6px;
border-top: 1px solid var(--color-line);
}
.chapter-map-title { grid-column: 1 / -1; }
@media (max-width: 720px) {
.chapter-map { grid-template-columns: 1fr; }
}
.chapter-map-title {
font-family: var(--font-display); font-weight: 600;
font-size: 1.2rem; letter-spacing: -0.02em;
margin: 0 0 16px;
}
.chapter-map-block { margin: 0 0 16px; }
.chapter-map-block .spec { margin: 0 0 8px; }
.chapter-map-block ul { margin: 0; padding: 0 0 0 1.1em; }
.chapter-map-block li { margin: 0 0 6px; }
.chapter-map-block a { color: var(--color-pine); font-weight: 600; }
.chapter-map-block a:hover { color: var(--color-pine-deep); }
.skip-row {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
margin: 0 0 48px; padding: 16px 0;
border-top: 1px dashed var(--color-line-2);
border-bottom: 1px dashed var(--color-line-2);
}
.skip-label {
font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-pine);
margin-right: 4px;
}
.skip-chip {
display: inline-flex; align-items: baseline; gap: 8px;
font-family: var(--font-mono); font-size: 12.5px; color: var(--color-pine);
background: var(--color-pine-wash); border: 1px solid var(--color-pine-tint);
padding: 7px 13px; border-radius: 999px;
transition: background 0.18s, transform 0.14s var(--ease);
}
.skip-chip:hover { background: var(--color-pine-tint); transform: translateY(-1px); }
.skip-chip:focus-visible { outline: 2px solid var(--color-pine); outline-offset: 3px; }
.skip-chip-job {
font-family: var(--font-body); font-size: 13px; font-weight: 600;
color: var(--color-ink); letter-spacing: 0; text-transform: none;
}
.essay,
.recipe-nested,
.end-matter,
.end-sources,
.recipe-nested .row {
scroll-margin-top: 88px;
}
.essay { margin: 0 0 36px; max-width: 46rem; }
.essay h2 {
font-family: var(--font-display); font-weight: 600;
font-size: 1.45rem; letter-spacing: -0.02em; line-height: 1.2;
margin: 0 0 12px;
}
.essay p { color: var(--color-ink); margin: 0 0 14px; }
.essay p:last-child { margin-bottom: 0; }
.workflow-kicker { margin: 8px 0 10px; }
.recipe-nested {
margin: 0 0 52px;
}
.recipe-nested .step-copy { min-width: 0; flex: 1; }
.recipe-nested .step-copy .term { margin-top: 12px; }
.recipe-nested h4.k { margin: 0; font-weight: 500; }
.recipe-nested .term {
overflow-x: auto;
overflow-y: hidden;
}
.recipe-nested .term pre { overflow-x: auto; }
.recipe-jump {
display: flex; flex-wrap: wrap; gap: 8px 16px;
margin: 0 0 8px; padding: 0 0 16px;
border-bottom: 1px dashed var(--color-line-2);
font-size: 13.5px;
}
.recipe-jump a { color: var(--color-pine); font-weight: 600; }
.recipe-jump a:hover { color: var(--color-pine-deep); }
.recipe-jump a:focus-visible { outline: 2px solid var(--color-pine); outline-offset: 2px; }
.plain-list { margin: 0; padding: 0 0 0 1.1em; }
.plain-list li { margin: 0 0 6px; }
.plain-list li:last-child { margin-bottom: 0; }
.evidence-p { margin: 0 0 10px; }
.evidence-p:last-child { margin-bottom: 0; }
.sub-line { color: var(--color-muted); font-size: 14.5px; margin: 8px 0 12px; }
.inline-sample {
font-family: var(--font-mono); font-size: 13px; color: var(--color-pine);
border-bottom: 1px solid var(--color-pine-tint);
}
.inline-sample:hover { color: var(--color-pine-deep); }
.tryit-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
.tryit-actions .btn { font-size: 14px; padding: 11px 18px; }
.tryit-done { gap: 8px; }
.recipe-record {
display: flex; flex-direction: column; gap: 6px;
font-size: 14.5px; color: var(--color-muted);
}
.recipe-record a { color: var(--color-pine); font-weight: 600; }
.recipe-record a:hover { color: var(--color-pine-deep); }
.end-matter {
margin-top: 56px; padding-top: 28px;
border-top: 1px solid var(--color-line);
}
.end-sources { margin-top: 32px; }
.end-matter h2,
.end-sources h2 {
font-family: var(--font-display); font-weight: 600;
font-size: 1.35rem; letter-spacing: -0.02em; margin: 0 0 12px;
}
.end-matter h2 + .further-list { margin-bottom: 8px; }
.further-list { margin: 0 0 8px; padding: 0 0 0 1.1em; color: var(--color-ink); }
.further-list li { margin: 0 0 8px; }
.further-list a { color: var(--color-pine); font-weight: 600; }
.chapter-updated {
font-family: var(--font-mono); font-size: 12px; color: var(--color-muted);
letter-spacing: 0.04em; margin-top: 18px;
}
.library-title {
font-family: var(--font-display); font-weight: 900;
font-size: clamp(1.9rem, 4vw, 3rem); letter-spacing: -0.025em;
line-height: 1.15; margin: 14px 0 16px;
white-space: nowrap;
}
.library-lede { color: var(--color-muted); max-width: 38rem; margin-bottom: 34px; }
.toc .toc-soon {
display: grid; grid-template-columns: 52px 1fr auto auto;
align-items: center; gap: 20px; padding: 22px 8px;
border-bottom: 1px solid var(--color-line);
color: var(--color-muted);
}
.toc .soon {
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--color-muted);
background: var(--color-card); border: 1px solid var(--color-line);
padding: 4px 10px; border-radius: 999px; white-space: nowrap;
}
.reader-library .toc a {
grid-template-columns: 52px 1fr auto auto auto;
}
.toc-count {
font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--color-pine); white-space: nowrap;
}
@media (max-width: 720px) {
.toc .toc-soon .desc { display: none; }
.toc .toc-soon { grid-template-columns: 40px 1fr auto; }
.reader-library .toc a { grid-template-columns: 40px 1fr auto auto; }
}
.recipe-focus-page { padding: 40px 0 96px; }
.recipe-focus-page .demo-chip { margin-bottom: 12px; }
.recipe-focus-page .recipe { max-width: none; }
.focus-back {
font-family: var(--font-mono); font-size: 13px; color: var(--color-muted);
margin: 0 0 28px;
}
.focus-back a { color: var(--color-pine); font-weight: 600; }
@media (max-width: 959px) {
.chapter-shell { grid-template-columns: 1fr; gap: 0; }
.chapter-outline { display: none; }
.chapter-rail { display: none; }
}
@media (min-width: 960px) {
.chapter-outline-mobile { display: none; }
}
@media print {
.reader-nav, .chapter-outline, .chapter-outline-mobile, .chapter-rail, .reader-footer, .skip-row, .tryit-actions, .demo-chip, .recipe-jump {
display: none !important;
}
.recipe-nested { break-inside: avoid; box-shadow: none; }
}